You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
LightWork3D/docs/superpowers/plans/2026-05-20-freecad-fcstd-te...

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.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:

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, and register_commands.
  • Command names are QET_Template_AddTerminal and QET_Template_ValidateTerminals.