18 KiB
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.pytoFreeCADExchange_Scripts.
- Add
- Modify:
src/Mod/FreeCADExchange/InitGui.py- Import
TemplateAuthoring. - Call
TemplateAuthoring.register_commands().
- Import
- 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:
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:
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:
# 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:
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:
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:
import TemplateAuthoring
Add registration after the existing command registration blocks:
try:
TemplateAuthoring.register_commands()
except Exception:
pass
- Step 3: Modify CMakeLists.txt
Add TemplateAuthoring.py to FreeCADExchange_Scripts:
TemplateAuthoring.py
Place it beside TemplateSemantics.py.
- Step 4: Verify syntax and tests
Run:
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:
- 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。
- Step 2: Run final verification
Run:
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:
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, andregister_commands. - Command names are
QET_Template_AddTerminalandQET_Template_ValidateTerminals.