# FreeCAD FCStd Template Authoring Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a FreeCADExchange Python authoring tool that turns imported STEP/STP/STE geometry into reusable FCStd equipment templates with electrical terminal LCS semantics. **Architecture:** Keep the feature in the FreeCADExchange Python layer. `TemplateAuthoring.py` owns template terminal creation, validation, and command registration; `TerminalObjects.py` remains the shared low-level semantic property helper; `InitGui.py` registers authoring commands when FreeCAD starts. **Tech Stack:** FreeCAD Python API, `Part::LocalCoordinateSystem` / `PartDesign::CoordinateSystem`, Python `unittest`, existing FreeCADExchange command registration pattern. --- ## File Structure - Create: `src/Mod/FreeCADExchange/TemplateAuthoring.py` - Build template terminal LCS objects. - Write template-only electrical semantics. - Validate template terminal objects. - Register FreeCAD commands. - Modify: `src/Mod/FreeCADExchange/CMakeLists.txt` - Add `TemplateAuthoring.py` to `FreeCADExchange_Scripts`. - Modify: `src/Mod/FreeCADExchange/InitGui.py` - Import `TemplateAuthoring`. - Call `TemplateAuthoring.register_commands()`. - Create: `tests/python/freecad_exchange_template_authoring_test.py` - Fake enough FreeCAD API to test terminal creation and validation without launching FreeCAD. - Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md` - Add development result entry after implementation. --- ### Task 1: Add Failing Tests For Template Terminal Creation **Files:** - Create: `tests/python/freecad_exchange_template_authoring_test.py` - [ ] **Step 1: Write the failing test file** Create `tests/python/freecad_exchange_template_authoring_test.py` with: ```python import importlib import sys import types import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange" if str(MODULE_DIR) not in sys.path: sys.path.insert(0, str(MODULE_DIR)) def _install_fake_freecad(): class Vector: def __init__(self, x=0.0, y=0.0, z=0.0): self.x = float(x) self.y = float(y) self.z = float(z) class Rotation: def __init__(self, axis=None, angle=None): self.Axis = axis self.Angle = angle class Placement: def __init__(self, base=None, rotation=None): self.Base = base self.Rotation = rotation fake_freecad = types.ModuleType("FreeCAD") fake_freecad.Vector = Vector fake_freecad.Rotation = Rotation fake_freecad.Placement = Placement fake_freecad.ActiveDocument = None fake_freecad.Console = types.SimpleNamespace( PrintMessage=lambda *args, **kwargs: None, PrintWarning=lambda *args, **kwargs: None, PrintError=lambda *args, **kwargs: None, ) sys.modules["FreeCAD"] = fake_freecad fake_freecadgui = types.ModuleType("FreeCADGui") fake_freecadgui.addCommand = lambda *args, **kwargs: None fake_freecadgui.Selection = types.SimpleNamespace(getSelectionEx=lambda: []) sys.modules["FreeCADGui"] = fake_freecadgui class FakeViewObject: def __init__(self): self.Visibility = True self.ShapeColor = None class FakeObject: def __init__(self, name, type_id): self.Name = name self.Label = name self.TypeId = type_id self.PropertiesList = [] self.Group = [] self.ViewObject = FakeViewObject() self.Placement = None def isDerivedFrom(self, type_name): if self.TypeId == type_name: return True if type_name == "App::DocumentObjectGroup": return self.TypeId == "App::DocumentObjectGroup" if type_name == "App::LocalCoordinateSystem": return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"} return False def addProperty(self, prop_type, prop_name, group_name, description): if prop_name not in self.PropertiesList: self.PropertiesList.append(prop_name) def addObject(self, child): if child not in self.Group: self.Group.append(child) class FakeDocument: def __init__(self): self.Name = "TemplateDoc" self.Objects = [] self.recomputed = False def addObject(self, type_name, name): obj = FakeObject(name, type_name) self.Objects.append(obj) return obj def getObject(self, name): for obj in self.Objects: if obj.Name == name: return obj return None def recompute(self): self.recomputed = True def _reload_modules(): for name in ["TerminalObjects", "TemplateAuthoring"]: sys.modules.pop(name, None) import TemplateAuthoring return TemplateAuthoring class TemplateAuthoringTest(unittest.TestCase): def test_create_template_terminal_writes_lcs_semantics(self): _install_fake_freecad() template_authoring = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal = template_authoring.create_template_terminal( doc, "P1", app.Vector(10, 20, 30), terminal_type="primary", ) self.assertEqual("Terminal_P1", terminal.Name) self.assertEqual("P1", terminal.Label) self.assertEqual("Terminal", terminal.Role) self.assertTrue(terminal.CanWire) self.assertEqual("P1", terminal.QetTemplateSlotName) self.assertEqual("P1", terminal.QetTerminalLabel) self.assertEqual("primary", terminal.QetTerminalType) self.assertEqual(10.0, terminal.Placement.Base.x) self.assertEqual(20.0, terminal.Placement.Base.y) self.assertEqual(30.0, terminal.Placement.Base.z) self.assertTrue(doc.recomputed) def test_validate_template_terminals_reports_missing_slot_name(self): _install_fake_freecad() template_authoring = _reload_modules() doc = FakeDocument() terminal = doc.addObject("Part::LocalCoordinateSystem", "BrokenTerminal") terminal.addProperty("App::PropertyString", "Role", "QET Template", "role") terminal.Role = "Terminal" terminal.addProperty("App::PropertyBool", "CanWire", "QET Template", "can wire") terminal.CanWire = True report = template_authoring.validate_template_terminals(doc) self.assertEqual(1, report["total_terminals"]) self.assertEqual(0, report["valid_terminals"]) self.assertEqual(1, len(report["warnings"])) self.assertIn("QetTemplateSlotName", report["warnings"][0]) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run the test and verify RED** Run: ```powershell python -m unittest tests.python.freecad_exchange_template_authoring_test -v ``` Expected: fails with `ModuleNotFoundError: No module named 'TemplateAuthoring'`. --- ### Task 2: Implement TemplateAuthoring Core **Files:** - Create: `src/Mod/FreeCADExchange/TemplateAuthoring.py` - [ ] **Step 1: Create the implementation** Create `src/Mod/FreeCADExchange/TemplateAuthoring.py` with: ```python # FreeCADExchange FCStd template authoring helpers. import FreeCAD as App try: import FreeCADGui as Gui except ImportError: Gui = None import TerminalObjects TEMPLATE_PROPERTY_GROUP = "QET Template" DEFAULT_TERMINAL_TYPE = "generic" class TemplateAuthoringError(RuntimeError): pass def _safe_slot_name(slot_name): value = (slot_name or "").strip() if not value: raise TemplateAuthoringError("Terminal slot name is required.") return value def _terminal_object_name(slot_name): return "Terminal_{0}".format(TerminalObjects.safe_token(slot_name)) def _ensure_template_property(obj, prop_name, value, prop_type="App::PropertyString"): if prop_type == "App::PropertyBool": TerminalObjects.ensure_bool_property( obj, prop_name, TEMPLATE_PROPERTY_GROUP, "QET template terminal property", bool(value), ) else: TerminalObjects.ensure_string_property( obj, prop_name, TEMPLATE_PROPERTY_GROUP, "QET template terminal property", value, ) def set_template_terminal_semantics(obj, slot_name, label="", terminal_type=DEFAULT_TERMINAL_TYPE): slot_name = _safe_slot_name(slot_name) label = (label or "").strip() or slot_name terminal_type = (terminal_type or "").strip() or DEFAULT_TERMINAL_TYPE _ensure_template_property(obj, "Role", TerminalObjects.TERMINAL_ROLE) _ensure_template_property(obj, "CanWire", True, prop_type="App::PropertyBool") _ensure_template_property(obj, "QetTemplateSlotName", slot_name) _ensure_template_property(obj, "QetTerminalLabel", label) _ensure_template_property(obj, "QetTerminalType", terminal_type) obj.Label = label return obj def create_template_terminal(doc, slot_name, position, rotation=None, label="", terminal_type=DEFAULT_TERMINAL_TYPE): if doc is None: raise TemplateAuthoringError("An active FreeCAD document is required.") slot_name = _safe_slot_name(slot_name) if position is None: raise TemplateAuthoringError("A terminal position is required.") if rotation is None: rotation = App.Rotation() placement = App.Placement(position, rotation) terminal = TerminalObjects.create_lcs_object( doc, _terminal_object_name(slot_name), placement=placement, label=(label or slot_name), ) set_template_terminal_semantics( terminal, slot_name, label=label or slot_name, terminal_type=terminal_type, ) try: terminal.ViewObject.ShapeColor = (0.0, 0.75, 1.0) except Exception: pass doc.recompute() return terminal def is_template_terminal(obj): if obj is None: return False return TerminalObjects.is_terminal_hint_object(obj) def _has_property(obj, prop_name): return prop_name in getattr(obj, "PropertiesList", []) def validate_template_terminals(doc): report = { "document_name": getattr(doc, "Name", ""), "total_terminals": 0, "valid_terminals": 0, "warnings": [], "terminals": [], } if doc is None: report["warnings"].append("No active FreeCAD document.") return report for obj in list(getattr(doc, "Objects", []) or []): if not is_template_terminal(obj): continue report["total_terminals"] += 1 slot_name = getattr(obj, "QetTemplateSlotName", "").strip() can_wire = bool(getattr(obj, "CanWire", False)) item = { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "slot_name": slot_name, "can_wire": can_wire, } report["terminals"].append(item) valid = True if not _has_property(obj, "QetTemplateSlotName") or not slot_name: report["warnings"].append( "Template terminal {0} is missing QetTemplateSlotName.".format( getattr(obj, "Name", "") ) ) valid = False if not can_wire: report["warnings"].append( "Template terminal {0} has CanWire disabled.".format( getattr(obj, "Name", "") ) ) valid = False if valid: report["valid_terminals"] += 1 return report def _selection_position(): if Gui is None: return None try: selection_ex = Gui.Selection.getSelectionEx() except Exception: return None if not selection_ex: return None picked = selection_ex[0] picked_points = list(getattr(picked, "PickedPoints", []) or []) if picked_points: return picked_points[0] obj = getattr(picked, "Object", None) shape = getattr(obj, "Shape", None) bound_box = getattr(shape, "BoundBox", None) if bound_box is None: return None return App.Vector( (bound_box.XMin + bound_box.XMax) * 0.5, (bound_box.YMin + bound_box.YMax) * 0.5, (bound_box.ZMin + bound_box.ZMax) * 0.5, ) ``` - [ ] **Step 2: Run the focused test and verify GREEN** Run: ```powershell python -m unittest tests.python.freecad_exchange_template_authoring_test -v ``` Expected: both tests pass. --- ### Task 3: Add FreeCAD Commands And Registration **Files:** - Modify: `src/Mod/FreeCADExchange/TemplateAuthoring.py` - Modify: `src/Mod/FreeCADExchange/InitGui.py` - Modify: `src/Mod/FreeCADExchange/CMakeLists.txt` - [ ] **Step 1: Extend TemplateAuthoring.py with commands** Append this code to `src/Mod/FreeCADExchange/TemplateAuthoring.py`: ```python class CommandAddTemplateTerminal: def GetResources(self): return { "MenuText": "Add Template Terminal", "ToolTip": "Create a reusable electrical terminal LCS for an FCStd equipment template", } def IsActive(self): return App.ActiveDocument is not None and Gui is not None def Activated(self): if Gui is None: return position = _selection_position() if position is None: App.Console.PrintWarning( "Select a model point or model object before adding a template terminal.\n" ) return slot_name = "T{0}".format(len(validate_template_terminals(App.ActiveDocument)["terminals"]) + 1) try: create_template_terminal(App.ActiveDocument, slot_name, position) App.Console.PrintMessage( "[FreeCADExchange] Created template terminal {0}. Rename QetTemplateSlotName if needed.\n".format(slot_name) ) except Exception as exc: App.Console.PrintError( "[FreeCADExchange] template terminal creation failed: {0}\n".format(exc) ) class CommandValidateTemplateTerminals: def GetResources(self): return { "MenuText": "Validate Template Terminals", "ToolTip": "Validate electrical terminal LCS objects in the current FCStd template", } def IsActive(self): return App.ActiveDocument is not None def Activated(self): report = validate_template_terminals(App.ActiveDocument) App.Console.PrintMessage( "[FreeCADExchange] Template terminals: {0} total, {1} valid\n".format( report["total_terminals"], report["valid_terminals"], ) ) for warning in report["warnings"]: App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) _COMMANDS_REGISTERED = False def register_commands(): global _COMMANDS_REGISTERED if _COMMANDS_REGISTERED: return if Gui is None: return Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) _COMMANDS_REGISTERED = True register_commands() ``` - [ ] **Step 2: Modify InitGui.py** Add the import near the existing FreeCADExchange imports: ```python import TemplateAuthoring ``` Add registration after the existing command registration blocks: ```python try: TemplateAuthoring.register_commands() except Exception: pass ``` - [ ] **Step 3: Modify CMakeLists.txt** Add `TemplateAuthoring.py` to `FreeCADExchange_Scripts`: ```cmake TemplateAuthoring.py ``` Place it beside `TemplateSemantics.py`. - [ ] **Step 4: Verify syntax and tests** Run: ```powershell python -m py_compile src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py python -m unittest tests.python.freecad_exchange_template_authoring_test -v ``` Expected: py_compile exits 0, tests pass. --- ### Task 4: Add Development Result Documentation **Files:** - Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md` - [ ] **Step 1: Add the result entry** Append to the development log section: ```markdown - 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 ``` - [ ] **Step 2: Run final verification** Run: ```powershell python -m py_compile src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py src/Mod/FreeCADExchange/TerminalObjects.py python -m unittest tests.python.freecad_exchange_template_authoring_test tests.python.freecad_exchange_template_semantics_test tests.python.freecad_exchange_manual_wiring_test -v ``` Expected: all tests pass. - [ ] **Step 3: Commit** Run: ```powershell git add src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py src/Mod/FreeCADExchange/CMakeLists.txt tests/python/freecad_exchange_template_authoring_test.py "docs/FreeCAD 端子显示连线保存回写开发文档.md" "docs/FreeCAD 二次开发说明.md" docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md git commit -m "feature/FCStd设备模板端子制作-zwl-0520" ``` Expected: commit succeeds and does not include `artifacts/`, `video_frames/`, or generated cache files. --- ## Self-Review Spec coverage: - FCStd as formal asset is covered by Tasks 2 and 4. - Template-only terminal semantics are covered by Task 1 and Task 2. - Command entry points are covered by Task 3. - Existing project import flow remains unchanged. Placeholder scan: - The plan contains no unresolved placeholders or open-ended implementation steps. Type consistency: - The planned module exposes `create_template_terminal`, `set_template_terminal_semantics`, `validate_template_terminals`, and `register_commands`. - Command names are `QET_Template_AddTerminal` and `QET_Template_ValidateTerminals`.