25 KiB
FreeCAD Electrical Auto Routing 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: Finish the first production-ready FreeCAD electrical auto-routing loop from QET wires[] tasks to saved AutoSuggested 3D wires with actionable diagnostics.
Architecture: Keep FreeCAD as the 3D truth source and QET as the electrical truth source. QET exports richer wires[] task metadata; FreeCAD consumes that task list, prepares a route-carrier graph, runs Dijkstra, stores routed wires and diagnostics in scene.FCStd, and never writes 3D wire geometry into the database.
Tech Stack: FreeCAD Python API, Qt/C++ QET export service, JSON exchange files, unittest, fake FreeCAD test harness, FreeCAD smoke scripts.
Scope Check
The approved spec touches two repos, but the work is still one coherent feature because QET only supplies task metadata and FreeCAD owns all routing behavior. Treat the QET change as a small upstream contract task. Do not run QET build commands in D:\code\zwl; that repo's AGENTS.md forbids build, cmake, make, and ninja.
File Structure
- Modify:
D:\code\zwl\sources\FreeCAD\FreeCADExchangeExportService.cpp- Responsibility: include
start_instance_idandend_instance_idin exportedwires[]entries so FreeCAD can bind local template terminals more reliably.
- Responsibility: include
- Modify:
src/Mod/FreeCADExchange/WiringImport.py- Responsibility: preserve
wire_style_id, start/end instance IDs, and endpoint display metadata onQETWiring_01_Tasks.
- Responsibility: preserve
- Modify:
src/Mod/FreeCADExchange/WiringObjects.py- Responsibility: provide shared helpers for wire task/routed wire metadata, including route length and diagnostic payload fields.
- Modify:
src/Mod/FreeCADExchange/AutoRouting.py- Responsibility: compute route length, carry
wire_style_id, create diagnostic summaries, and enrich batch reports.
- Responsibility: compute route length, carry
- Modify:
src/Mod/FreeCADExchange/AutoRoutingPanel.py- Responsibility: show the improved batch summary without adding extra workflow steps.
- Modify:
tests/python/freecad_exchange_wiring_import_test.py- Responsibility: lock
wires[]task import metadata.
- Responsibility: lock
- Modify:
tests/python/freecad_exchange_auto_routing_test.py- Responsibility: lock auto-route metadata, diagnostic group output, and one-click reporting.
- Modify:
tests/manual/freecad_auto_routing_smoke.py- Responsibility: verify the end-to-end FreeCAD document can save, reopen, and retain auto routes.
Task 1: Add QET wire endpoint instance IDs to wires[]
Files:
-
Modify:
D:\code\zwl\sources\FreeCAD\FreeCADExchangeExportService.cpp:412-477 -
Modify:
D:\code\zwl\sources\FreeCAD\FreeCADExchangeExportService.cpp:928 -
Step 1: Confirm the current export gap
Run:
rg -n "start_instance_id|end_instance_id|buildWireObjectsForDiagram" D:\code\zwl\sources\FreeCAD\FreeCADExchangeExportService.cpp
Expected before this task: buildWireObjectsForDiagram exists, but start_instance_id and end_instance_id are not inserted into wireObject.
- Step 2: Change the function signature
In D:\code\zwl\sources\FreeCAD\FreeCADExchangeExportService.cpp, change the function header from:
QJsonArray buildWireObjectsForDiagram(Diagram *diagram)
to:
QJsonArray buildWireObjectsForDiagram(
Diagram *diagram,
const QHash<QUuid, QString> &terminalInstanceIds)
- Step 3: Insert endpoint instance IDs
Inside the loop that builds wireObject, immediately after the existing terminal UUID inserts, add:
const QString startInstanceId = terminalInstanceIds.value(startTerminalUuid).trimmed();
const QString endInstanceId = terminalInstanceIds.value(endTerminalUuid).trimmed();
wireObject.insert(QStringLiteral("start_instance_id"), startInstanceId);
wireObject.insert(QStringLiteral("end_instance_id"), endInstanceId);
The surrounding block should read:
wireObject.insert(QStringLiteral("start_element_uuid"), uuidText(startElementUuid));
wireObject.insert(QStringLiteral("start_terminal_uuid"), uuidText(startTerminalUuid));
wireObject.insert(QStringLiteral("start_instance_id"), startInstanceId);
wireObject.insert(QStringLiteral("end_element_uuid"), uuidText(endElementUuid));
wireObject.insert(QStringLiteral("end_terminal_uuid"), uuidText(endTerminalUuid));
wireObject.insert(QStringLiteral("end_instance_id"), endInstanceId);
- Step 4: Pass the binding map from the exporter
Replace:
wiresArray = buildWireObjectsForDiagram(diagram);
with:
wiresArray = buildWireObjectsForDiagram(diagram, terminalInstanceIds);
- Step 5: Run static verification
Run:
rg -n "start_instance_id|end_instance_id|buildWireObjectsForDiagram\\(" D:\code\zwl\sources\FreeCAD\FreeCADExchangeExportService.cpp
Expected: one function definition with the new parameter, one call that passes terminalInstanceIds, and two JSON inserts for start_instance_id / end_instance_id.
- Step 6: Commit the QET contract change
Run in D:\code\zwl:
git status --short
git add -- sources/FreeCAD/FreeCADExchangeExportService.cpp
git commit -m "feat: export freecad wire endpoint instances"
Expected: the commit contains only sources/FreeCAD/FreeCADExchangeExportService.cpp.
Task 2: Preserve wire style and endpoint metadata on FreeCAD tasks
Files:
-
Modify:
src/Mod/FreeCADExchange/WiringImport.py:47-109 -
Modify:
src/Mod/FreeCADExchange/WiringImport.py:145-190 -
Modify:
src/Mod/FreeCADExchange/WiringObjects.py:181-213 -
Test:
tests/python/freecad_exchange_wiring_import_test.py -
Step 1: Write the failing import test
Add this test to tests/python/freecad_exchange_wiring_import_test.py inside WiringImportTest:
def test_import_wire_tasks_preserves_auto_routing_metadata(self):
wiring_import = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-1",
"wire_label": "W1",
"wire_mark": "N4111",
"wire_mark_is_manual": True,
"wire_style_id": 42,
"net_uuid": "net-1",
"group_uuid": "group-1",
"start_element_uuid": "device-a",
"start_instance_id": "instance-a",
"start_terminal_uuid": "terminal-a",
"start_terminal_display": "A1",
"end_element_uuid": "device-b",
"end_instance_id": "instance-b",
"end_terminal_uuid": "terminal-b",
"end_terminal_display": "B1",
}
],
}
report = wiring_import.import_wire_tasks_from_payload(payload, doc)
task = doc.getObject("QETWiring_01_Tasks").Group[0]
self.assertEqual(1, report["imported_tasks"])
self.assertEqual("42", task.QetWireStyleId)
self.assertEqual("instance-a", task.QetStartInstanceId)
self.assertEqual("instance-b", task.QetEndInstanceId)
self.assertEqual("A1", task.QetStartTerminalDisplay)
self.assertEqual("B1", task.QetEndTerminalDisplay)
- Step 2: Run the test and verify it fails
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_wiring_import_test.WiringImportTest.test_import_wire_tasks_preserves_auto_routing_metadata
Expected: FAIL because QetWireStyleId is not set.
- Step 3: Extend normalized wire entries
In src/Mod/FreeCADExchange/WiringImport.py, add this helper near _bool_value:
def _int_text_value(item, field_name):
value = item.get(field_name, "")
if value is None:
return ""
try:
return str(int(value)).strip()
except Exception:
return str(value).strip()
Then add this field in _normalize_wire_entry:
"wire_style_id": _int_text_value(item, "wire_style_id"),
- Step 4: Persist the style ID on tasks
In _set_task_extra_properties, add:
_ensure_string_property(task, "QetWireStyleId", entry["wire_style_id"])
- Step 5: Run the import test again
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_wiring_import_test.WiringImportTest.test_import_wire_tasks_preserves_auto_routing_metadata
Expected: PASS.
- Step 6: Run the wiring import test file
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_wiring_import_test
Expected: all tests in the file pass.
- Step 7: Commit
Run:
git add src/Mod/FreeCADExchange/WiringImport.py tests/python/freecad_exchange_wiring_import_test.py
git commit -m "feat: preserve freecad wire task metadata"
Expected: the commit contains the import code and its test.
Task 3: Store auto-route length and wire style diagnostics
Files:
-
Modify:
src/Mod/FreeCADExchange/AutoRouting.py:260-300 -
Modify:
src/Mod/FreeCADExchange/AutoRouting.py:860-970 -
Modify:
src/Mod/FreeCADExchange/AutoRouting.py:1169-1190 -
Test:
tests/python/freecad_exchange_auto_routing_test.py -
Step 1: Write the failing routed-wire metadata test
Add this test to tests/python/freecad_exchange_auto_routing_test.py inside AutoRoutingTest:
def test_auto_route_stores_length_and_wire_style_diagnostics(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(100, 0, 20)],
project_uuid="project-1",
kind="WireDuct",
)
result = auto_routing.route_between_terminals(
doc,
start,
end,
wire_uuid="wire-1",
wire_label="N4111",
options={"wire_style_id": "42"},
)
wire = result["wire"]
payload = json.loads(wire.QetAutoRouteDiagnosticsJson)
self.assertGreater(float(wire.QetAutoRouteLengthMm), 0.0)
self.assertEqual("42", wire.QetWireStyleId)
self.assertEqual("42", payload["wire_style_id"])
self.assertGreater(payload["length_mm"], 0.0)
If the test file does not already import json, add:
import json
- Step 2: Run the new test and verify it fails
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest.test_auto_route_stores_length_and_wire_style_diagnostics
Expected: FAIL because QetAutoRouteLengthMm and QetWireStyleId are not set on routed wires.
- Step 3: Add route length helpers
In src/Mod/FreeCADExchange/AutoRouting.py, add near _point_payload:
def _route_length(points):
total = 0.0
normalized = [_vector(point) for point in points or []]
for index in range(len(normalized) - 1):
total += _distance(normalized[index], normalized[index + 1])
return total
- Step 4: Add wire style and length to the diagnostic payload
Change _route_payload to accept wire_style_id:
def _route_payload(route_data, collisions, wire_style_id=""):
points = route_data.get("points", [])
return {
"algorithm": route_data.get("algorithm", ""),
"length_mm": _route_length(points),
"wire_style_id": str(wire_style_id or "").strip(),
"points": [_point_payload(point) for point in points],
"collision_count": len(collisions),
"collisions": collisions,
"network": route_data.get("network", {}),
}
Change _set_auto_metadata to accept and store wire_style_id:
def _set_auto_metadata(wire, route_data, collisions, wire_style_id=""):
length_mm = _route_length(route_data.get("points", []))
_set_string(wire, "QetAutoRouteAlgorithm", route_data.get("algorithm", ""), "Auto-routing algorithm used for this wire")
_set_string(wire, "QetAutoRouteLengthMm", "{0:.3f}".format(length_mm), "Auto route length in millimeters")
_set_string(wire, "QetWireStyleId", str(wire_style_id or "").strip(), "QET wire style ID")
_set_string(
wire,
"QetAutoRouteDiagnosticsJson",
json.dumps(_route_payload(route_data, collisions, wire_style_id=wire_style_id), ensure_ascii=False),
"Auto-routing diagnostics for this wire",
)
if route_data.get("network"):
_set_string(
wire,
"QetAutoRouteNetworkJson",
json.dumps(route_data.get("network", {}), ensure_ascii=False),
"Route network metadata used by this wire",
)
- Step 5: Thread
wire_style_idthrough routing
Add a parameter to route_between_terminals:
wire_style_id="",
Before metadata is written, merge explicit parameter and options:
effective_wire_style_id = str(wire_style_id or opts.get("wire_style_id", "") or "").strip()
Then replace:
_set_auto_metadata(wire, route_data, collisions)
with:
_set_auto_metadata(wire, route_data, collisions, wire_style_id=effective_wire_style_id)
- Step 6: Pass task style from payload and task objects
In route_all_from_payload, pass:
wire_style_id=_wire_item_value(item, "wire_style_id"),
In _wire_tasks_payload, add:
"wire_style_id": (getattr(task, "QetWireStyleId", "") or "").strip(),
- Step 7: Run the focused test
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest.test_auto_route_stores_length_and_wire_style_diagnostics
Expected: PASS.
- Step 8: Run the auto-routing test file
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test
Expected: all tests in the file pass.
- Step 9: Commit
Run:
git add src/Mod/FreeCADExchange/AutoRouting.py tests/python/freecad_exchange_auto_routing_test.py
git commit -m "feat: record freecad auto route diagnostics"
Expected: the commit contains route metadata and tests.
Task 4: Write batch diagnostics into QETWiring_05_Diagnostics
Files:
-
Modify:
src/Mod/FreeCADExchange/AutoRouting.py:1022-1152 -
Modify:
src/Mod/FreeCADExchange/WiringObjects.py:153-156 -
Test:
tests/python/freecad_exchange_auto_routing_test.py -
Step 1: Write the failing diagnostics group test
Add this test to tests/python/freecad_exchange_auto_routing_test.py inside AutoRoutingTest:
def test_route_all_writes_diagnostic_object_for_missing_terminal(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-1",
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-missing",
}
],
}
report = auto_routing.route_all_from_payload(doc, payload)
diagnostic_group = doc.getObject("QETWiring_05_Diagnostics")
self.assertEqual(1, report["skipped_missing_terminal"])
self.assertIsNotNone(diagnostic_group)
self.assertEqual(1, len(diagnostic_group.Group))
diagnostic = diagnostic_group.Group[0]
self.assertEqual("AutoRouteBatch", diagnostic.QetDiagnosticKind)
self.assertIn("terminal-missing", diagnostic.QetDiagnosticJson)
- Step 2: Run the new test and verify it fails
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest.test_route_all_writes_diagnostic_object_for_missing_terminal
Expected: FAIL because no diagnostic object is created.
- Step 3: Add diagnostic helper functions
In src/Mod/FreeCADExchange/AutoRouting.py, add after format_route_all_report:
def _clear_auto_route_batch_diagnostics(doc):
group = WiringObjects.ensure_diagnostic_group(doc, _project_uuid(doc))
removed = 0
for obj in list(getattr(group, "Group", []) or []):
if (getattr(obj, "QetDiagnosticKind", "") or "").strip() != "AutoRouteBatch":
continue
try:
group.removeObject(obj)
except Exception:
pass
try:
if doc.getObject(getattr(obj, "Name", "")) is not None:
doc.removeObject(obj.Name)
removed += 1
except Exception:
pass
return removed
def _write_auto_route_batch_diagnostic(doc, report):
if doc is None or not isinstance(report, dict):
return None
if not report.get("errors") and not report.get("missing_endpoint_uuids") and report.get("collision_warnings", 0) <= 0:
return None
project_uuid = _project_uuid(doc)
group = WiringObjects.ensure_diagnostic_group(doc, project_uuid)
_clear_auto_route_batch_diagnostics(doc)
diagnostic = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETAutoRouteDiagnostic"))
diagnostic.Label = "QET Auto Route Diagnostic"
_set_string(diagnostic, "QetDiagnosticKind", "AutoRouteBatch", "QET diagnostic kind")
_set_string(
diagnostic,
"QetDiagnosticJson",
json.dumps(report, ensure_ascii=False),
"QET auto-routing batch diagnostic payload",
)
group.addObject(diagnostic)
return diagnostic
- Step 4: Call the diagnostic writer
At the end of route_all_from_payload, immediately before return report, add:
_write_auto_route_batch_diagnostic(doc, report)
- Step 5: Run the focused test
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest.test_route_all_writes_diagnostic_object_for_missing_terminal
Expected: PASS.
- Step 6: Run auto-routing tests
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test
Expected: all tests pass.
- Step 7: Commit
Run:
git add src/Mod/FreeCADExchange/AutoRouting.py tests/python/freecad_exchange_auto_routing_test.py
git commit -m "feat: write freecad auto routing diagnostics"
Expected: the commit contains diagnostic object behavior and tests.
Task 5: Improve one-click panel summary
Files:
-
Modify:
src/Mod/FreeCADExchange/AutoRouting.py:1116-1152 -
Modify:
src/Mod/FreeCADExchange/AutoRoutingPanel.py:137-160 -
Test:
tests/python/freecad_exchange_auto_routing_test.py -
Step 1: Write the failing report text test
Add this test to tests/python/freecad_exchange_auto_routing_test.py inside AutoRoutingTest:
def test_route_all_report_includes_network_and_first_error(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
report = {
"total_wires": 2,
"routed": 1,
"collision_warnings": 1,
"skipped_missing_terminal": 1,
"prepared_layout": {
"wire_duct_carriers": 2,
"surface_carriers": 4,
"terminal_access_carriers": 6,
},
"missing_endpoint_samples": [
{
"start_terminal_uuid": "terminal-a",
"end_terminal_uuid": "terminal-b",
}
],
"errors": ["没有可用的线槽/路由路径网络"],
}
message = auto_routing.format_route_all_report(report)
self.assertIn("routed=1", message)
self.assertIn("线槽路径 2 条", message)
self.assertIn("首个错误:没有可用的线槽/路由路径网络", message)
self.assertIn("缺失示例:terminal-a -> terminal-b", message)
- Step 2: Run the new report test
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest.test_route_all_report_includes_network_and_first_error
Expected: FAIL because the first error is not included.
- Step 3: Extend
format_route_all_report
In src/Mod/FreeCADExchange/AutoRouting.py, inside format_route_all_report, after the prepared layout block, add:
errors = report.get("errors", []) or []
if errors:
message += "\n首个错误:{0}".format(str(errors[0]))
Then ensure the missing endpoint sample block runs whenever a sample exists:
sample = (report.get("missing_endpoint_samples") or [None])[0]
if sample:
message += "\n缺失示例:{0} -> {1}".format(
sample.get("start_terminal_uuid", ""),
sample.get("end_terminal_uuid", ""),
)
- Step 4: Run the focused test
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest.test_route_all_report_includes_network_and_first_error
Expected: PASS.
- Step 5: Run auto-routing tests
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest tests.python.freecad_exchange_auto_routing_test
Expected: all tests pass.
- Step 6: Commit
Run:
git add src/Mod/FreeCADExchange/AutoRouting.py tests/python/freecad_exchange_auto_routing_test.py
git commit -m "fix: clarify freecad auto routing report"
Expected: the commit contains only report-formatting code and tests.
Task 6: Add save and reopen coverage to the FreeCAD smoke
Files:
-
Modify:
tests/manual/freecad_auto_routing_smoke.py -
Step 1: Extend smoke output assertions
In tests/manual/freecad_auto_routing_smoke.py, after the current document save path is created, ensure the script saves and reopens the FCStd:
doc.saveAs(OUT_FCSTD)
App.closeDocument(doc.Name)
reopened = App.openDocument(OUT_FCSTD)
routed_group = reopened.getObject("QETWiring_04_Routed")
reopened_wires = list(getattr(routed_group, "Group", []) or []) if routed_group else []
result_payload["reopened_routed_wire_count"] = len(reopened_wires)
result_payload["reopened_has_auto_route"] = any(
(getattr(wire, "RouteType", "") or "").strip() == "AutoSuggested"
for wire in reopened_wires
)
If the script currently writes JSON before save/reopen, move the JSON write after the new payload fields are set.
- Step 2: Run the manual smoke
Run:
D:\FreeCAD-1.1.1\bin\python.exe tests\manual\freecad_auto_routing_smoke.py
Expected: exit code 0 and JSON output contains:
"reopened_has_auto_route": true
- Step 3: Commit
Run:
git add tests/manual/freecad_auto_routing_smoke.py
git commit -m "test: verify freecad auto routes survive reopen"
Expected: the commit contains only the manual smoke update.
Task 7: Final verification
Files:
-
Read:
docs/superpowers/specs/2026-05-28-freecad-electrical-auto-routing-design.md -
Read: changed files from Tasks 1-6
-
Step 1: Run FreeCADExchange unit tests
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m unittest discover -s tests\python -p "freecad_exchange*_test.py"
Expected: all discovered tests pass.
- Step 2: Run FreeCADExchange syntax check
Run:
D:\FreeCAD-1.1.1\bin\python.exe -m py_compile src\Mod\FreeCADExchange\AutoRouting.py src\Mod\FreeCADExchange\RoutingNetwork.py src\Mod\FreeCADExchange\AutoRoutingPanel.py src\Mod\FreeCADExchange\WiringImport.py src\Mod\FreeCADExchange\WiringObjects.py
Expected: exit code 0 with no syntax errors.
- Step 3: Run manual smoke
Run:
D:\FreeCAD-1.1.1\bin\python.exe tests\manual\freecad_auto_routing_smoke.py
Expected: exit code 0, output JSON exists, and reopened auto route is true.
- Step 4: Verify no forbidden first-version database dependency was introduced
Run:
rg -n "project_3d_scene_instance|project_3d_space_object|project_2d3d_link|start_end_terminal_matches|connection_point_key|terminal_key" src\Mod\FreeCADExchange docs\superpowers\plans\2026-05-28-freecad-electrical-auto-routing.md
Expected: no matches in src/Mod/FreeCADExchange; matches in this plan are allowed only because this verification command names forbidden strings.
- Step 5: Verify staged and unstaged diffs
Run:
git status --short
git diff --check
Expected: no whitespace errors. Untracked screenshots and diagnostic JSON files may remain; do not stage them.
- Step 6: Commit final docs if the plan was adjusted during execution
Run only if this plan file changed during execution:
git add docs/superpowers/plans/2026-05-28-freecad-electrical-auto-routing.md
git commit -m "docs: update freecad auto routing plan"
Expected: commit contains only the plan document.