feature/完善待装配设备和布线-zwl-0618

dev
Zhaowenlong 6 days ago
parent e861c2408a
commit f78f30509f

@ -8,6 +8,7 @@ This directory contains a reusable electrical panel assembly model based on the
- `qet_panel_assembly.step`: geometry-only exchange export.
- `qet_panel_assembly_report.json`: generated metadata for verification.
- `create_qet_panel_assembly.py`: FreeCAD Python generator used to recreate the asset.
- `verify_qet_panel_assembly_contacts.py`: checks the key face-contact constraints after regeneration.
## Geometry
@ -15,11 +16,11 @@ The model is a medium-detail approximation of the full assembly:
- Pale gray cabinet / door body.
- Thick side edge and recessed door panel.
- Two circular hinge / screw markers.
- Dark right-side mounting plate.
- Dark rear mounting plate centered on the main body's wide face.
- Two vertical white perforated connector banks.
- Small lower accessory connector modules.
- Yellow wire-frame style guide geometry matching the highlighted wiring envelope in the reference.
The left thin side face touches the main rectangular body directly. The rear mounting plate is attached to the main body's wide face and centered on it, and the connector banks / lower accessory connectors sit on that mounting plate.
Approximate dimensions:
@ -40,4 +41,5 @@ On this Windows workstation, use the registered FreeCAD runtime:
$runtime = Get-Content -LiteralPath 'C:\Users\ng123\AppData\Local\QETDeps\runtime.json' -Raw | ConvertFrom-Json
$env:QET_FREECAD_RUNTIME_JSON = 'C:\Users\ng123\AppData\Local\QETDeps\runtime.json'
& $runtime.freecad_python 'D:\LightWork3D\data\examples\qet_panel_assembly\create_qet_panel_assembly.py'
& $runtime.freecad_python 'D:\LightWork3D\data\examples\qet_panel_assembly\verify_qet_panel_assembly_contacts.py'
```

@ -84,30 +84,6 @@ def _cylinder_y(doc, name, radius, length, x, y, z, color):
return obj
def _wire_segment(doc, name, start, end, thickness, color):
sx, sy, sz = start
ex, ey, ez = end
dx = abs(ex - sx) or thickness
dy = abs(ey - sy) or thickness
dz = abs(ez - sz) or thickness
x = min(sx, ex) - (thickness / 2.0 if ex == sx else 0.0)
y = min(sy, ey) - (thickness / 2.0 if ey == sy else 0.0)
z = min(sz, ez) - (thickness / 2.0 if ez == sz else 0.0)
return _box(doc, name, dx, dy, dz, x, y, z, color, 0)
def _wire_rect(doc, prefix, x0, y0, z0, width, height, depth_offset=0.0):
color = (1.0, 0.86, 0.05)
y = y0 + depth_offset
thickness = 0.55
return [
_wire_segment(doc, prefix + "_Left", (x0, y, z0), (x0, y, z0 + height), thickness, color),
_wire_segment(doc, prefix + "_Right", (x0 + width, y, z0), (x0 + width, y, z0 + height), thickness, color),
_wire_segment(doc, prefix + "_Top", (x0, y, z0 + height), (x0 + width, y, z0 + height), thickness, color),
_wire_segment(doc, prefix + "_Bottom", (x0, y, z0), (x0 + width, y, z0), thickness, color),
]
def _export_step(objects):
try:
import Import
@ -141,24 +117,11 @@ def _create_connector_bank(doc, prefix, x, y, z, rows, cols, plate_height, plate
1.0,
0.7,
px,
y - 0.15,
y + 4.15,
pz,
dark,
)
)
if col == 0:
objects.append(
_cylinder_y(
doc,
"{0}_Screw_R{1:02d}".format(prefix, row + 1),
0.55,
0.8,
x + plate_width + 2.6,
y - 0.15,
pz,
metal,
)
)
return objects
@ -172,46 +135,37 @@ def main():
dark_panel = (0.22, 0.2, 0.26)
white = (0.95, 0.94, 0.96)
black = (0.02, 0.02, 0.02)
mount_x = -47.5
mount_y = 30.0
component_y = 36.0
# Cabinet / door, roughly matching the large pale box in the reference video.
objects.extend(
[
_box(doc, "Panel_BackBox", 110.0, 55.0, 180.0, -80.0, -25.0, 0.0, light, 0),
_box(doc, "Panel_LeftDoorFace", 6.0, 60.0, 178.0, -88.0, -28.0, 1.0, edge, 0),
_box(doc, "Panel_LeftDoorFace", 6.0, 60.0, 178.0, -86.0, -28.0, 1.0, edge, 0),
_box(doc, "Panel_InnerRecess", 72.0, 1.4, 132.0, -68.0, -29.0, 24.0, (0.9, 0.92, 0.96), 18),
_box(doc, "Panel_RecessLeftLine", 1.2, 1.8, 132.0, -68.0, -30.0, 24.0, edge, 0),
_box(doc, "Panel_RecessRightLine", 1.2, 1.8, 132.0, 2.8, -30.0, 24.0, edge, 0),
_box(doc, "Panel_RecessTopLine", 72.0, 1.8, 1.2, -68.0, -30.0, 155.0, edge, 0),
_box(doc, "Panel_RecessBottomLine", 72.0, 1.8, 1.2, -68.0, -30.0, 24.0, edge, 0),
_cylinder_y(doc, "Panel_HingeTop", 2.3, 1.0, -85.0, -31.0, 134.0, (0.18, 0.18, 0.2)),
_cylinder_y(doc, "Panel_HingeBottom", 2.3, 1.0, -85.0, -31.0, 44.0, (0.18, 0.18, 0.2)),
_box(doc, "Panel_RightMountPlate", 45.0, 6.0, 168.0, 30.0, -31.0, 6.0, dark_panel, 0),
_box(doc, "Panel_RightMountPlate", 45.0, 6.0, 168.0, mount_x, mount_y, 6.0, dark_panel, 0),
]
)
# Connector banks and small accessory blocks on the right side.
objects.extend(_create_connector_bank(doc, "ConnectorBank_Left", 35.0, -36.0, 44.0, 10, 2, 110.0, 18.0))
objects.extend(_create_connector_bank(doc, "ConnectorBank_Right", 60.0, -37.0, 50.0, 12, 3, 102.0, 20.0))
objects.extend(
[
_box(doc, "ConnectorBank_LeftTopCap", 18.0, 4.2, 12.0, 35.0, -36.2, 154.0, white, 0),
_box(doc, "ConnectorBank_RightTopCap", 20.0, 4.2, 11.0, 60.0, -37.2, 152.0, white, 0),
_box(doc, "AccessoryConnector_LowerLeft", 16.0, 4.0, 34.0, 26.0, -37.0, 18.0, white, 0),
_box(doc, "AccessoryConnector_LowerRight", 14.0, 4.0, 28.0, 70.0, -38.0, 22.0, white, 0),
_cylinder_y(doc, "AccessoryConnector_LowerLeftScrew1", 0.8, 0.7, 34.0, -38.1, 28.0, black),
_cylinder_y(doc, "AccessoryConnector_LowerLeftScrew2", 0.8, 0.7, 34.0, -38.1, 42.0, black),
_cylinder_y(doc, "AccessoryConnector_LowerRightScrew1", 0.8, 0.7, 77.0, -39.1, 30.0, black),
_cylinder_y(doc, "AccessoryConnector_LowerRightScrew2", 0.8, 0.7, 77.0, -39.1, 44.0, black),
]
)
# Yellow annotation-like wire frames from the source video.
objects.extend(_wire_rect(doc, "WireFrame_LeftBank", 32.0, -41.0, 34.0, 27.0, 130.0))
objects.extend(_wire_rect(doc, "WireFrame_RightBank", 56.0, -42.0, 38.0, 33.0, 126.0, -1.2))
objects.extend(_create_connector_bank(doc, "ConnectorBank_Left", mount_x + 5.0, component_y, 44.0, 10, 2, 110.0, 18.0))
objects.extend(_create_connector_bank(doc, "ConnectorBank_Right", mount_x + 30.0, component_y, 50.0, 12, 3, 102.0, 20.0))
objects.extend(
[
_wire_segment(doc, "WireFrame_TopBridge", (45.0, -42.0, 158.0), (88.0, -42.0, 158.0), 0.55, (1.0, 0.86, 0.05)),
_wire_segment(doc, "WireFrame_BottomBridge", (42.0, -42.0, 34.0), (88.0, -42.0, 34.0), 0.55, (1.0, 0.86, 0.05)),
_box(doc, "ConnectorBank_LeftTopCap", 18.0, 4.2, 12.0, mount_x + 5.0, component_y, 154.0, white, 0),
_box(doc, "ConnectorBank_RightTopCap", 20.0, 4.2, 11.0, mount_x + 30.0, component_y, 152.0, white, 0),
_box(doc, "AccessoryConnector_LowerLeft", 16.0, 4.0, 34.0, mount_x - 4.0, component_y, 18.0, white, 0),
_box(doc, "AccessoryConnector_LowerRight", 14.0, 4.0, 28.0, mount_x + 36.0, component_y, 22.0, white, 0),
_cylinder_y(doc, "AccessoryConnector_LowerLeftScrew1", 0.8, 0.7, mount_x + 4.0, component_y + 4.1, 28.0, black),
_cylinder_y(doc, "AccessoryConnector_LowerLeftScrew2", 0.8, 0.7, mount_x + 4.0, component_y + 4.1, 42.0, black),
_cylinder_y(doc, "AccessoryConnector_LowerRightScrew1", 0.8, 0.7, mount_x + 43.0, component_y + 4.1, 30.0, black),
_cylinder_y(doc, "AccessoryConnector_LowerRightScrew2", 0.8, 0.7, mount_x + 43.0, component_y + 4.1, 44.0, black),
]
)

File diff suppressed because it is too large Load Diff

@ -17,91 +17,67 @@
"Panel_RecessRightLine",
"Panel_RecessTopLine",
"Panel_RecessBottomLine",
"Panel_HingeTop",
"Panel_HingeBottom",
"Panel_RightMountPlate",
"ConnectorBank_Left_Body",
"ConnectorBank_Left_SideRailLeft",
"ConnectorBank_Left_SideRailRight",
"ConnectorBank_Left_Hole_R01_C01",
"ConnectorBank_Left_Screw_R01",
"ConnectorBank_Left_Hole_R01_C02",
"ConnectorBank_Left_Hole_R02_C01",
"ConnectorBank_Left_Screw_R02",
"ConnectorBank_Left_Hole_R02_C02",
"ConnectorBank_Left_Hole_R03_C01",
"ConnectorBank_Left_Screw_R03",
"ConnectorBank_Left_Hole_R03_C02",
"ConnectorBank_Left_Hole_R04_C01",
"ConnectorBank_Left_Screw_R04",
"ConnectorBank_Left_Hole_R04_C02",
"ConnectorBank_Left_Hole_R05_C01",
"ConnectorBank_Left_Screw_R05",
"ConnectorBank_Left_Hole_R05_C02",
"ConnectorBank_Left_Hole_R06_C01",
"ConnectorBank_Left_Screw_R06",
"ConnectorBank_Left_Hole_R06_C02",
"ConnectorBank_Left_Hole_R07_C01",
"ConnectorBank_Left_Screw_R07",
"ConnectorBank_Left_Hole_R07_C02",
"ConnectorBank_Left_Hole_R08_C01",
"ConnectorBank_Left_Screw_R08",
"ConnectorBank_Left_Hole_R08_C02",
"ConnectorBank_Left_Hole_R09_C01",
"ConnectorBank_Left_Screw_R09",
"ConnectorBank_Left_Hole_R09_C02",
"ConnectorBank_Left_Hole_R10_C01",
"ConnectorBank_Left_Screw_R10",
"ConnectorBank_Left_Hole_R10_C02",
"ConnectorBank_Right_Body",
"ConnectorBank_Right_SideRailLeft",
"ConnectorBank_Right_SideRailRight",
"ConnectorBank_Right_Hole_R01_C01",
"ConnectorBank_Right_Screw_R01",
"ConnectorBank_Right_Hole_R01_C02",
"ConnectorBank_Right_Hole_R01_C03",
"ConnectorBank_Right_Hole_R02_C01",
"ConnectorBank_Right_Screw_R02",
"ConnectorBank_Right_Hole_R02_C02",
"ConnectorBank_Right_Hole_R02_C03",
"ConnectorBank_Right_Hole_R03_C01",
"ConnectorBank_Right_Screw_R03",
"ConnectorBank_Right_Hole_R03_C02",
"ConnectorBank_Right_Hole_R03_C03",
"ConnectorBank_Right_Hole_R04_C01",
"ConnectorBank_Right_Screw_R04",
"ConnectorBank_Right_Hole_R04_C02",
"ConnectorBank_Right_Hole_R04_C03",
"ConnectorBank_Right_Hole_R05_C01",
"ConnectorBank_Right_Screw_R05",
"ConnectorBank_Right_Hole_R05_C02",
"ConnectorBank_Right_Hole_R05_C03",
"ConnectorBank_Right_Hole_R06_C01",
"ConnectorBank_Right_Screw_R06",
"ConnectorBank_Right_Hole_R06_C02",
"ConnectorBank_Right_Hole_R06_C03",
"ConnectorBank_Right_Hole_R07_C01",
"ConnectorBank_Right_Screw_R07",
"ConnectorBank_Right_Hole_R07_C02",
"ConnectorBank_Right_Hole_R07_C03",
"ConnectorBank_Right_Hole_R08_C01",
"ConnectorBank_Right_Screw_R08",
"ConnectorBank_Right_Hole_R08_C02",
"ConnectorBank_Right_Hole_R08_C03",
"ConnectorBank_Right_Hole_R09_C01",
"ConnectorBank_Right_Screw_R09",
"ConnectorBank_Right_Hole_R09_C02",
"ConnectorBank_Right_Hole_R09_C03",
"ConnectorBank_Right_Hole_R10_C01",
"ConnectorBank_Right_Screw_R10",
"ConnectorBank_Right_Hole_R10_C02",
"ConnectorBank_Right_Hole_R10_C03",
"ConnectorBank_Right_Hole_R11_C01",
"ConnectorBank_Right_Screw_R11",
"ConnectorBank_Right_Hole_R11_C02",
"ConnectorBank_Right_Hole_R11_C03",
"ConnectorBank_Right_Hole_R12_C01",
"ConnectorBank_Right_Screw_R12",
"ConnectorBank_Right_Hole_R12_C02",
"ConnectorBank_Right_Hole_R12_C03",
"ConnectorBank_LeftTopCap",
@ -111,17 +87,7 @@
"AccessoryConnector_LowerLeftScrew1",
"AccessoryConnector_LowerLeftScrew2",
"AccessoryConnector_LowerRightScrew1",
"AccessoryConnector_LowerRightScrew2",
"WireFrame_LeftBank_Left",
"WireFrame_LeftBank_Right",
"WireFrame_LeftBank_Top",
"WireFrame_LeftBank_Bottom",
"WireFrame_RightBank_Left",
"WireFrame_RightBank_Right",
"WireFrame_RightBank_Top",
"WireFrame_RightBank_Bottom",
"WireFrame_TopBridge",
"WireFrame_BottomBridge"
"AccessoryConnector_LowerRightScrew2"
],
"engineering_binding_properties_present": {},
"terminal_role_objects": []

@ -0,0 +1,134 @@
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def _bootstrap_windows_freecad_runtime():
if os.name != "nt":
return
runtime_json = os.environ.get("QET_FREECAD_RUNTIME_JSON")
if not runtime_json:
runtime_json = os.path.join(os.environ.get("LOCALAPPDATA", ""), "QETDeps", "runtime.json")
if not runtime_json or not os.path.exists(runtime_json):
return
with open(runtime_json, "r", encoding="utf-8-sig") as handle:
runtime = json.load(handle)
roots = [str(item) for item in runtime.get("path_prefix", []) if item]
freecad_root = runtime.get("freecad_root", "")
if freecad_root:
roots.extend(
[
os.path.join(freecad_root, "build", "Mod", "Material"),
os.path.join(freecad_root, "build", "Mod", "Part"),
os.path.join(freecad_root, "build", "Mod", "Import"),
os.path.join(freecad_root, "build", "Mod"),
]
)
roots.append(os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "downlevel"))
for root in roots:
if root and os.path.isdir(root):
try:
os.add_dll_directory(root)
except (AttributeError, OSError):
pass
if root not in sys.path:
sys.path.append(root)
_bootstrap_windows_freecad_runtime()
import FreeCAD as App
OUT_DIR = Path(__file__).resolve().parent
FCSTD_PATH = OUT_DIR / "qet_panel_assembly.FCStd"
STEP_PATH = OUT_DIR / "qet_panel_assembly.step"
TOLERANCE = 0.01
def _bbox(doc, name):
obj = doc.getObject(name)
if obj is None:
raise AssertionError("missing object: {0}".format(name))
bbox = obj.Shape.BoundBox
return bbox
def _assert_close(label, actual, expected):
if abs(actual - expected) > TOLERANCE:
raise AssertionError("{0}: expected {1:.3f}, got {2:.3f}".format(label, expected, actual))
def main():
if not FCSTD_PATH.exists():
raise AssertionError("missing FCStd: {0}".format(FCSTD_PATH))
if not STEP_PATH.exists():
raise AssertionError("missing STEP: {0}".format(STEP_PATH))
if "ISO-10303-21" not in STEP_PATH.read_text(encoding="utf-8", errors="ignore")[:256]:
raise AssertionError("STEP output is missing ISO-10303-21 header")
doc = App.openDocument(str(FCSTD_PATH))
backbox = _bbox(doc, "Panel_BackBox")
left_face = _bbox(doc, "Panel_LeftDoorFace")
mount_plate = _bbox(doc, "Panel_RightMountPlate")
object_names = {obj.Name for obj in doc.Objects}
removed_objects = sorted(
name
for name in object_names
if name.startswith("WireFrame_")
or "_Screw_R" in name
or name in {"Panel_HingeTop", "Panel_HingeBottom"}
)
if removed_objects:
raise AssertionError("removed guide/hinge objects are still present: {0}".format(removed_objects))
_assert_close("left thin face touches main body", left_face.XMax, backbox.XMin)
_assert_close("right mounting plate touches rear of main body", mount_plate.YMin, backbox.YMax)
_assert_close(
"right mounting plate is centered on main body's wide face",
(mount_plate.XMin + mount_plate.XMax) / 2.0,
(backbox.XMin + backbox.XMax) / 2.0,
)
if mount_plate.XMin < backbox.XMin - TOLERANCE or mount_plate.XMax > backbox.XMax + TOLERANCE:
raise AssertionError(
"right mounting plate must sit within main body's wide face: "
"plate X=({0:.3f}, {1:.3f}), body X=({2:.3f}, {3:.3f})".format(
mount_plate.XMin,
mount_plate.XMax,
backbox.XMin,
backbox.XMax,
)
)
for name in (
"ConnectorBank_Left_Body",
"ConnectorBank_Right_Body",
"AccessoryConnector_LowerLeft",
"AccessoryConnector_LowerRight",
):
bbox = _bbox(doc, name)
_assert_close("{0} touches mounting plate".format(name), bbox.YMin, mount_plate.YMax)
if bbox.XMin < backbox.XMin - TOLERANCE or bbox.XMax > backbox.XMax + TOLERANCE:
raise AssertionError(
"{0} must sit within main body's wide face: object X=({1:.3f}, {2:.3f}), body X=({3:.3f}, {4:.3f})".format(
name,
bbox.XMin,
bbox.XMax,
backbox.XMin,
backbox.XMax,
)
)
print("verified qet_panel_assembly contact constraints")
if __name__ == "__main__":
main()

@ -0,0 +1,36 @@
# NAU03 Split Cabinet Asset
This directory contains a simplified NAU03-style electrical cabinet STEP asset for QET / FreeCAD assembly tests.
## Outputs
- `nau03_test_cabinet_split.step`: STEP model with cabinet faces split into independently hideable parts.
- `nau03_test_cabinet_split.FCStd`: FreeCAD source document generated for inspection and regeneration.
- `nau03_test_cabinet_split_report.json`: Generated metadata.
- `create_nau03_split_cabinet.py`: FreeCAD Python generator.
- `verify_nau03_split_cabinet.py`: Import verification script.
## Hideable Parts
The STEP is intentionally exported at medium granularity:
- `NAU03_Cabinet_Frame`
- `NAU03_Left_Side_Panel`
- `NAU03_Right_Side_Panel`
- `NAU03_Rear_Panel`
- `NAU03_Front_Left_Door`
- `NAU03_Front_Right_Door`
- `NAU03_Top_Roof`
- `NAU03_Bottom_Base`
- `NAU03_Interior_Mounting_Plate`
This avoids the original behavior where hiding the imported cabinet hides a large assembly at once, while also avoiding hundreds of tiny screw and hinge tree objects.
## Regenerate
```powershell
$runtime = Get-Content -LiteralPath 'C:\Users\ng123\AppData\Local\QETDeps\runtime.json' -Raw | ConvertFrom-Json
$env:QET_FREECAD_RUNTIME_JSON = 'C:\Users\ng123\AppData\Local\QETDeps\runtime.json'
& $runtime.freecad_python 'D:\LightWork3D\data\examples\qet_split_cabinet\create_nau03_split_cabinet.py'
& $runtime.freecad_python 'D:\LightWork3D\data\examples\qet_split_cabinet\verify_nau03_split_cabinet.py'
```

@ -0,0 +1,262 @@
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def _bootstrap_windows_freecad_runtime() -> None:
if os.name != "nt":
return
runtime_json = os.environ.get("QET_FREECAD_RUNTIME_JSON")
if not runtime_json:
runtime_json = os.path.join(os.environ.get("LOCALAPPDATA", ""), "QETDeps", "runtime.json")
if not runtime_json or not os.path.exists(runtime_json):
return
with open(runtime_json, "r", encoding="utf-8-sig") as handle:
runtime = json.load(handle)
roots = [str(item) for item in runtime.get("path_prefix", []) if item]
freecad_root = runtime.get("freecad_root", "")
roots.extend(
[
os.path.join(freecad_root, "build", "Mod", "Material"),
os.path.join(freecad_root, "build", "Mod", "Part"),
os.path.join(freecad_root, "build", "Mod", "Import"),
os.path.join(freecad_root, "build", "Mod"),
os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "downlevel"),
]
)
for root in roots:
if root and os.path.isdir(root):
try:
os.add_dll_directory(root)
except (AttributeError, OSError):
pass
if root not in sys.path:
sys.path.append(root)
_bootstrap_windows_freecad_runtime()
import FreeCAD as App
import Part
OUT_DIR = Path(__file__).resolve().parent
FCSTD_PATH = OUT_DIR / "nau03_test_cabinet_split.FCStd"
STEP_PATH = OUT_DIR / "nau03_test_cabinet_split.step"
REPORT_PATH = OUT_DIR / "nau03_test_cabinet_split_report.json"
def _style(obj, color, transparency=0) -> None:
if not hasattr(obj, "ViewObject") or obj.ViewObject is None:
return
obj.ViewObject.ShapeColor = color
obj.ViewObject.Transparency = transparency
def _box(dx, dy, dz, x, y, z):
return Part.makeBox(dx, dy, dz, App.Vector(x, y, z))
def _part(doc, name, shape, color, transparency=0):
obj = doc.addObject("Part::Feature", name)
obj.Label = name
obj.Shape = shape
_style(obj, color, transparency)
return obj
def _fuse(shapes):
valid_shapes = [shape for shape in shapes if shape and not shape.isNull()]
if not valid_shapes:
raise ValueError("expected at least one shape to fuse")
result = valid_shapes[0]
for shape in valid_shapes[1:]:
result = result.fuse(shape)
try:
result = result.removeSplitter()
except Exception:
pass
return result
def _windowed_door(x, y, z, width, thickness, height, window_x, window_z, window_w, window_h):
panel = _box(width, thickness, height, x, y, z)
void = _box(window_w, thickness + 6.0, window_h, x + window_x, y - 3.0, z + window_z)
panel = panel.cut(void)
# Small raised border around the viewing window, matching the reference cabinet silhouette.
border = 12.0
border_y = y - 4.0
border_t = 8.0
border_shapes = [
_box(window_w + border * 2.0, border_t, border, x + window_x - border, border_y, z + window_z - border),
_box(window_w + border * 2.0, border_t, border, x + window_x - border, border_y, z + window_z + window_h),
_box(border, border_t, window_h, x + window_x - border, border_y, z + window_z),
_box(border, border_t, window_h, x + window_x + window_w, border_y, z + window_z),
]
handle_w = 16.0
handle_h = 120.0
handle = _box(handle_w, 14.0, handle_h, x + width - 42.0, y - 12.0, z + height * 0.48)
return _fuse([panel, handle] + border_shapes)
def _frame_shape(width, depth, height):
post = 45.0
rail = 55.0
x0 = -width / 2.0
y0 = -depth / 2.0
shapes = []
for x in (x0, width / 2.0 - post):
for y in (y0, depth / 2.0 - post):
shapes.append(_box(post, post, height, x, y, 0.0))
for z in (0.0, height - rail):
shapes.extend(
[
_box(width, rail, rail, x0, y0, z),
_box(width, rail, rail, x0, depth / 2.0 - rail, z),
_box(rail, depth, rail, x0, y0, z),
_box(rail, depth, rail, width / 2.0 - rail, y0, z),
]
)
return _fuse(shapes)
def _vent_slots(x, y, z, width, thickness, height, count):
panel = _box(width, thickness, height, x, y, z)
slot_w = width * 0.62
slot_h = 16.0
pitch = 42.0
start_z = z + height * 0.22
for index in range(count):
sx = x + (width - slot_w) / 2.0
sz = start_z + index * pitch
panel = panel.cut(_box(slot_w, thickness + 6.0, slot_h, sx, y - 3.0, sz))
return panel
def _export_step(objects) -> None:
try:
import Import
Import.export(objects, str(STEP_PATH))
except Exception:
import ImportGui
ImportGui.export(objects, str(STEP_PATH))
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
doc = App.newDocument("NAU03_Test_Cabinet_Split")
width = 750.0
depth = 1300.0
height = 2300.0
side_t = 32.0
door_t = 36.0
roof_h = 75.0
base_h = 110.0
metal = (0.62, 0.68, 0.69)
dark = (0.08, 0.09, 0.10)
inner = (0.34, 0.38, 0.40)
x_min = -width / 2.0
x_max = width / 2.0
y_front = -depth / 2.0
y_back = depth / 2.0
door_gap = 8.0
door_width = (width - 30.0 - door_gap) / 2.0
door_z = 55.0
door_h = height - 105.0
left_door = _windowed_door(
x_min + 12.0,
y_front - door_t,
door_z,
door_width,
door_t,
door_h,
95.0,
1260.0,
185.0,
470.0,
)
right_door = _windowed_door(
x_min + 12.0 + door_width + door_gap,
y_front - door_t,
door_z,
door_width,
door_t,
door_h,
70.0,
1260.0,
185.0,
470.0,
)
rear_panel = _box(width - 36.0, side_t, height - 90.0, x_min + 18.0, y_back, 45.0)
left_panel = _vent_slots(x_min - side_t, y_front + 42.0, 50.0, side_t, depth - 84.0, height - 100.0, 10)
right_panel = _vent_slots(x_max, y_front + 42.0, 50.0, side_t, depth - 84.0, height - 100.0, 10)
roof = _fuse(
[
_box(width + 70.0, depth + 100.0, roof_h, x_min - 35.0, y_front - 50.0, height),
_box(width + 30.0, 58.0, 45.0, x_min - 15.0, y_front - 10.0, height - 45.0),
_box(width + 30.0, 58.0, 45.0, x_min - 15.0, y_back - 48.0, height - 45.0),
]
)
base = _fuse(
[
_box(width + 35.0, depth + 30.0, base_h, x_min - 17.5, y_front - 15.0, -base_h),
_box(width + 85.0, 95.0, 58.0, x_min - 42.5, y_front - 40.0, -base_h - 58.0),
_box(width + 85.0, 95.0, 58.0, x_min - 42.5, y_back - 55.0, -base_h - 58.0),
]
)
mounting_plate = _box(width - 165.0, 14.0, height - 360.0, x_min + 82.5, y_back - 82.0, 180.0)
objects = [
_part(doc, "NAU03_Cabinet_Frame", _frame_shape(width, depth, height), dark, 0),
_part(doc, "NAU03_Left_Side_Panel", left_panel, metal, 0),
_part(doc, "NAU03_Right_Side_Panel", right_panel, metal, 0),
_part(doc, "NAU03_Rear_Panel", rear_panel, metal, 0),
_part(doc, "NAU03_Front_Left_Door", left_door, metal, 0),
_part(doc, "NAU03_Front_Right_Door", right_door, metal, 0),
_part(doc, "NAU03_Top_Roof", roof, metal, 0),
_part(doc, "NAU03_Bottom_Base", base, dark, 0),
_part(doc, "NAU03_Interior_Mounting_Plate", mounting_plate, inner, 8),
]
doc.recompute()
doc.saveAs(str(FCSTD_PATH))
_export_step(objects)
report = {
"source_reference": r"D:\downloadWX\xwechat_files\wxid_pv577xuccot722_5d4a\msg\file\2026-04\MCCB CABINET ASS'Y.STEP",
"outputs": {"fcstd": str(FCSTD_PATH), "step": str(STEP_PATH)},
"dimensions_mm": {
"width": width,
"depth": depth,
"height_without_roof_base": height,
"overall_height": height + roof_h + base_h + 58.0,
},
"hideable_parts": [obj.Label for obj in objects],
"object_count": len(objects),
}
REPORT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print("Generated FCStd: {0}".format(FCSTD_PATH))
print("Generated STEP: {0}".format(STEP_PATH))
print("Generated report: {0}".format(REPORT_PATH))
if __name__ == "__main__":
main()

@ -0,0 +1,25 @@
{
"source_reference": "D:\\downloadWX\\xwechat_files\\wxid_pv577xuccot722_5d4a\\msg\\file\\2026-04\\MCCB CABINET ASS'Y.STEP",
"outputs": {
"fcstd": "D:\\LightWork3D\\data\\examples\\qet_split_cabinet\\nau03_test_cabinet_split.FCStd",
"step": "D:\\LightWork3D\\data\\examples\\qet_split_cabinet\\nau03_test_cabinet_split.step"
},
"dimensions_mm": {
"width": 750.0,
"depth": 1300.0,
"height_without_roof_base": 2300.0,
"overall_height": 2543.0
},
"hideable_parts": [
"NAU03_Cabinet_Frame",
"NAU03_Left_Side_Panel",
"NAU03_Right_Side_Panel",
"NAU03_Rear_Panel",
"NAU03_Front_Left_Door",
"NAU03_Front_Right_Door",
"NAU03_Top_Roof",
"NAU03_Bottom_Base",
"NAU03_Interior_Mounting_Plate"
],
"object_count": 9
}

@ -0,0 +1,96 @@
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def _bootstrap_windows_freecad_runtime() -> None:
if os.name != "nt":
return
runtime_json = os.environ.get("QET_FREECAD_RUNTIME_JSON")
if not runtime_json:
runtime_json = os.path.join(os.environ.get("LOCALAPPDATA", ""), "QETDeps", "runtime.json")
if not runtime_json or not os.path.exists(runtime_json):
return
with open(runtime_json, "r", encoding="utf-8-sig") as handle:
runtime = json.load(handle)
roots = [str(item) for item in runtime.get("path_prefix", []) if item]
freecad_root = runtime.get("freecad_root", "")
roots.extend(
[
os.path.join(freecad_root, "build", "Mod", "Import"),
os.path.join(freecad_root, "build", "Mod", "Part"),
os.path.join(freecad_root, "build", "Mod"),
os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "downlevel"),
]
)
for root in roots:
if root and os.path.isdir(root):
try:
os.add_dll_directory(root)
except (AttributeError, OSError):
pass
if root not in sys.path:
sys.path.append(root)
_bootstrap_windows_freecad_runtime()
import FreeCAD as App
import Import
OUT_DIR = Path(__file__).resolve().parent
STEP_PATH = Path(os.environ.get("NAU03_SPLIT_CABINET_STEP", OUT_DIR / "nau03_test_cabinet_split.step"))
EXPECTED_LABELS = {
"NAU03_Cabinet_Frame",
"NAU03_Left_Side_Panel",
"NAU03_Right_Side_Panel",
"NAU03_Rear_Panel",
"NAU03_Front_Left_Door",
"NAU03_Front_Right_Door",
"NAU03_Top_Roof",
"NAU03_Bottom_Base",
"NAU03_Interior_Mounting_Plate",
}
def main() -> None:
if not STEP_PATH.exists():
raise AssertionError(f"missing STEP output: {STEP_PATH}")
header = STEP_PATH.read_text(encoding="utf-8", errors="ignore")[:256]
if "ISO-10303-21" not in header:
raise AssertionError("STEP output is missing ISO-10303-21 header")
doc = App.newDocument("verify_nau03_split")
Import.insert(str(STEP_PATH), doc.Name)
doc.recompute()
shape_objects = [
obj
for obj in doc.Objects
if hasattr(obj, "Shape") and not obj.Shape.isNull() and len(obj.Shape.Solids) > 0
]
labels = {obj.Label for obj in shape_objects}
missing = sorted(EXPECTED_LABELS - labels)
if missing:
raise AssertionError(f"missing expected independently hideable labels: {missing}")
if len(shape_objects) > 16:
raise AssertionError(f"too many top-level shape objects: {len(shape_objects)}")
for obj in shape_objects:
if obj.Shape.Volume <= 0:
raise AssertionError(f"{obj.Label} has no positive volume")
print("verified NAU03 split cabinet STEP")
print("shape_objects=", len(shape_objects))
print("labels=", sorted(labels))
if __name__ == "__main__":
main()

@ -162,6 +162,18 @@ QET 在导出时负责:
- `device_models`:设备 3D 模型解析结果
- `wires`:导线起点/终点与标注快照
### 4.2 FreeCAD 侧 v2 校验规则
FreeCAD 当前只接受 `schema_version=2.0` 的交换文件。为了避免旧协议字段继续混入新链路,导入时会直接拒绝下列旧结构:
- 根级 `terminals[]`:端子必须放在 `devices[].terminals[]`
- `devices[].instance_id`:设备实例必须使用 `devices[].device_instance_id`
- `devices[].element_uuid`:设备顶层不再表达单个 2D 符号,成员关系由 `devices[].terminals[].element_uuid` 表达
- `device_models[].instance_id``device_models[].element_uuid`:模型只允许按 `device_models[].device_instance_id` 关联
- `wires[].start_instance_id` / `wires[].end_instance_id`:第一版数据库绑定仍不依赖这两个字段,正式主键仍是 `terminal_uuid`;但在 QET 端 `terminal_uuid` 尚未完全做到端子实例唯一前FreeCAD 可把它们作为自动布线端点消歧的辅助信息,优先按 `terminal_uuid + element_uuid + device_instance_id` 匹配 3D 工程端子,避免重复 `terminal_uuid` 时误接到其它设备端子。QET 后续把 `terminal_uuid` 修成真正端子实例 UUID 后,这两个字段可以退回诊断/兼容用途。
FreeCAD 内部对象属性 `QetInstanceId` 仍可保留,这是 3D 文档内的对象属性名,不等同于 JSON 旧字段 `instance_id`
---
## 5. `cabinet` 结构
@ -523,7 +535,7 @@ FreeCAD 根据 `resolved_model_path` 的扩展名导入 `.FCStd`,并在导入
```json
{
"schema_version": "1.0",
"schema_version": "2.0",
"project_uuid": "string",
"generated_at": "2026-05-18T11:00:00+08:00",
"instances": [],
@ -555,6 +567,8 @@ FreeCAD 根据 `resolved_model_path` 的扩展名导入 `.FCStd`,并在导入
- `instances[]` 继续表达:某个 2D `element_uuid` 绑定到哪个 3D 设备实例
- `terminals[]` 表达:某个 2D `terminal_uuid` 绑定到哪个 3D 设备实例,以及可选的哪个 3D 端子实例
- 如果当前版本 QET 只消费设备实例级绑定,`terminal_instance_id` 可暂时忽略,但字段命名应保留清晰语义
- `3d_to_2d.json` 与优化后的 `2d_to_3d.json` 使用同一套命名:设备实例字段统一为 `device_instance_id`,端子实例字段统一为 `terminal_instance_id`
- `3d_to_2d.json` 不再输出旧字段 `instance_id`QET 读取后再把 `device_instance_id / terminal_instance_id` 写入两张绑定表的 `instance_id`
第一版不回写:

@ -805,6 +805,8 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
面板中的 `端子接入警告距离 mm` 用于判断“端子接入过长”。设为 `0` 时按默认规则自动计算;如果当前机柜尺度较大,且 600-700mm 的端子接入属于可接受的设备局部出线,可以把该值调到 700mm 左右再检查。这个参数只影响质量告警,不会放宽 `端子接入最大距离 mm`,也不会让超过最大距离的端子强行接入。
如果路径网络诊断包含 `unconnected_terminals`,点击 `选择未接入端子`。系统会从最新 `RoutingPathNetwork` 诊断中选择未接入路由网络、或端子出口到最近网络距离超过 `端子接入最大距离 mm` 的端子及所属设备;状态栏会显示本次样例里的最大最近网络距离。选中后先确认设备是否已经装配到柜内正确位置,再看端子附近是否缺线槽入口、过线孔、黄色 `UserPath` 或设备局部出线路径;如果装配和路径都合理,但实际柜型允许更长的局部接入,再考虑调大 `端子接入最大距离 mm`
如果有线槽但导线仍大量走布线面,优先看 `RoutingPathNetwork.QetDiagnosticIssueCodes` 是否包含 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`。这个问题表示线槽已经识别成路径 carrier但它所在的路径组件没有任何 `TerminalAccess`,导线很难自然进入线槽。中文报告会尽量显示“建议桥接到哪个主网络”和最近距离;`QetDiagnosticJson.wire_ducts_without_terminal_access[].bridge_suggestion` 会保存建议连接的两段 carrier、两个最近点和距离。处理方式是在 FreeCAD 中用 UserPath、线槽开口或桥接路径把线槽组件接到端子接入所在的主网络再重新生成布线路径网络和导线。
`生成布线路径网络` 不会把 FreeCAD 的 Origin 坐标轴、已有 `QETRouteCarrier*` 或异常巨大包围盒对象当成用户路径源。真正的 `UserPath` 需要来自你选中的草图线、Draft 线、带 `Points` 的路径对象,或通过 `按诊断建议生成桥接` / `选中两路径生成桥接` 生成。如果 `生成布线连接` 后诊断显示 `路径采用:线槽/主路径 0 条,布线面/辅助路径 N 条`,说明当前导线基本都在走安装板/门板等辅助 RoutingRange优先补线槽到端子主网络的桥接路径或手动画柜内主路径后点击 `选中路径作为用户路径`
@ -893,12 +895,50 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
如果某条线已经生成但端子附近拉出很长一段斜线或折线,选中该导线对象查看 `QetRouteEntryDistanceMm`、`QetRouteExitDistanceMm`、`QetRouteAccessWarningDistanceMm` 和 `QetRouteAccessStatus`。其中 `LongAccessWarning` 表示起点或终点到主路径网络的接入距离超过当前告警阈值;`QetRouteAccessWarningSides` 会显示触发侧,`entry` 是起点侧,`exit` 是终点侧。出现该提示时,优先检查设备是否已经装配到正确位置、端子局部出线路径是否存在、用户路径或线槽是否离设备端子太远。
端子默认出线方向来自工程端子 LCS 的本地 `+Z` 方向;如果工程端子带 `QetTerminalExitDirectionJson`,则优先使用该显式方向。该字段使用 FreeCAD 文档坐标,例如 `{"x":1,"y":0,"z":0}` 表示沿全局 X 正方向出线。这个设计对应 SW 中 CPoint/连接点保存出线方向的思路,适合把常用设备模板里的真实出线方向固化下来。
如果当前只是端子 CPoint 方向不对、但不需要画完整局部路径,可以直接在工程里设置显式出线方向:
1. 选中一个可布线工程端子。
2. 再选中一条表示出线方向的草图线、Draft 线、边或连续 Wire。
3. 点击 `选中端子设置出线方向`
4. 系统会取所选线的第一段方向,归一化后写入该端子的 `QetTerminalExitDirectionJson`,只修改当前 FreeCAD 文档,不写 QET 数据库。
5. 重新点击 `生成布线路径网络``生成布线连接`
这个动作只保存“方向”,仍由系统按 `端子出线长度 mm``端子出线最大长度 mm` 计算出线段;如果端子附近需要梳状、折线或跨平面局部走线,应使用 `选中端子设置局部出线`
如果端子没有显式方向,且默认 LCS 方向会在设备包围盒内走很深才离开设备,系统会尝试自动改用最近的侧向出口,避免第一段导线穿过设备主体或悬空过长。选中导线后查看 `QetRouteDiagnosticsJson.endpoint_access.*_diagnostics.exit_direction_corrected`,为 `true` 表示本次使用了自动校正方向;`original_exit_direction` 是原 LCS 方向,`exit_direction` 是实际采用方向。显式方向不会被自动改,若显式方向错误,应修改设备模板方向、工程端子 `QetTerminalExitDirectionJson`,或给该端子设置局部出线路径。
端子默认出线长度仍是 `terminal_exit_length=20mm`,但现在有 `terminal_exit_max_length=80mm` 上限。端子如果位于设备包围盒内部,系统会尝试沿出线方向离开设备外轮廓;如果离开包围盒需要超过上限,出线段会被截断,并在诊断中标记 `terminal_exit_length_capped`。出现这个问题通常表示端子方向朝内、设备包围盒过大,或端子位置放在设备深处;不要简单把上限调得很大,应优先检查端子 LCS 方向、显式出线方向或局部出线路径。
选中生成的导线对象后,可以在 `QetRouteDiagnosticsJson.endpoint_access.start_diagnostics / end_diagnostics` 中查看每侧端子的 `exit_rule`、`exit_direction_source`、`exit_direction`、`requested_exit_length_mm`、`actual_exit_length_mm`、`device_exit_required_length_mm` 和 `exit_length_capped`。如果 `exit_rule=local_route`,说明该端子正在使用 `QetTerminalLocalRoutePointsJson` 局部出线路径;如果 `exit_length_capped=true`,说明这侧端子按当前显式方向无法在合理长度内离开设备包围盒,后续容易出现端子附近悬空过长或穿模,应优先修正端子方向或给该端子设置局部出线路径。
点击 `检查布线路径网络` 时,也会提前汇总端子出线问题。`corrected_terminal_exits[]` 表示默认 LCS 出线方向被系统自动改到最近侧向出口,通常说明设备模板端子方向还需要复查;`capped_terminal_exits[]` 表示端子按当前显式方向或默认方向无法在最大出线长度内离开设备包围盒,系统已经截断出线段。两个数组都会保留端子名、端子 UUID、父设备、原始方向、实际方向、请求长度、实际长度和上限便于手动验收时先定位设备端子再决定是修模板 CPoint、设置工程端子局部出线还是补主路径入口。
如果 `QetTerminalExitDirectionJson` 格式错误、方向向量无法解析或方向长度为 0路径网络诊断会额外输出 `invalid_terminal_exit_directions[]`。这种情况不会让 FreeCAD 依赖 QET 计算 3D 路径,而是明确提示当前 FreeCAD 文档或设备模板中的 CPoint 方向元数据需要修正;可以用 `选中端子设置出线方向` 重写当前工程端子的显式方向,或回到设备模板中修正后重新导入。
如果要直接定位这些端子,点击 `选择出线问题端子`。系统会从最新 `RoutingPathNetwork` 诊断中合并选择 `corrected_terminal_exits[]`、`capped_terminal_exits[]`、`invalid_terminal_exit_directions[]` 和 `invalid_terminal_local_routes[]` 对应的端子及父设备;这个操作只负责定位,不会自动改端子方向或重新布线。选中后先看端子 LCS 朝向、显式 `QetTerminalExitDirectionJson`、局部路径 `QetTerminalLocalRoutePointsJson`、设备包围盒是否过大,再决定是否设置显式出线方向、设置局部出线路径或回到设备模板修正 CPoint。
每个自动生成的 `TerminalAccess` carrier 会记录接入目标:`QetTerminalAccessTargetKind / Name / Label / DistanceMm` 表示端子局部出口接到哪条线槽、`UserPath`、过线孔或面板路径;`QetTerminalAccessTargetRule` 表示选择规则,`main_path_nearest` 是直接接入最近主路径,`main_path_preferred_over_fallback` 是附近虽有 `RoutingRange` 等兜底路径但系统仍优先接入主路径,`fallback_only` 表示当前找不到线槽/UserPath/过线孔等主路径,只能退回面板路径或辅助路径。`QetTerminalAccessFallbackTarget=1` 时,应优先补线槽入口、黄色草图 `UserPath`、过线孔或设备局部路径,再重新生成布线路径网络。
如果端子已经通过局部出线路径离开设备,但局部出口到主路径入口的短接入段会重新穿过该端子所属设备包围盒,系统会给这段 `TerminalAccess` 自动加一个外侧绕行折点。`QetTerminalAccessAvoidedEndpointDevice=1` 表示这条接入线已经做过端点设备避让;选中最终导线时,也可以在 `QetRouteDiagnosticsJson.network.start_terminal_access_avoided_endpoint_device / end_terminal_access_avoided_endpoint_device` 里看是哪一侧触发。这个规则只处理端子接入主路径前的短段,不替代整条导线的全局碰撞避让。如果避让后仍穿其它设备,仍需要补更合理的 UserPath、线槽入口或设备局部出线路径。
点击 `检查布线路径网络` 时,诊断 JSON 也会汇总 `terminal_access_fallback_targets[]``terminal_access_endpoint_device_avoidance[]`。前者表示某些端子接入只能退回 `RoutingRange` 等兜底路径,通常需要补线槽入口、`UserPath` 或过线孔;后者表示某些端子接入段已经为了避开端点设备做了绕行,后续我进行手动验收时会优先检查这些端子附近是否缺设备局部出线路径或主路径入口。这两个数组都包含端子名、端子 UUID、父设备、`TerminalAccess` 接入段对象名、目标路径类型、目标路径对象名、`access_points[]` 和 `access_length_mm`,便于自动定位对象并判断接入段是否过长、是否绕回设备附近。
如果要定位端子接入退回到布线面的对象,点击 `选择端子退回位置`。该按钮既能读取独立 `RoutingPathNetwork.terminal_access_fallback_targets[]`,也能读取批量布线诊断里的端子退回样例;只执行 `检查布线路径网络`、还没有生成导线时,也可以先选中端子、父设备、`TerminalAccess` 接入段和退回目标,判断应该补线槽入口、黄色 `UserPath`、过线孔还是设备局部出线路径。
如果这些退回目标只是缺一小段到主路径的入口,可以直接点击 `按诊断建议生成桥接`。该按钮现在既能读取批量布线诊断里的 `terminal_access_fallback_target_samples[]`,也能读取刚执行 `检查布线路径网络` 后生成的 `RoutingPathNetwork.terminal_access_fallback_targets[]`,自动在退回布线面和最近线槽、`UserPath`、过线孔等主路径之间生成 `TerminalAccessFallbackBridge`。生成后重新执行 `生成布线路径网络``生成布线连接`,端子接入会优先走补出的桥接路径;如果仍然退回布线面,说明需要补更明确的主路径入口或设备局部出线路径。
如果要直接定位端点设备避让问题,点击 `选择端点避让接入`。系统会读取最新 `RoutingPathNetwork` 诊断中的 `terminal_access_endpoint_device_avoidance[]`,选中对应端子、父设备、目标主路径和 `TerminalAccess` 接入段;这个按钮主要服务手动验收和开发侧复查,只定位对象,不重新布线、不写 QET 数据库。
`检查布线路径网络` 和批量布线的 `routing_path_network_diagnostic.long_terminal_accesses[]` 会保留长接入样例。样例里包含 `parent_device_label / parent_device_name`、`terminal_origin`、`terminal_access_points`、`terminal_access_dominant_axis` 和 `terminal_access_axis_lengths_mm`。如果 `terminal_access_dominant_axis``z`,且 `z` 方向长度占大头,通常表示端子点和柜内主路径平面高度差过大;优先检查该设备装配高度、端子 LCS 方向,或为该设备补局部出线路径。
如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。
如果要从设备角度排查,点击 `选择长接入设备`。系统会读取长接入样例里的 `parent_device_name / parent_device_label` 并选中对应设备。通常先用 `选择长接入端子` 看具体端子点,再用 `选择长接入设备` 检查该设备整体是否装配到正确高度、端子 LCS 是否随设备移动,以及设备附近是否需要补局部出线路径。
这两个长接入定位按钮既能读取批量布线诊断内嵌的 `routing_path_network_diagnostic.long_terminal_accesses[]`,也能直接读取独立 `RoutingPathNetwork` 诊断里的 `long_terminal_accesses[]`。因此只执行 `检查布线路径网络`、还没有生成导线时,也可以先定位长接入端子和设备,适合在正式布线前先修装配高度、端子方向和局部出线路径。
如果确认是某个工程端子缺少设备局部出线路径,可以直接在当前装配工程里补:
1. 选中一个可布线工程端子。
@ -945,6 +985,8 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
`汇总布线诊断` 还会根据当前问题给出下一步建议,例如 `点击“选择缺端子设备”定位需要补工程端子的设备`、`点击“选择异常导线”定位带问题码的导线`、`点击“选择碰撞父装配”确认结构件后再标记忽略碰撞`。手测时可以先看这一行,再决定下一步点哪个定位按钮。
如果路径网络诊断中存在 `terminal_exit_direction_corrected`、`terminal_exit_length_capped`、`invalid_terminal_exit_directions` 或 `invalid_terminal_local_routes`,汇总建议会提示点击 `选择出线问题端子`。这一步用于先定位设备端子本身的问题,再决定是否修设备模板 CPoint/LCS、重写显式出线方向、设置工程端子局部出线路径或补端子附近到线槽/UserPath 的入口。
如果要判断某根线是明显穿模还是只是距离太近,选中导线对象查看 `QetRouteCollisionStatus`、`QetRouteHardIntersectionCount` 和 `QetRouteClearanceWarningCount`。`HardIntersectionWarning` 表示导线穿过障碍包围盒,应优先改路径或设备位置;`ClearanceWarning` 表示导线没有穿过障碍,但低于安全间隙,通常需要微调路径或安全间隙参数。
批量诊断中的 `collision_samples[]` 也会带 `wire_object_label`。如果报告出现“碰撞示例”,可以先复制这个 Label 到树目录中查找对应导线,再结合 `collision_kind` 判断是硬碰撞还是安全间隙。碰撞样例还会带 `obstacle_parent_labels / obstacle_parent_names`,用于判断类似 `NAUO141` 这样的零件属于前门、柜体、安装板还是具体设备;确认是装配辅助件或可穿过结构后,再手动标记为忽略碰撞对象。

@ -377,7 +377,9 @@ ManualWiring.py
- `QET_Template_AddTerminal`
- `QET_Template_SaveAsFCStd`
- 第一版端子位置可以通过用户选择对象/点位后的三维坐标生成。
- 第一版端子方向默认使用单位旋转,后续再补出线方向编辑。
- 第一版端子方向以模板 LCS 的本地 `+Z` 作为默认出线方向;工程端子也允许保存 `QetTerminalExitDirectionJson`,用文档坐标明确指定出线方向,例如 `{"x":1,"y":0,"z":0}`。这相当于 SW Electrical 3D 的 CPoint 方向数据,适合沉淀到常用设备模板。
- 如果没有显式出线方向FreeCAD 侧会在端子位于设备包围盒内部且默认 LCS 方向需要很长距离才能离开设备时,自动尝试改用最近的侧向出口;显式方向不自动改,只按出线长度上限诊断,避免覆盖模板作者或人工指定的 CPoint 方向。
- 当前工程可通过 `QetTerminalLocalRoutePointsJson` 保存端子局部出线路径;一旦存在局部路径,自动布线优先使用它连接到 TerminalAccess 和柜内主路径网络,不再按默认 LCS 出线段生成。
模板端子属性:
@ -616,4 +618,8 @@ ManualWiring.py
- 2026-05-20新增 `TemplateAuthoringPanel.py`,提供“设备模板端子制作”任务面板和 `QET_Template_OpenAuthoringPanel` 命令;面板支持输入端子名、添加端子、校验端子、保存 FCStd并已同步到运行目录验证模块可导入。
- 2026-05-25新增 `WiringImport.py`,把 `2d_to_3d.json` 中的 `wires` 导入为 `QETWiring_01_Tasks` 下的导线任务;`ExchangeBootstrap.py` 已接入启动导入流程。`ManualWiringPanel.py` 增加任务列表、选择导线任务和删除最后折点,按任务生成导线时会把 `wire_id / net_uuid / group_uuid / wire_mark` 写入正式导线对象,并把任务状态更新为 `Routed`。已通过 35 项 `freecad_exchange*_test.py` 单元测试,并安装到 `D:\fc\run-FreeCAD-1.1.1` 运行目录验证 `WiringImport / ManualWiring / ManualWiringPanel / WiringObjects` 可导入。
- 2026-05-25修复 FCStd 设备导入后模板 LCS 留在工程场景的问题;导入时会把模板槽位位置和朝向缓存到设备组 `QetTemplateSlotsJson`,随后删除模板 LCS 及其 `OriginFeatures`,工程端子仍按 `terminal_uuid` 生成到 `QETTerminals_*`。已补单元测试验证 FCStd 导入不保留模板 LCS、切回 STEP 会清空旧槽位缓存,并避免重复访问已删除对象的 `Group / InList / Name`
- 2026-06-15新增 QET 待装配设备导入策略。FreeCAD 从 QET 打开项目时,新设备默认只创建 `QETDevice_*` 设备组并标记 `QetAssemblyState=Pending`,不再自动导入几何到原点;已装配设备如果在 `scene.FCStd` 中已有模型对象,则继续复用并标记 `Placed`。新增 `list_pending_devices()` 后端清单和 `QET_Exchange_InsertPendingDevice` 命令,用户可选中整个 `QETDevice_*` 设备组执行“插入待装配设备”,导入模型后状态转为 `Placed`。已用 `freecad_exchange_device_import_fcstd_test.py` 验证默认 pending、显式插入、命令注册和清单粒度只返回整设备组不返回 `JHD5-6灰001` 等内部几何子对象。待跟进:补专用待装配设备任务面板或树右键菜单。
- 2026-06-15新增 `PendingDeviceAssemblyPanel.py` 待装配设备任务面板,提供“刷新清单”“插入设备”“插入到选中目标”三个入口。面板清单按 `QETDevice_*` 整设备显示,用户可先在 3D 视图选择安装板、导轨、线槽或柜体面,再把设备插入到目标对象;如果 FreeCAD 提供选中面的拾取点或面中心点,则优先用该点作为设备 Placement否则退回目标对象 Placement。`insert_pending_device()` 新增 `mount_target / mount_placement` 参数并写入 `QetMountMode / QetMountHostName / QetMountHostLabel / QetMountHostKind`,保存 `scene.FCStd` 后可作为装配状态恢复依据。已用单元测试验证插入到安装目标、显式安装点优先、面板命令注册和清单显示。
- 2026-06-15补强待装配设备贴合语义。`insert_pending_device()` 新增 `mount_normal / mount_offset_mm`,可按选中面的法向应用贴合间距,并把 `QetMountHostNormalJson / QetMountOffsetMm` 写入设备组;待装配面板新增“贴合间距”输入,选中面时会尽量读取面法向,插入到目标时传给后端。已用单元测试验证设备 Placement 会沿法向偏移,且法向和间距元数据被保存。
- 2026-06-15新增 `tests/manual/freecad_pending_device_scene_smoke.py`,用真实 `FreeCADCmd.exe` 创建设备模型和 `QETScene.FCStd`,执行待装配设备插入、保存、关闭、重新打开,并断言设备仍为 `Placed`、Placement 为 `(10,20,35)`、挂载目标和法向/间距元数据仍存在。已在 `D:\fc\run-FreeCAD-1.1.1\bin\FreeCADCmd.exe` 下执行通过。
```

@ -0,0 +1,128 @@
# FreeCAD Terminal Access 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:** 参考 SOLIDWORKS Electrical 3D 的 CPoint/RPoint 思路,把 FreeCAD 自动布线的端子出线方向、出线长度和 TerminalAccess 接入路径做成可诊断、可测试、可手动验收的工程规则。
**Architecture:** 第一阶段保留现有 `RoutingNetwork.py` 作为路径网络核心,但把端子出线规则集中到少量函数:显式出线方向优先,其次 LCS 方向,再通过设备包围盒和候选方向做校正。`AutoRouting.py` 继续消费 `terminal_access_path_points_with_network_access()`,诊断数据写入 route/report面板和文档读取这些字段。
**Tech Stack:** FreeCAD Python API, `src/Mod/FreeCADExchange`, `unittest`, `FreeCADCmd.exe`
---
### Task 1: 出线长度上限和方向诊断
**Files:**
- Modify: `src/Mod/FreeCADExchange/RoutingNetwork.py`
- Modify: `src/Mod/FreeCADExchange/AutoRouting.py`
- Test: `tests/python/freecad_exchange_auto_routing_test.py`
- [x] **Step 1: Write failing tests**
Add tests that create a terminal inside a large device box and assert that the default access path does not extend indefinitely through the full box. Add a second test that verifies the access diagnostics include the selected direction, requested exit length, actual exit length, and whether the length was capped.
- [x] **Step 2: Run focused tests**
Run:
```powershell
python -B -m unittest tests.python.freecad_exchange_auto_routing_test.AutoRoutingTest
```
Expected before implementation: the new tests fail because current bbox fallback can return a long exit segment and route diagnostics do not expose capped length metadata.
- [x] **Step 3: Implement minimal routing rule**
Add `terminal_exit_max_length` option with a conservative default. The device-aware exit point should cap bbox-derived length and record diagnostics when the cap is reached. Keep Chinese comments near non-obvious engineering rules.
- [x] **Step 4: Verify**
Run the same focused tests. Expected: PASS.
### Task 2: 显式端子出线方向
**Files:**
- Modify: `src/Mod/FreeCADExchange/TerminalObjects.py`
- Modify: `src/Mod/FreeCADExchange/RoutingNetwork.py`
- Test: `tests/python/freecad_exchange_auto_routing_test.py`
- [x] **Step 1: Write failing tests**
Add tests that set a terminal property such as `QetTerminalExitDirectionJson={"x":1,"y":0,"z":0}` and verify the access path uses that vector instead of LCS `+Z`.
- [x] **Step 2: Implement explicit direction reader**
Add a small helper that reads explicit direction JSON or comma-separated text, normalizes it, and falls back to current LCS direction when missing or invalid.
- [x] **Step 3: Verify**
Run the focused tests and full auto-routing test module.
### Task 3: TerminalAccess 接入主路径质量
**Files:**
- Modify: `src/Mod/FreeCADExchange/RoutingNetwork.py`
- Modify: `src/Mod/FreeCADExchange/AutoRouting.py`
- Test: `tests/python/freecad_exchange_auto_routing_test.py`
- [x] **Step 1: Write failing tests**
Add tests where both a main UserPath/WireDuct and a fallback RoutingRange exist. Assert TerminalAccess prefers the main path unless outside max distance.
- [x] **Step 2: Improve target selection diagnostics**
Expose target kind, target label, distance, primary segment count, and fallback reason in route diagnostics.
- [x] **Step 3: Verify**
Run focused and full routing tests.
### Task 4: 文档和运行目录同步
**Files:**
- Modify: `docs/FreeCAD 机柜装配操作文档.md`
- Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md`
- Runtime copy: `D:\fc\run-FreeCAD-1.1.1\Mod\FreeCADExchange`
- [x] **Step 1: Update Chinese docs**
Document terminal exit direction, explicit direction property, exit length cap, TerminalAccess target metadata, and manual testing steps.
- [x] **Step 2: Sync runtime plugin**
Run:
```powershell
robocopy D:\LightWork3D\src\Mod\FreeCADExchange D:\fc\run-FreeCAD-1.1.1\Mod\FreeCADExchange /E /NFL /NDL /NJH /NJS /NP
```
Robocopy return code 0 or 1 is acceptable.
- [x] **Step 3: FreeCADCmd verification**
Run:
```powershell
D:\fc\run-FreeCAD-1.1.1\bin\FreeCADCmd.exe -c "import sys; sys.path.insert(0, r'D:\fc\run-FreeCAD-1.1.1\Mod\FreeCADExchange'); import AutoRouting, RoutingNetwork, TerminalObjects; print('freecad_exchange_import_ok')"
```
Expected: `freecad_exchange_import_ok`.
### Task 5: TerminalAccess 端点设备避让
**Files:**
- Modify: `src/Mod/FreeCADExchange/RoutingNetwork.py`
- Test: `tests/python/freecad_exchange_auto_routing_test.py`
- Docs: `docs/FreeCAD 机柜装配操作文档.md`
- [x] **Step 1: Write failing test**
Add a local-route terminal case where the short TerminalAccess segment from the local route exit to a UserPath would re-enter the terminal parent device bbox.
- [x] **Step 2: Implement endpoint-device avoidance**
When generating TerminalAccess carriers, test only the short access-to-target segment against the terminal parent bbox. If the direct orthogonal path crosses that bbox, choose the shortest dogleg candidate outside the bbox and mark `QetTerminalAccessAvoidedEndpointDevice=1`.
- [x] **Step 3: Verify and document**
Run the focused TerminalAccess tests and document the manual-test property.

@ -425,30 +425,74 @@ QET 侧建议保留并改造一个工具项:
### 15.1 `2d_to_3d.json`
第一版只要求包含最小绑定信息:
第一版交换文件使用优化后的 `schema_version=2.0` 快照结构。
- 设备绑定:
- `project_uuid`
- `element_uuid`
- `instance_id`
- 端子绑定:
`2d_to_3d.json` 不再把设备顶层当成单个 `element_uuid` 镜像,而是按 3D 设备实例组织:
- 顶层:
- `project_uuid`
- `terminal_uuid`
- `instance_id`
- `devices[]`
- `device_models[]`
- `wires[]`
- 设备实例:
- `devices[].device_instance_id`
- `devices[].display_tag`
- `devices[].terminals[]`
- 设备端子:
- `devices[].terminals[].terminal_uuid`
- `devices[].terminals[].element_uuid`
- `devices[].terminals[].terminal_instance_id`
- 设备模型:
- `device_models[].device_instance_id`
- `device_models[].resolved_model_path`
说明:
- `instance_id` 在第一版中由 FreeCAD 侧生成更合理
- 如果首次进入 3D 时尚未生成 `instance_id`,可以先导出为空,再由 FreeCAD 创建后回写
- JSON 协议中用 `device_instance_id` 表达 3D 设备实例,避免和端子实例混用同一个字段名
- JSON 协议中用 `terminal_instance_id` 表达 3D 端子对象实例
- 数据库绑定表列名仍然保持 `instance_id`,由 QET 在读取 JSON 时把 `device_instance_id / terminal_instance_id` 映射进去
### 15.2 `3d_to_2d.json`
第一版只建议回写:
`3d_to_2d.json` 文件名保持不变,但字段名同步为优化后的 2D/3D 交换协议。
推荐结构:
```json
{
"schema_version": "2.0",
"project_uuid": "string",
"generated_at": "2026-05-18T11:00:00+08:00",
"instances": [
{
"element_uuid": "string",
"device_instance_id": "string"
}
],
"terminals": [
{
"terminal_uuid": "string",
"device_instance_id": "string",
"terminal_instance_id": "string"
}
]
}
```
当前只建议回写:
- `project_uuid`
- `element_uuid`
- `instance_id`
- `device_instance_id`
- `terminal_uuid`
- `terminal_instance_id`
说明:
- `3d_to_2d.json` 不再输出旧字段 `instance_id`
- QET 读取 `instances[].device_instance_id` 后写入 `project_2d3d_symbol_binding.instance_id`
- QET 读取 `terminals[].terminal_instance_id` 后写入 `project_2d3d_terminal_binding.instance_id`
- `terminals[].device_instance_id` 用于说明该端子属于哪个 3D 设备实例,便于校验和排障
当前不要求回写:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -5,6 +5,7 @@ set(FreeCADExchange_Scripts
ExchangeBootstrap.py
DeviceImport.py
DevicePreview.py
PendingDeviceAssemblyPanel.py
TerminalObjects.py
TemplateSemantics.py
TemplateAuthoring.py

@ -1,6 +1,7 @@
import os
from pathlib import Path
import uuid
import json
from datetime import datetime
import FreeCAD as App
@ -28,6 +29,8 @@ TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
GROUP_KIND_TERMINALS = "Terminals"
GROUP_KIND_WIRES = "Wires"
ASSEMBLY_STATE_PENDING = "Pending"
ASSEMBLY_STATE_PLACED = "Placed"
class DeviceImportError(RuntimeError):
@ -525,18 +528,12 @@ def _device_report_label(display_tag, instance_id, element_uuid=""):
def _payload_device_instance_id(device):
if not isinstance(device, dict):
return ""
return (
(device.get("device_instance_id") or "").strip()
or (device.get("instance_id") or "").strip()
)
return (device.get("device_instance_id") or "").strip()
def _payload_device_element_uuid(device):
if not isinstance(device, dict):
return ""
element_uuid = (device.get("element_uuid") or "").strip()
if element_uuid:
return element_uuid
for terminal in device.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
@ -653,6 +650,16 @@ def _update_device_group_metadata(device_group, root_group, element_uuid, instan
)
def _set_device_assembly_state(device_group, state):
_ensure_string_property(
device_group,
"QetAssemblyState",
"QET Assembly",
"Assembly state in the FreeCAD scene.",
state,
)
def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path, display_tag, layout_index):
created_now = False
device_group = _find_device_group_by_instance_id(doc, instance_id)
@ -725,6 +732,282 @@ def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path,
return device_group, created_now
def _register_pending_device(report, device_group, display_tag, instance_id, element_uuid, resolved_model_path):
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PENDING)
report.setdefault("pending_devices", 0)
report.setdefault("pending_device_details", [])
report["pending_devices"] += 1
report["pending_device_details"].append(
_device_change_detail(
display_tag,
instance_id,
element_uuid=element_uuid,
change_types=["待装配"],
resolved_model_path=resolved_model_path,
)
)
def _looks_like_qet_device_group(obj):
if obj is None:
return False
if not getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return False
return bool((getattr(obj, "QetInstanceId", "") or "").strip())
def _find_device_group_from_object(obj):
if _looks_like_qet_device_group(obj):
return obj
pending = list(getattr(obj, "InList", []) or [])
seen = set()
while pending:
parent = pending.pop(0)
parent_name = getattr(parent, "Name", "")
if parent_name in seen:
continue
seen.add(parent_name)
if _looks_like_qet_device_group(parent):
return parent
pending.extend(list(getattr(parent, "InList", []) or []))
return None
def list_pending_devices(doc):
if doc is None:
return []
root = doc.getObject(ROOT_GROUP_NAME)
if root is None:
return []
pending_devices = []
for child in list(getattr(root, "Group", []) or []):
if not _looks_like_qet_device_group(child):
continue
if (getattr(child, "QetAssemblyState", "") or "").strip() != ASSEMBLY_STATE_PENDING:
continue
pending_devices.append(
{
"device": child,
"instance_id": (getattr(child, "QetInstanceId", "") or "").strip(),
"element_uuid": (getattr(child, "QetElementUuid", "") or "").strip(),
"display_tag": (getattr(child, "QetDisplayTag", "") or "").strip(),
"label": getattr(child, "Label", "") or getattr(child, "Name", ""),
"resolved_model_path": (
getattr(child, "QetResolvedModelPath", "") or ""
).strip(),
}
)
return pending_devices
def _target_mount_kind(target_obj):
if target_obj is None:
return ""
kind = (getattr(target_obj, "QetCarrierKind", "") or "").strip()
if kind:
return kind
text = " ".join(
[
getattr(target_obj, "Label", "") or "",
getattr(target_obj, "Name", "") or "",
getattr(target_obj, "TypeId", "") or "",
]
).lower()
if "rail" in text or "din" in text or "导轨" in text:
return "rail"
if "wireduct" in text or "wire_duct" in text or "线槽" in text:
return "wire_duct"
if "plate" in text or "panel" in text or "安装板" in text or "面板" in text:
return "mounting_plate"
if "cabinet" in text or "" in text:
return "cabinet"
return ""
def _placement_for_mount_target(mount_target, fallback_rotation=None):
placement = getattr(mount_target, "Placement", None)
base = getattr(placement, "Base", None)
if placement is None or base is None:
return None
rotation = getattr(placement, "Rotation", None) or fallback_rotation or App.Rotation()
return App.Placement(base, rotation)
def _vector_payload(vector):
return {
"x": float(getattr(vector, "x", 0.0) or 0.0),
"y": float(getattr(vector, "y", 0.0) or 0.0),
"z": float(getattr(vector, "z", 0.0) or 0.0),
}
def _normalized_vector(vector):
if vector is None:
return None
x = float(getattr(vector, "x", 0.0) or 0.0)
y = float(getattr(vector, "y", 0.0) or 0.0)
z = float(getattr(vector, "z", 0.0) or 0.0)
length = (x * x + y * y + z * z) ** 0.5
if length <= 1e-9:
return None
return App.Vector(x / length, y / length, z / length)
def _placement_with_normal_offset(placement, normal=None, offset_mm=0.0):
if placement is None:
return None
normal = _normalized_vector(normal)
if normal is None or not float(offset_mm or 0.0):
return placement
base = getattr(placement, "Base", None)
if base is None:
return placement
offset = float(offset_mm or 0.0)
moved_base = App.Vector(
float(getattr(base, "x", 0.0) or 0.0) + normal.x * offset,
float(getattr(base, "y", 0.0) or 0.0) + normal.y * offset,
float(getattr(base, "z", 0.0) or 0.0) + normal.z * offset,
)
return App.Placement(moved_base, getattr(placement, "Rotation", App.Rotation()))
def _set_device_mount_metadata(device_group, mount_target, normal=None, offset_mm=0.0):
if device_group is None or mount_target is None:
return
target_name = getattr(mount_target, "Name", "") or ""
target_label = getattr(mount_target, "Label", "") or target_name
_ensure_string_property(
device_group,
"QetMountMode",
"QET Mount",
"How this QET device was mounted in the FreeCAD scene.",
"manual_insert",
)
_ensure_string_property(
device_group,
"QetMountHostName",
"QET Mount",
"Mount target object name.",
target_name,
)
_ensure_string_property(
device_group,
"QetMountHostLabel",
"QET Mount",
"Mount target object label.",
target_label,
)
_ensure_string_property(
device_group,
"QetMountHostKind",
"QET Mount",
"Mount target kind.",
_target_mount_kind(mount_target),
)
normal = _normalized_vector(normal)
if normal is not None:
_ensure_string_property(
device_group,
"QetMountHostNormalJson",
"QET Mount",
"Mount target face normal at insert time.",
json.dumps(_vector_payload(normal), sort_keys=True),
)
_ensure_string_property(
device_group,
"QetMountOffsetMm",
"QET Mount",
"Mount offset in target normal direction.",
"{0:.6f}".format(float(offset_mm or 0.0)),
)
def insert_pending_device(
doc,
device_group,
source_doc_cache=None,
mount_target=None,
mount_placement=None,
mount_normal=None,
mount_offset_mm=0.0,
):
if doc is None:
raise DeviceImportError("A FreeCAD document is required.")
device_group = _find_device_group_from_object(device_group)
if device_group is None:
raise DeviceImportError("请选择一个待装配 QET 设备。")
model_path = _native_path(getattr(device_group, "QetResolvedModelPath", ""))
if not model_path:
raise DeviceImportError("待装配设备缺少模型路径。")
if not os.path.isfile(model_path):
raise DeviceImportError("待装配设备模型文件不存在:{0}".format(model_path))
if not _supported_for_import(model_path):
raise DeviceImportError("待装配设备模型格式暂不支持:{0}".format(model_path))
existing_model_objects = _existing_model_objects(doc, device_group)
if existing_model_objects:
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
target_placement = mount_placement or _placement_for_mount_target(
mount_target,
getattr(getattr(device_group, "Placement", None), "Rotation", None),
)
target_placement = _placement_with_normal_offset(
target_placement,
mount_normal,
mount_offset_mm,
)
if target_placement is not None:
device_group.Placement = target_placement
_set_device_mount_metadata(
device_group,
mount_target,
normal=mount_normal,
offset_mm=mount_offset_mm,
)
return {
"device": device_group,
"imported_objects": existing_model_objects,
"already_placed": True,
}
_clear_group_contents(doc, device_group)
imported_objects = _import_model_into_group(
doc,
device_group,
model_path,
source_doc_cache=source_doc_cache if source_doc_cache is not None else {},
)
target_placement = mount_placement or _placement_for_mount_target(
mount_target,
getattr(getattr(device_group, "Placement", None), "Rotation", None),
)
target_placement = _placement_with_normal_offset(
target_placement,
mount_normal,
mount_offset_mm,
)
if target_placement is not None:
device_group.Placement = target_placement
_set_device_mount_metadata(
device_group,
mount_target,
normal=mount_normal,
offset_mm=mount_offset_mm,
)
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
try:
doc.recompute()
except Exception:
pass
return {
"device": device_group,
"imported_objects": list(imported_objects or []),
"already_placed": False,
}
def _remove_object_tree(doc, obj):
if obj is None:
return
@ -1208,15 +1491,9 @@ def _close_cached_source_documents(source_doc_cache, target_doc=None):
def _model_index(payload):
index = {}
for item in payload.get("device_models", []):
instance_id = (
(item.get("device_instance_id") or "").strip()
or (item.get("instance_id") or "").strip()
)
element_uuid = (item.get("element_uuid") or "").strip()
instance_id = (item.get("device_instance_id") or "").strip()
if instance_id and instance_id not in index:
index[instance_id] = item
if element_uuid and element_uuid not in index:
index[element_uuid] = item
return index
@ -1313,7 +1590,7 @@ def _import_cabinet_model(doc, root_group, cabinet, report, source_doc_cache=Non
)
def import_devices_from_payload(payload, scene_path=""):
def import_devices_from_payload(payload, scene_path="", auto_insert_pending_devices=False):
_append_debug_log("DeviceImport.import_devices_from_payload entered")
doc = _ensure_document(scene_path)
cabinet = payload.get("cabinet")
@ -1345,6 +1622,8 @@ def import_devices_from_payload(payload, scene_path=""):
"cabinet_skipped_missing_file": 0,
"cabinet_skipped_unsupported_format": 0,
"cabinet_skipped_import_error": 0,
"pending_devices": 0,
"pending_device_details": [],
"warnings": [],
}
@ -1390,8 +1669,6 @@ def import_devices_from_payload(payload, scene_path=""):
doc, existing_device_group
)
model_info = models_by_element.get(instance_id, {})
if not model_info and element_uuid:
model_info = models_by_element.get(element_uuid, {})
resolved_model_path = _native_path(model_info.get("resolved_model_path", ""))
_append_debug_log(
"DeviceImport device instance_id={0}, display_tag={1}, resolved_model_path={2}".format(
@ -1490,7 +1767,25 @@ def import_devices_from_payload(payload, scene_path=""):
not created_now
and (not existing_model_objects or not same_source)
)
if not auto_insert_pending_devices and not existing_model_objects:
_register_pending_device(
report,
device_group,
display_tag,
instance_id,
element_uuid,
resolved_model_path,
)
_append_debug_log(
"DeviceImport registered pending device without importing model: instance_id={0}, model_path={1}".format(
instance_id,
resolved_model_path,
)
)
continue
if existing_model_objects and same_source:
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
if display_tag_changed or terminals_changed:
change_types = []
if display_tag_changed:
@ -1562,6 +1857,7 @@ def import_devices_from_payload(payload, scene_path=""):
)
if existing_model_objects:
_remove_model_objects(doc, existing_model_objects)
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
except Exception as exc:
if existing_model_objects:
_ensure_string_property(
@ -1643,3 +1939,66 @@ def import_devices_from_payload(payload, scene_path=""):
)
)
return report
class CommandInsertPendingDevice:
def GetResources(self):
return {
"MenuText": "插入待装配设备",
"ToolTip": "将选中的 QET 待装配设备模型插入当前 3D 场景",
}
def IsActive(self):
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
def Activated(self):
if Gui is None:
return
selection = list(Gui.Selection.getSelection() or [])
device_group = None
for obj in selection:
device_group = _find_device_group_from_object(obj)
if device_group is not None:
break
if device_group is None:
try:
App.Console.PrintWarning("[FreeCADExchange] 请先选择一个待装配 QET 设备。\n")
except Exception:
pass
return
try:
result = insert_pending_device(App.ActiveDocument, device_group)
try:
App.Console.PrintMessage(
"[FreeCADExchange] 已插入设备:{0},导入对象 {1} 个。\n".format(
getattr(result["device"], "Label", ""),
len(result.get("imported_objects", []) or []),
)
)
except Exception:
pass
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError("[FreeCADExchange] 插入待装配设备失败:{0}\n".format(exc))
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
try:
Gui.addCommand("QET_Exchange_InsertPendingDevice", CommandInsertPendingDevice())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register pending device command: {0}".format(exc))

@ -567,7 +567,7 @@ def _normalize_devices(payload):
normalized_terminals.append(
{
"terminal_uuid": terminal_uuid,
"instance_id": device_instance_id,
"device_instance_id": device_instance_id,
"element_uuid": terminal_element_uuid,
"terminal_display": _optional_string(
terminal_item, "terminal_display", terminal_entry_label
@ -591,7 +591,7 @@ def _normalize_devices(payload):
{
"element_uuid": element_uuid,
"element_uuids": list(device_element_uuids),
"instance_id": device_instance_id,
"device_instance_id": device_instance_id,
"display_tag": display_tag.strip() if isinstance(display_tag, str) else "",
"terminals": normalized_terminals,
}
@ -604,8 +604,8 @@ def _normalize_terminals(devices):
for device in devices:
for terminal in device.get("terminals", []) or []:
entry = dict(terminal)
if not entry.get("instance_id"):
entry["instance_id"] = device.get("instance_id", "")
if not entry.get("device_instance_id"):
entry["device_instance_id"] = device.get("device_instance_id", "")
normalized.append(entry)
return normalized
@ -618,23 +618,6 @@ def _normalize_top_level_terminals(payload):
return []
def _merge_terminal_entries(*terminal_groups):
merged = []
seen = set()
for terminal_group in terminal_groups:
for item in terminal_group:
key = (
item.get("terminal_uuid", ""),
item.get("element_uuid", ""),
item.get("instance_id", ""),
)
if key in seen:
continue
seen.add(key)
merged.append(item)
return merged
def _optional_string(item, field_name, entry_label):
value = item.get(field_name, "")
if value is None:
@ -710,10 +693,8 @@ def _normalize_wires(payload):
"wire_mark_is_manual": wire_mark_is_manual,
"wire_style_id": _optional_text(item, "wire_style_id"),
"start_element_uuid": _optional_string(item, "start_element_uuid", entry_label),
"start_instance_id": _optional_string(item, "start_instance_id", entry_label),
"start_terminal_uuid": _optional_string(item, "start_terminal_uuid", entry_label),
"end_element_uuid": _optional_string(item, "end_element_uuid", entry_label),
"end_instance_id": _optional_string(item, "end_instance_id", entry_label),
"end_terminal_uuid": _optional_string(item, "end_terminal_uuid", entry_label),
"start_terminal_display": _optional_string(item, "start_terminal_display", entry_label),
"end_terminal_display": _optional_string(item, "end_terminal_display", entry_label),
@ -747,7 +728,6 @@ def _normalize_device_models(payload):
entry_label
)
)
element_uuid = ""
instance_id = _require_string(item, "device_instance_id")
parts_3d = item.get("parts_3d", "")
if parts_3d and not isinstance(parts_3d, str):
@ -774,8 +754,7 @@ def _normalize_device_models(payload):
normalized.append(
{
"element_uuid": element_uuid,
"instance_id": instance_id,
"device_instance_id": instance_id,
"device_id": device_id,
"parts_3d": parts_3d.strip() if isinstance(parts_3d, str) else "",
"resolved_model_path": (
@ -906,16 +885,17 @@ def load_exchange_payload(json_path):
raise ExchangeValidationError("Exchange JSON root must be an object.")
project_uuid = _require_string(payload, "project_uuid")
schema_version = payload.get("schema_version", "1.0")
schema_version = payload.get("schema_version", "")
if not isinstance(schema_version, str) or not schema_version.strip():
raise ExchangeValidationError("Field 'schema_version' must be a string.")
if schema_version.strip() != "2.0":
raise ExchangeValidationError(
"Field 'schema_version' must be '2.0' for the 2D/3D exchange v2 protocol."
)
normalized_devices = _normalize_devices(payload)
normalized_terminals = _merge_terminal_entries(
_normalize_terminals(normalized_devices),
_normalize_top_level_terminals(payload),
)
_normalize_top_level_terminals(payload)
normalized = {
"schema_version": schema_version.strip(),
@ -924,7 +904,6 @@ def load_exchange_payload(json_path):
"source": payload.get("source", {}),
"cabinet": _normalize_cabinet(payload),
"devices": normalized_devices,
"terminals": normalized_terminals,
"device_models": _normalize_device_models(payload),
"wires": _normalize_wires(payload),
}
@ -936,13 +915,13 @@ def load_exchange_payload(json_path):
def _build_summary(payload, json_path):
devices = payload["devices"]
terminals = payload["terminals"]
terminals = _normalize_terminals(devices)
device_models = payload["device_models"]
wires = payload.get("wires", [])
cabinet = payload.get("cabinet")
missing_device_instances = sum(1 for item in devices if not item["instance_id"])
missing_device_instances = sum(1 for item in devices if not item["device_instance_id"])
missing_terminal_instances = sum(
1 for item in terminals if not item["instance_id"]
1 for item in terminals if not item["device_instance_id"]
)
with_model_paths = sum(
1 for item in device_models if item["resolved_model_path"] or item["parts_3d"]
@ -1073,8 +1052,8 @@ def _mark_stale_objects(payload):
# Log each payload device element_uuid for comparison
for item in (payload.get("devices", []) or [])[:10]:
_append_debug_log(
" payload device: element_uuid={0}, instance_id={1}".format(
item.get("element_uuid", ""), item.get("instance_id", "")
" payload device: element_uuid={0}, device_instance_id={1}".format(
item.get("element_uuid", ""), item.get("device_instance_id", "")
)
)

@ -12,6 +12,8 @@ COMMANDS = [
"QET_Template_AddTerminal",
"QET_Template_ValidateTerminals",
"QET_Template_SaveAsFCStd",
"QET_Exchange_OpenPendingDevicePanel",
"QET_Exchange_InsertPendingDevice",
"QET_Template_ImportInstance",
"QET_Template_CreateEngineeringTerminals",
"QET_Exchange_CreateManualWire",
@ -72,6 +74,8 @@ def _register_exchange_commands(
auto_routing_panel = safe_import("AutoRoutingPanel")
manual_wiring = safe_import("ManualWiring")
manual_wiring_panel = safe_import("ManualWiringPanel")
device_import = safe_import("DeviceImport")
pending_device_panel = safe_import("PendingDeviceAssemblyPanel")
stale_object_actions = safe_import("StaleObjectActions")
template_authoring = safe_import("TemplateAuthoring")
template_authoring_panel = safe_import("TemplateAuthoringPanel")
@ -117,6 +121,26 @@ def _register_exchange_commands(
)
)
try:
if pending_device_panel is not None:
pending_device_panel.register_commands()
except Exception:
append_init_log(
"InitGui failed to register pending device panel command:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if device_import is not None:
device_import.register_commands()
except Exception:
append_init_log(
"InitGui failed to register pending device command:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if manual_wiring is not None:
manual_wiring.register_commands()

@ -0,0 +1,309 @@
# FreeCADExchange GUI panel for QET pending device assembly.
from pathlib import Path
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
try:
from PySide2 import QtCore, QtWidgets
except ImportError:
try:
from PySide import QtCore
from PySide import QtGui as QtWidgets
except ImportError:
QtCore = None
QtWidgets = None
import DeviceImport
COMMAND_NAME = "QET_Exchange_OpenPendingDevicePanel"
class PendingDeviceAssemblyPanelError(RuntimeError):
pass
def pending_device_rows(doc):
rows = []
for item in DeviceImport.list_pending_devices(doc):
model_path = (item.get("resolved_model_path", "") or "").strip()
model_name = Path(model_path).name if model_path else "未绑定模型"
display_tag = (item.get("display_tag", "") or "").strip()
label = display_tag or (item.get("label", "") or "").strip()
instance_id = (item.get("instance_id", "") or "").strip()
title = label or instance_id or "未命名设备"
rows.append(
{
"device": item.get("device"),
"display_tag": display_tag,
"instance_id": instance_id,
"element_uuid": (item.get("element_uuid", "") or "").strip(),
"resolved_model_path": model_path,
"display_text": "{0} {1}".format(title, model_name),
}
)
return rows
def _document():
doc = getattr(App, "ActiveDocument", None)
if doc is None:
raise PendingDeviceAssemblyPanelError("请先打开 FreeCAD 工程。")
return doc
def _selected_objects():
if Gui is None:
return []
try:
return list(Gui.Selection.getSelection() or [])
except Exception:
return []
def selected_mount_target(exclude_device=None):
excluded = DeviceImport._find_device_group_from_object(exclude_device)
for obj in _selected_objects():
candidate_device = DeviceImport._find_device_group_from_object(obj)
if excluded is not None and candidate_device is excluded:
continue
return obj
return None
def _vector_from_tuple(value):
try:
if value is None:
return None
return App.Vector(value[0], value[1], value[2])
except Exception:
return None
def _face_anchor_point(face):
if face is None:
return None
for attr_name in ("CenterOfMass", "Center"):
point = getattr(face, attr_name, None)
if point is not None:
return point
try:
return face.valueAt(0.0, 0.0)
except Exception:
return None
def _face_normal(face):
if face is None:
return None
for attr_name in ("normalAt", "NormalAt"):
normal_getter = getattr(face, attr_name, None)
if callable(normal_getter):
try:
return normal_getter(0.0, 0.0)
except Exception:
pass
return getattr(face, "Normal", None)
def selected_mount_context(exclude_device=None):
excluded = DeviceImport._find_device_group_from_object(exclude_device)
if Gui is not None:
try:
selection_ex = list(Gui.Selection.getSelectionEx() or [])
except Exception:
selection_ex = []
for selected in selection_ex:
obj = getattr(selected, "Object", None)
candidate_device = DeviceImport._find_device_group_from_object(obj)
if excluded is not None and candidate_device is excluded:
continue
picked_points = list(getattr(selected, "PickedPoints", []) or [])
point = picked_points[0] if picked_points else None
if point is None:
for sub_object in list(getattr(selected, "SubObjects", []) or []):
if (getattr(sub_object, "ShapeType", "") or "").lower() == "face":
point = _face_anchor_point(sub_object)
normal = _face_normal(sub_object)
break
else:
normal = None
for sub_object in list(getattr(selected, "SubObjects", []) or []):
if (getattr(sub_object, "ShapeType", "") or "").lower() == "face":
normal = _face_normal(sub_object)
break
if point is not None:
rotation = getattr(getattr(obj, "Placement", None), "Rotation", None)
return {
"target": obj,
"placement": App.Placement(point, rotation or App.Rotation()),
"normal": normal,
}
if obj is not None:
return {"target": obj, "placement": None, "normal": None}
target = selected_mount_target(exclude_device=exclude_device)
return {"target": target, "placement": None, "normal": None}
def _set_status(label, message, error=False):
try:
label.setText(message)
except Exception:
pass
try:
if error:
App.Console.PrintError("[FreeCADExchange] {0}\n".format(message))
else:
App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message))
except Exception:
pass
class PendingDeviceAssemblyTaskPanel:
def __init__(self):
if QtWidgets is None:
raise PendingDeviceAssemblyPanelError("Qt widgets are not available.")
self.rows = []
self.form = QtWidgets.QWidget()
self.form.setWindowTitle("QET待装配设备")
layout = QtWidgets.QVBoxLayout(self.form)
self.device_list = QtWidgets.QListWidget()
layout.addWidget(self.device_list)
self.refresh_button = QtWidgets.QPushButton("刷新清单")
self.insert_button = QtWidgets.QPushButton("插入设备")
self.insert_to_target_button = QtWidgets.QPushButton("插入到选中目标")
layout.addWidget(self.refresh_button)
layout.addWidget(self.insert_button)
offset_row = QtWidgets.QHBoxLayout()
offset_row.addWidget(QtWidgets.QLabel("贴合间距"))
self.mount_offset_input = QtWidgets.QDoubleSpinBox()
self.mount_offset_input.setRange(-10000.0, 10000.0)
self.mount_offset_input.setDecimals(1)
self.mount_offset_input.setSingleStep(1.0)
self.mount_offset_input.setSuffix(" mm")
self.mount_offset_input.setValue(0.0)
offset_row.addWidget(self.mount_offset_input)
layout.addLayout(offset_row)
layout.addWidget(self.insert_to_target_button)
self.status_label = QtWidgets.QLabel("")
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
self.refresh_button.clicked.connect(self.refresh)
self.insert_button.clicked.connect(self.insert_selected_device)
self.insert_to_target_button.clicked.connect(self.insert_selected_device_to_target)
self.refresh()
def _selected_device(self):
item = self.device_list.currentItem()
if item is None:
raise PendingDeviceAssemblyPanelError("请先在清单中选择一个待装配设备。")
row_index = self.device_list.row(item)
if row_index < 0 or row_index >= len(self.rows):
raise PendingDeviceAssemblyPanelError("待装配设备清单已变化,请刷新后重试。")
device = self.rows[row_index].get("device")
if device is None:
raise PendingDeviceAssemblyPanelError("待装配设备无效,请刷新后重试。")
return device
def refresh(self):
try:
self.rows = pending_device_rows(getattr(App, "ActiveDocument", None))
self.device_list.clear()
for row in self.rows:
self.device_list.addItem(row["display_text"])
if self.rows:
self.device_list.setCurrentRow(0)
_set_status(self.status_label, "待装配设备:{0}".format(len(self.rows)))
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def insert_selected_device(self):
try:
result = DeviceImport.insert_pending_device(_document(), self._selected_device())
self.refresh()
_set_status(
self.status_label,
"已插入设备:{0}".format(getattr(result["device"], "Label", "") or getattr(result["device"], "Name", "")),
)
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def insert_selected_device_to_target(self):
try:
device = self._selected_device()
context = selected_mount_context(exclude_device=device)
target = context.get("target")
if target is None:
raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择安装板、导轨、线槽或柜体安装面。")
result = DeviceImport.insert_pending_device(
_document(),
device,
mount_target=target,
mount_placement=context.get("placement"),
mount_normal=context.get("normal"),
mount_offset_mm=self.mount_offset_input.value(),
)
self.refresh()
_set_status(
self.status_label,
"已插入设备到选中目标:{0}".format(
getattr(result["device"], "Label", "") or getattr(result["device"], "Name", "")
),
)
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def accept(self):
return True
def reject(self):
return True
class CommandOpenPendingDevicePanel:
def GetResources(self):
return {
"MenuText": "待装配设备",
"ToolTip": "打开 QET 待装配设备清单,并将设备插入到当前 3D 场景",
}
def IsActive(self):
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
def Activated(self):
if Gui is None or not hasattr(Gui, "Control"):
return
if hasattr(Gui.Control, "activeDialog") and Gui.Control.activeDialog():
Gui.Control.closeDialog()
Gui.Control.showDialog(PendingDeviceAssemblyTaskPanel())
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
Gui.addCommand(COMMAND_NAME, CommandOpenPendingDevicePanel())
_COMMANDS_REGISTERED = True

File diff suppressed because it is too large Load Diff

@ -64,9 +64,7 @@ def _payload_identity_sets(payload):
break
for item in payload.get("devices", []) or []:
instance_id = _string_value(item, "device_instance_id") or _string_value(
item, "instance_id"
)
instance_id = _string_value(item, "device_instance_id")
if instance_id:
device_instance_ids.add(instance_id)
for terminal in item.get("terminals", []) or []:
@ -74,11 +72,6 @@ def _payload_identity_sets(payload):
if terminal_uuid:
terminal_uuids.add(terminal_uuid)
for item in payload.get("terminals", []) or []:
terminal_uuid = _string_value(item, "terminal_uuid")
if terminal_uuid:
terminal_uuids.add(terminal_uuid)
for item in payload.get("wires", []) or []:
wire_uuid = (
_string_value(item, "wire_id")

@ -38,10 +38,7 @@ def _normalize_terminal_entry(item, index):
"Terminal entry #{0} is missing terminal_uuid.".format(index)
)
instance_id = (
(item.get("device_instance_id") or "").strip()
or (item.get("instance_id") or "").strip()
)
device_instance_id = (item.get("device_instance_id") or "").strip()
element_uuid = (item.get("element_uuid") or "").strip()
terminal_display = (item.get("terminal_display") or "").strip()
slot_name_hint = (
@ -55,7 +52,7 @@ def _normalize_terminal_entry(item, index):
return {
"terminal_uuid": terminal_uuid,
"instance_id": instance_id,
"device_instance_id": device_instance_id,
"element_uuid": element_uuid,
"terminal_display": terminal_display,
"slot_name_hint": slot_name_hint,
@ -70,10 +67,7 @@ def _payload_device_lookup(payload):
if not isinstance(item, dict):
continue
instance_id = (
(item.get("device_instance_id") or "").strip()
or (item.get("instance_id") or "").strip()
)
instance_id = (item.get("device_instance_id") or "").strip()
if instance_id:
by_instance_id.add(instance_id)
@ -99,10 +93,7 @@ def _payload_device_instance_by_element(payload):
for device in payload.get("devices", []) or []:
if not isinstance(device, dict):
continue
device_instance_id = (
(device.get("device_instance_id") or "").strip()
or (device.get("instance_id") or "").strip()
)
device_instance_id = (device.get("device_instance_id") or "").strip()
if not device_instance_id:
continue
for terminal in device.get("terminals", []) or []:
@ -111,43 +102,26 @@ def _payload_device_instance_by_element(payload):
element_uuid = (terminal.get("element_uuid") or "").strip()
if element_uuid and element_uuid not in result:
result[element_uuid] = device_instance_id
for terminal in payload.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
element_uuid = (terminal.get("element_uuid") or "").strip()
device_instance_id = (
(terminal.get("device_instance_id") or "").strip()
or (terminal.get("instance_id") or "").strip()
)
if element_uuid and device_instance_id and element_uuid not in result:
result[element_uuid] = device_instance_id
return result
def _payload_terminal_entries(payload):
if "terminals" in payload and payload.get("terminals") is not None:
terminal_entries = payload.get("terminals", [])
if not isinstance(terminal_entries, list):
raise TerminalImportError("Field 'terminals' must be a list.")
return list(terminal_entries)
raise TerminalImportError(
"Field 'terminals' at the JSON root is no longer supported. Use devices[].terminals[]."
)
terminal_entries = []
for device in payload.get("devices", []) or []:
if not isinstance(device, dict):
continue
device_instance_id = (
(device.get("device_instance_id") or "").strip()
or (device.get("instance_id") or "").strip()
)
device_instance_id = (device.get("device_instance_id") or "").strip()
for terminal in device.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
entry = dict(terminal)
if device_instance_id and not (
(entry.get("device_instance_id") or "").strip()
or (entry.get("instance_id") or "").strip()
):
entry["instance_id"] = device_instance_id
if device_instance_id:
entry["device_instance_id"] = device_instance_id
terminal_entries.append(entry)
return terminal_entries
@ -163,8 +137,7 @@ def _device_embedded_terminal_entries(payload, existing_keys):
if not isinstance(device, dict):
continue
device_element_uuid = (device.get("element_uuid") or "").strip()
device_instance_id = (device.get("instance_id") or "").strip()
device_instance_id = (device.get("device_instance_id") or "").strip()
device_terminals = device.get("terminals", []) or []
if not isinstance(device_terminals, list):
continue
@ -173,9 +146,8 @@ def _device_embedded_terminal_entries(payload, existing_keys):
if not isinstance(terminal, dict):
continue
terminal_uuid = (terminal.get("terminal_uuid") or "").strip()
element_uuid = (terminal.get("element_uuid") or "").strip() or device_element_uuid
instance_id = (terminal.get("instance_id") or "").strip() or device_instance_id
if not terminal_uuid or not (element_uuid or instance_id):
element_uuid = (terminal.get("element_uuid") or "").strip()
if not terminal_uuid or not (element_uuid or device_instance_id):
continue
# QET 的正式端子可能直接挂在 devices[].terminals[] 下。
@ -194,7 +166,7 @@ def _device_embedded_terminal_entries(payload, existing_keys):
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"instance_id": instance_id,
"device_instance_id": device_instance_id,
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
@ -216,10 +188,8 @@ def _wire_endpoint_terminal_entries(payload, existing_keys):
for side in ("start", "end"):
terminal_uuid = (wire.get("{0}_terminal_uuid".format(side)) or "").strip()
element_uuid = (wire.get("{0}_element_uuid".format(side)) or "").strip()
instance_id = (wire.get("{0}_instance_id".format(side)) or "").strip()
if not instance_id and element_uuid:
instance_id = instance_by_element.get(element_uuid, "")
if not terminal_uuid or not (element_uuid or instance_id):
device_instance_id = instance_by_element.get(element_uuid, "")
if not terminal_uuid or not (element_uuid or device_instance_id):
continue
key = (element_uuid, terminal_uuid)
@ -235,7 +205,7 @@ def _wire_endpoint_terminal_entries(payload, existing_keys):
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"instance_id": instance_id,
"device_instance_id": device_instance_id,
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
@ -244,7 +214,7 @@ def _wire_endpoint_terminal_entries(payload, existing_keys):
def _terminal_belongs_to_payload_devices(entry, device_lookup):
instance_id = entry["instance_id"]
instance_id = entry["device_instance_id"]
element_uuid = entry["element_uuid"]
if instance_id and instance_id in device_lookup["instance_ids"]:
@ -324,7 +294,7 @@ def _device_key(device_group):
def _locate_device_group(doc, entry):
instance_id = entry["instance_id"]
instance_id = entry["device_instance_id"]
element_uuid = entry["element_uuid"]
device_group = None

@ -419,7 +419,50 @@ def _normalized_direction(vector):
return App.Vector(vector.x / length, vector.y / length, vector.z / length)
def _direction_from_payload(value):
if isinstance(value, dict):
try:
return App.Vector(
float(value.get("x", 0.0) or 0.0),
float(value.get("y", 0.0) or 0.0),
float(value.get("z", 0.0) or 0.0),
)
except Exception:
return None
if isinstance(value, (list, tuple)) and len(value) >= 3:
try:
return App.Vector(float(value[0] or 0.0), float(value[1] or 0.0), float(value[2] or 0.0))
except Exception:
return None
return None
def _explicit_terminal_exit_direction(obj):
for property_name in ("QetTerminalExitDirectionJson", "QetExitDirectionJson"):
raw = str(getattr(obj, property_name, "") or "").strip()
if not raw:
continue
parsed = None
try:
parsed = json.loads(raw)
except Exception:
parts = [part.strip() for part in raw.replace(";", ",").split(",")]
if len(parts) >= 3:
parsed = parts[:3]
direction = _direction_from_payload(parsed)
if direction is None:
continue
normalized = _normalized_direction(direction)
if abs(normalized.x) + abs(normalized.y) + abs(normalized.z) > 1e-9:
return normalized
return None
def terminal_direction(obj):
explicit_direction = _explicit_terminal_exit_direction(obj)
if explicit_direction is not None:
return explicit_direction
direction = App.Vector(0, 0, 1)
try:
@ -464,6 +507,13 @@ def terminal_direction(obj):
return App.Vector(0, 0, 1)
def terminal_direction_source(obj):
"""Return where the terminal exit direction currently comes from."""
if _explicit_terminal_exit_direction(obj) is not None:
return "explicit"
return "lcs"
def create_lcs_object(doc, name_hint, placement=None, label=None):
base_name = safe_token(name_hint) or "QETTerminal"
object_name = _unique_object_name(doc, base_name)

@ -47,13 +47,46 @@ def _device_display_map(payload):
for item in payload.get("devices", []) or []:
if not isinstance(item, dict):
continue
element_uuid = _string_value(item, "element_uuid")
display_tag = _string_value(item, "display_tag")
if element_uuid and display_tag:
if not display_tag:
continue
# 新交换协议中,一个 3D 设备实例可能合并多个 2D 符号;
# 导线端点仍用 element_uuid所以这里要把组内所有 2D 符号都映射到同一设备标注。
candidate_element_uuids = []
for terminal in item.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
element_uuid = _string_value(terminal, "element_uuid")
if element_uuid:
candidate_element_uuids.append(element_uuid)
for element_uuid in candidate_element_uuids:
labels[element_uuid] = display_tag
return labels
def _endpoint_instance_map(payload):
by_terminal = {}
by_element = {}
for device in payload.get("devices", []) or []:
if not isinstance(device, dict):
continue
device_instance_id = _string_value(device, "device_instance_id")
if not device_instance_id:
continue
for terminal in device.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
element_uuid = _string_value(terminal, "element_uuid")
terminal_uuid = _string_value(terminal, "terminal_uuid")
if element_uuid:
by_element.setdefault(element_uuid, device_instance_id)
if element_uuid and terminal_uuid:
by_terminal[(element_uuid, terminal_uuid)] = device_instance_id
return by_terminal, by_element
def _endpoint_text(device_label, terminal_display, terminal_uuid):
terminal = terminal_display or terminal_uuid
if device_label and terminal:
@ -61,11 +94,12 @@ def _endpoint_text(device_label, terminal_display, terminal_uuid):
return device_label or terminal or "未命名端子"
def _normalize_wire_entry(item, index, device_labels=None):
def _normalize_wire_entry(item, index, device_labels=None, endpoint_instances=None):
if not isinstance(item, dict):
raise WiringImportError("Wire entry #{0} must be an object.".format(index))
device_labels = device_labels or {}
endpoint_by_terminal, endpoint_by_element = endpoint_instances or ({}, {})
wire_uuid = (
_string_value(item, "wire_id")
or _string_value(item, "wire_uuid")
@ -89,6 +123,14 @@ def _normalize_wire_entry(item, index, device_labels=None):
end_element_uuid = _string_value(item, "end_element_uuid")
start_terminal_display = _string_value(item, "start_terminal_display")
end_terminal_display = _string_value(item, "end_terminal_display")
start_instance_id = endpoint_by_terminal.get(
(start_element_uuid, start_terminal_uuid),
endpoint_by_element.get(start_element_uuid, ""),
)
end_instance_id = endpoint_by_terminal.get(
(end_element_uuid, end_terminal_uuid),
endpoint_by_element.get(end_element_uuid, ""),
)
start_device_label = _string_value(item, "start_device_label") or device_labels.get(
start_element_uuid, ""
)
@ -109,12 +151,12 @@ def _normalize_wire_entry(item, index, device_labels=None):
"wire_style_id": _int_text_value(item, "wire_style_id"),
"start_element_uuid": start_element_uuid,
"start_terminal_uuid": start_terminal_uuid,
"start_instance_id": _string_value(item, "start_instance_id"),
"start_instance_id": start_instance_id,
"start_terminal_display": start_terminal_display,
"start_device_label": start_device_label,
"end_element_uuid": end_element_uuid,
"end_terminal_uuid": end_terminal_uuid,
"end_instance_id": _string_value(item, "end_instance_id"),
"end_instance_id": end_instance_id,
"end_terminal_display": end_terminal_display,
"end_device_label": end_device_label,
"endpoint_label": endpoint_label,
@ -243,9 +285,15 @@ def import_wire_tasks_from_payload(payload, doc=None):
}
device_labels = _device_display_map(payload)
endpoint_instances = _endpoint_instance_map(payload)
for index, item in enumerate(wires):
try:
entry = _normalize_wire_entry(item, index, device_labels=device_labels)
entry = _normalize_wire_entry(
item,
index,
device_labels=device_labels,
endpoint_instances=endpoint_instances,
)
except WiringImportError as exc:
report["skipped_invalid"] += 1
report["warnings"].append(str(exc))

@ -0,0 +1,148 @@
r"""Smoke test for QET pending-device scene persistence.
Run with FreeCADCmd.exe, not system Python:
D:\fc\run-FreeCAD-1.1.1\bin\FreeCADCmd.exe tests\manual\freecad_pending_device_scene_smoke.py
"""
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
import FreeCAD as App
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))
import DeviceImport # noqa: E402
def _close_doc(doc):
if doc is None:
return
try:
App.closeDocument(doc.Name)
except Exception:
pass
def _make_source_model(path):
doc = App.newDocument("SmokeSourceModel")
try:
body = doc.addObject("Part::Box", "Body")
body.Length = 10
body.Width = 8
body.Height = 6
doc.recompute()
doc.saveAs(str(path))
finally:
_close_doc(doc)
def _assert_close(actual, expected, label):
if abs(float(actual) - float(expected)) > 1e-6:
raise AssertionError("{0}: expected {1}, got {2}".format(label, expected, actual))
def main():
temp_dir = Path(tempfile.mkdtemp(prefix="qet_pending_device_smoke_"))
try:
source_model = temp_dir / "device.FCStd"
scene_file = temp_dir / "QETScene.FCStd"
_make_source_model(source_model)
scene = App.newDocument("QETSceneSmoke")
root = DeviceImport._ensure_root_group(scene, None, "project-smoke")
device_group, created = DeviceImport._ensure_device_group(
scene,
root,
"element-smoke",
"instance-smoke",
str(source_model),
"N600",
0,
)
if not created:
raise AssertionError("smoke device group should be newly created")
DeviceImport._set_device_assembly_state(
device_group,
DeviceImport.ASSEMBLY_STATE_PENDING,
)
mount_target = scene.addObject("App::Part", "MountingPlate")
mount_target.Label = "安装板"
mount_target.Placement = App.Placement(App.Vector(100, 200, 300), App.Rotation())
DeviceImport._ensure_string_property(
mount_target,
"QetCarrierKind",
"QET Mount",
"Smoke mount target kind",
"mounting_plate",
)
DeviceImport.insert_pending_device(
scene,
device_group,
mount_target=mount_target,
mount_placement=App.Placement(App.Vector(10, 20, 30), App.Rotation()),
mount_normal=App.Vector(0, 0, 1),
mount_offset_mm=5.0,
)
device_group_name = device_group.Name
scene.recompute()
scene.saveAs(str(scene_file))
_close_doc(scene)
reopened = App.openDocument(str(scene_file))
try:
reopened_device = reopened.getObject(device_group_name)
if reopened_device is None:
raise AssertionError("reopened scene does not contain device group")
if getattr(reopened_device, "QetAssemblyState", "") != DeviceImport.ASSEMBLY_STATE_PLACED:
raise AssertionError("reopened device is not marked Placed")
_assert_close(reopened_device.Placement.Base.x, 10.0, "placement x")
_assert_close(reopened_device.Placement.Base.y, 20.0, "placement y")
_assert_close(reopened_device.Placement.Base.z, 35.0, "placement z")
if getattr(reopened_device, "QetMountHostName", "") != "MountingPlate":
raise AssertionError("mount host name was not persisted")
if getattr(reopened_device, "QetMountHostKind", "") != "mounting_plate":
raise AssertionError("mount host kind was not persisted")
if getattr(reopened_device, "QetMountOffsetMm", "") != "5.000000":
raise AssertionError("mount offset was not persisted")
normal_payload = json.loads(getattr(reopened_device, "QetMountHostNormalJson", "{}") or "{}")
_assert_close(normal_payload.get("z", 0.0), 1.0, "normal z")
finally:
_close_doc(reopened)
result_path = os.environ.get("QET_PENDING_DEVICE_SMOKE_RESULT", "").strip()
if result_path:
Path(result_path).write_text(
json.dumps(
{
"ok": True,
"scene": str(scene_file),
"device": device_group_name,
"placement": {"x": 10.0, "y": 20.0, "z": 35.0},
"assembly_state": DeviceImport.ASSEMBLY_STATE_PLACED,
"mount_host": "MountingPlate",
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
print("SMOKE_OK scene={0}".format(scene_file))
return 0
finally:
if os.environ.get("QET_KEEP_SMOKE_OUTPUT", "").strip() != "1":
shutil.rmtree(str(temp_dir), ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

@ -109,7 +109,7 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "1.2",
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
@ -166,20 +166,27 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
path.write_text(json.dumps(payload), encoding="utf-8")
normalized = bootstrap.load_exchange_payload(str(path))
self.assertEqual("device-inst-1", normalized["devices"][0]["instance_id"])
self.assertEqual("device-inst-1", normalized["devices"][0]["device_instance_id"])
self.assertEqual("element-a", normalized["devices"][0]["element_uuid"])
self.assertEqual(["element-a"], normalized["devices"][0]["element_uuids"])
self.assertEqual(1, len(normalized["terminals"]))
self.assertEqual("terminal-a", normalized["terminals"][0]["terminal_uuid"])
self.assertEqual("device-inst-1", normalized["terminals"][0]["instance_id"])
self.assertEqual("device-inst-1", normalized["device_models"][0]["instance_id"])
self.assertNotIn("terminals", normalized)
self.assertEqual(1, len(normalized["devices"][0]["terminals"]))
self.assertEqual("terminal-a", normalized["devices"][0]["terminals"][0]["terminal_uuid"])
self.assertEqual(
"device-inst-1",
normalized["devices"][0]["terminals"][0]["device_instance_id"],
)
self.assertEqual(
"device-inst-1",
normalized["device_models"][0]["device_instance_id"],
)
def test_load_exchange_payload_preserves_wire_label_and_style_id(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "1.2",
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
@ -221,7 +228,7 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
with self.assertRaises(bootstrap.ExchangeValidationError):
bootstrap.load_exchange_payload(str(path))
def test_load_exchange_payload_detects_wire_properties_database_next_to_json(self):
def test_load_exchange_payload_rejects_non_v2_schema(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
@ -233,6 +240,125 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
"wires": [],
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "2d_to_3d.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with self.assertRaises(bootstrap.ExchangeValidationError):
bootstrap.load_exchange_payload(str(path))
def test_load_exchange_payload_rejects_legacy_device_instance_id_field(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [
{
"instance_id": "legacy-device-instance",
"display_tag": "QF1",
"terminals": [],
}
],
"device_models": [],
"wires": [],
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "2d_to_3d.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with self.assertRaises(bootstrap.ExchangeValidationError):
bootstrap.load_exchange_payload(str(path))
def test_load_exchange_payload_rejects_legacy_device_level_element_uuid(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-inst-1",
"element_uuid": "legacy-device-element",
"display_tag": "QF1",
"terminals": [],
}
],
"device_models": [],
"wires": [],
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "2d_to_3d.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with self.assertRaises(bootstrap.ExchangeValidationError):
bootstrap.load_exchange_payload(str(path))
def test_load_exchange_payload_rejects_legacy_device_model_element_uuid(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [],
"device_models": [
{
"element_uuid": "legacy-device",
"resolved_model_path": r"D:\models\legacy.step",
}
],
"wires": [],
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "2d_to_3d.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with self.assertRaises(bootstrap.ExchangeValidationError):
bootstrap.load_exchange_payload(str(path))
def test_load_exchange_payload_ignores_legacy_wire_endpoint_instance_ids(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
"wires": [
{
"wire_id": "wire-1",
"start_terminal_uuid": "terminal-a",
"end_terminal_uuid": "terminal-b",
"start_instance_id": "legacy-start",
"end_instance_id": "legacy-end",
}
],
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "2d_to_3d.json"
path.write_text(json.dumps(payload), encoding="utf-8")
normalized = bootstrap.load_exchange_payload(str(path))
self.assertEqual("wire-1", normalized["wires"][0]["wire_id"])
self.assertNotIn("start_instance_id", normalized["wires"][0])
self.assertNotIn("end_instance_id", normalized["wires"][0])
def test_load_exchange_payload_detects_wire_properties_database_next_to_json(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
"wires": [],
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "2d_to_3d.json"
db_path = Path(temp_dir) / "project-local.sqlite"
@ -261,7 +387,7 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "1.2",
"schema_version": "2.0",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
@ -303,7 +429,6 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
payload = {
"project_uuid": "project-1",
"devices": [],
"terminals": [],
"device_models": [],
"wires": [],
"wire_style_database_path": "D:/project/project-local.sqlite",

@ -444,14 +444,19 @@ class FcstdDeviceImportTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-1",
"instance_id": "instance-1",
"device_instance_id": "instance-1",
"display_tag": "QF1",
"terminals": [
{
"terminal_uuid": "terminal-1",
"element_uuid": "device-1",
}
],
}
],
"device_models": [
{
"element_uuid": "device-1",
"device_instance_id": "instance-1",
"resolved_model_path": str(model_path),
}
],
@ -779,7 +784,8 @@ class FcstdDeviceImportTest(unittest.TestCase):
}
)
self.assertEqual(1, report["imported_devices"])
self.assertEqual(0, report["imported_devices"])
self.assertEqual(1, report["pending_devices"])
root = doc.getObject(device_import.ROOT_GROUP_NAME)
self.assertIsNotNone(root)
devices = [
@ -790,6 +796,410 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertEqual(1, len(devices))
self.assertEqual("device-inst-1", devices[0].QetInstanceId)
self.assertEqual("element-a", devices[0].QetElementUuid)
self.assertEqual("Pending", devices[0].QetAssemblyState)
def test_import_devices_from_payload_registers_new_devices_as_pending_without_importing_model(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
doc.recompute = lambda: None
device_import._ensure_document = lambda scene_path: doc
import_calls = []
device_import._import_model_into_group = lambda *args, **kwargs: import_calls.append((args, kwargs))
report = device_import.import_devices_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-inst-1",
"display_tag": "N600",
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "element-a",
}
],
}
],
"device_models": [
{
"device_instance_id": "device-inst-1",
"resolved_model_path": str(model_path),
}
],
}
)
self.assertEqual([], import_calls)
self.assertEqual(0, report["imported_devices"])
self.assertEqual(1, report["pending_devices"])
root = doc.getObject(device_import.ROOT_GROUP_NAME)
devices = [
obj
for obj in root.Group
if getattr(obj, "Name", "").startswith(device_import.DEVICE_GROUP_PREFIX)
]
self.assertEqual(1, len(devices))
self.assertEqual("Pending", devices[0].QetAssemblyState)
self.assertEqual(str(model_path), devices[0].QetResolvedModelPath)
self.assertEqual([], device_import._existing_model_objects(doc, devices[0]))
def test_import_devices_from_payload_keeps_existing_pending_device_without_importing_model(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
doc.recompute = lambda: None
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root,
"element-a",
"device-inst-1",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
import_calls = []
device_import._import_model_into_group = lambda *args, **kwargs: import_calls.append((args, kwargs))
report = device_import.import_devices_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-inst-1",
"display_tag": "N600",
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "element-a",
}
],
}
],
"device_models": [
{
"device_instance_id": "device-inst-1",
"resolved_model_path": str(model_path),
}
],
}
)
self.assertEqual([], import_calls)
self.assertEqual(0, report["imported_devices"])
self.assertEqual(1, report["pending_devices"])
self.assertEqual("Pending", device_group.QetAssemblyState)
def test_insert_pending_device_imports_model_and_marks_device_placed(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root,
"element-a",
"device-inst-1",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
import_calls = []
def fake_import_model(doc_arg, group_arg, path_arg, **kwargs):
import_calls.append((doc_arg, group_arg, path_arg, kwargs))
body = doc_arg.addObject("Part::Feature", "DeviceBody")
group_arg.addObject(body)
return [body]
device_import._import_model_into_group = fake_import_model
result = device_import.insert_pending_device(doc, device_group)
self.assertEqual(1, len(import_calls))
self.assertEqual(str(model_path), import_calls[0][2])
self.assertEqual(device_group, result["device"])
self.assertEqual("Placed", device_group.QetAssemblyState)
self.assertEqual(["DeviceBody"], [obj.Name for obj in device_group.Group if obj.Name == "DeviceBody"])
def test_insert_pending_device_can_place_whole_device_on_mount_target(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root,
"element-a",
"device-inst-1",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
mount_target = doc.addObject("App::Part", "MountingPlate")
mount_target.Label = "安装板"
mount_target.Placement = app.Placement(app.Vector(100, 200, 300), app.Rotation())
device_import._ensure_string_property(
mount_target,
"QetCarrierKind",
"QET Mount",
"",
"mounting_plate",
)
def fake_import_model(doc_arg, group_arg, path_arg, **kwargs):
body = doc_arg.addObject("Part::Feature", "DeviceBody")
group_arg.addObject(body)
return [body]
device_import._import_model_into_group = fake_import_model
result = device_import.insert_pending_device(
doc,
device_group,
mount_target=mount_target,
)
self.assertEqual(device_group, result["device"])
self.assertEqual("Placed", device_group.QetAssemblyState)
self.assertEqual(100.0, device_group.Placement.Base.x)
self.assertEqual(200.0, device_group.Placement.Base.y)
self.assertEqual(300.0, device_group.Placement.Base.z)
self.assertEqual("manual_insert", device_group.QetMountMode)
self.assertEqual("MountingPlate", device_group.QetMountHostName)
self.assertEqual("安装板", device_group.QetMountHostLabel)
self.assertEqual("mounting_plate", device_group.QetMountHostKind)
def test_insert_pending_device_prefers_explicit_mount_placement_over_target_origin(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root,
"element-a",
"device-inst-1",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
mount_target = doc.addObject("App::Part", "CabinetFace")
mount_target.Label = "柜体安装面"
mount_target.Placement = app.Placement(app.Vector(100, 200, 300), app.Rotation())
explicit_placement = app.Placement(app.Vector(11, 22, 33), app.Rotation())
def fake_import_model(doc_arg, group_arg, path_arg, **kwargs):
body = doc_arg.addObject("Part::Feature", "DeviceBody")
group_arg.addObject(body)
return [body]
device_import._import_model_into_group = fake_import_model
device_import.insert_pending_device(
doc,
device_group,
mount_target=mount_target,
mount_placement=explicit_placement,
)
self.assertEqual(11.0, device_group.Placement.Base.x)
self.assertEqual(22.0, device_group.Placement.Base.y)
self.assertEqual(33.0, device_group.Placement.Base.z)
self.assertEqual("CabinetFace", device_group.QetMountHostName)
def test_insert_pending_device_applies_mount_normal_offset_and_records_face_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root,
"element-a",
"device-inst-1",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
mount_target = doc.addObject("App::Part", "CabinetFace")
mount_target.Label = "柜体安装面"
base_placement = app.Placement(app.Vector(10, 20, 30), app.Rotation())
def fake_import_model(doc_arg, group_arg, path_arg, **kwargs):
body = doc_arg.addObject("Part::Feature", "DeviceBody")
group_arg.addObject(body)
return [body]
device_import._import_model_into_group = fake_import_model
device_import.insert_pending_device(
doc,
device_group,
mount_target=mount_target,
mount_placement=base_placement,
mount_normal=app.Vector(0, 0, 1),
mount_offset_mm=5.0,
)
self.assertEqual(10.0, device_group.Placement.Base.x)
self.assertEqual(20.0, device_group.Placement.Base.y)
self.assertEqual(35.0, device_group.Placement.Base.z)
self.assertEqual("5.000000", device_group.QetMountOffsetMm)
self.assertIn('"z": 1.0', device_group.QetMountHostNormalJson)
def test_register_commands_adds_insert_pending_device_command(self):
_install_fake_freecad(None)
gui = sys.modules["FreeCADGui"]
registered = {}
gui.addCommand = lambda name, command: registered.setdefault(name, command)
device_import, _ = _reload_modules()
device_import.register_commands()
self.assertIn("QET_Exchange_InsertPendingDevice", registered)
def test_list_pending_devices_returns_device_groups_not_internal_model_children(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "jhd5.FCStd"
model_path.write_text("fake fcstd placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
pending_group, _ = device_import._ensure_device_group(
doc,
root,
"element-n600",
"inst-n600",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
pending_group,
device_import.ASSEMBLY_STATE_PENDING,
)
internal_model = doc.addObject("Part::Feature", "JHD5_6_grey001")
pending_group.addObject(internal_model)
placed_group, _ = device_import._ensure_device_group(
doc,
root,
"element-ta",
"inst-ta",
str(model_path),
"TAa",
1,
)
device_import._set_device_assembly_state(
placed_group,
device_import.ASSEMBLY_STATE_PLACED,
)
pending_devices = device_import.list_pending_devices(doc)
self.assertEqual(1, len(pending_devices))
self.assertEqual("inst-n600", pending_devices[0]["instance_id"])
self.assertEqual("N600", pending_devices[0]["display_tag"])
self.assertEqual(str(model_path), pending_devices[0]["resolved_model_path"])
self.assertIs(pending_group, pending_devices[0]["device"])
def test_pending_device_panel_registers_command_and_formats_pending_rows(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "jhd5.FCStd"
model_path.write_text("fake fcstd placeholder", encoding="utf-8")
_install_fake_freecad(None)
gui = sys.modules["FreeCADGui"]
registered = {}
gui.addCommand = lambda name, command: registered.setdefault(name, command)
device_import, _ = _reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
pending_group, _ = device_import._ensure_device_group(
doc,
root,
"element-n600",
"inst-n600",
str(model_path),
"N600",
0,
)
device_import._set_device_assembly_state(
pending_group,
device_import.ASSEMBLY_STATE_PENDING,
)
rows = pending_panel.pending_device_rows(doc)
pending_panel.register_commands()
self.assertEqual(1, len(rows))
self.assertEqual("N600", rows[0]["display_tag"])
self.assertIn("N600", rows[0]["display_text"])
self.assertIn("jhd5.FCStd", rows[0]["display_text"])
self.assertIn("QET_Exchange_OpenPendingDevicePanel", registered)
def test_import_devices_from_payload_reuses_fcstd_source_document_within_one_sync(self):
source = FakeDocument("TerminalSlice", r"D:\models\qet_terminal_slice.FCStd")
@ -841,7 +1251,8 @@ class FcstdDeviceImportTest(unittest.TestCase):
"resolved_model_path": source.FileName,
},
],
}
},
auto_insert_pending_devices=True,
)
finally:
device_import.os.path.isfile = original_isfile

@ -165,15 +165,13 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
}
],
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "device-a",
"instance_id": "instance-a",
"device_instance_id": "instance-a",
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "device-a",
}
],
}
],
}
@ -253,11 +251,10 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
"device_instance_id": "instance-a",
"terminals": [],
}
],
"terminals": [],
}
)
@ -326,6 +323,33 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual(1, len(terminals))
self.assertEqual("terminal-a", terminals[0].QetTerminalUuid)
def test_import_rejects_legacy_top_level_terminals(self):
_install_fake_freecad()
terminal_import, _terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
with self.assertRaises(terminal_import.TerminalImportError):
terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [],
}
],
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "device-a",
"device_instance_id": "instance-a",
}
],
}
)
def test_import_synthesizes_missing_terminal_entries_from_wire_endpoints(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
@ -367,19 +391,32 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
{
"project_uuid": "project-1",
"devices": [
{"element_uuid": "device-a", "instance_id": "instance-a"},
{"element_uuid": "device-b", "instance_id": "instance-b"},
{
"device_instance_id": "instance-a",
"terminals": [
{
"terminal_uuid": "known-a",
"element_uuid": "device-a",
}
],
},
{
"device_instance_id": "instance-b",
"terminals": [
{
"terminal_uuid": "known-b",
"element_uuid": "device-b",
}
],
},
],
"terminals": [],
"wires": [
{
"wire_id": "wire-1",
"start_element_uuid": "device-a",
"start_terminal_uuid": "terminal-a",
"start_instance_id": "",
"end_element_uuid": "device-b",
"end_terminal_uuid": "terminal-b",
"end_instance_id": "",
}
],
}
@ -402,12 +439,12 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
)
)
self.assertEqual(2, report["imported_terminals"])
self.assertEqual(4, report["imported_terminals"])
self.assertEqual(2, report["synthesized_wire_endpoint_terminals"])
self.assertEqual("terminal-a", start_terminals[0].QetTerminalUuid)
self.assertEqual("device-a", start_terminals[0].QetElementUuid)
self.assertEqual("terminal-b", end_terminals[0].QetTerminalUuid)
self.assertEqual("device-b", end_terminals[0].QetElementUuid)
start_by_uuid = {terminal.QetTerminalUuid: terminal for terminal in start_terminals}
end_by_uuid = {terminal.QetTerminalUuid: terminal for terminal in end_terminals}
self.assertEqual("device-a", start_by_uuid["terminal-a"].QetElementUuid)
self.assertEqual("device-b", end_by_uuid["terminal-b"].QetElementUuid)
def test_import_reads_qet_terminals_embedded_in_devices(self):
_install_fake_freecad()
@ -445,29 +482,24 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
"device_instance_id": "instance-a",
"terminals": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
"terminal_uuid": "device-a:terminal-p1",
"terminal_display": "P1",
}
],
}
],
"terminals": [],
"wires": [
{
"wire_id": "wire-1",
"start_element_uuid": "device-a",
"start_terminal_uuid": "device-a:terminal-p1",
"start_instance_id": "instance-a",
"start_terminal_display": "P1",
"end_element_uuid": "device-a",
"end_terminal_uuid": "device-a:terminal-p1",
"end_instance_id": "instance-a",
"end_terminal_display": "P1",
}
],
@ -481,7 +513,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(1, report["device_embedded_terminals"])
self.assertEqual(0, report["device_embedded_terminals"])
self.assertEqual(0, report["synthesized_wire_endpoint_terminals"])
self.assertEqual(1, len(terminals))
self.assertEqual("device-a:terminal-p1", terminals[0].QetTerminalUuid)
@ -531,16 +563,20 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
{
"project_uuid": "project-1",
"devices": [
{"element_uuid": "device-a", "instance_id": "instance-a"},
{"element_uuid": "device-b", "instance_id": "instance-b"},
],
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "device-a",
"instance_id": "instance-b",
"terminal_display": "12",
}
"device_instance_id": "instance-a",
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "device-a",
"terminal_display": "12",
}
],
},
{
"device_instance_id": "instance-b",
"terminals": [],
},
],
}
)
@ -630,26 +666,23 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
"device_instance_id": "instance-a",
"terminals": [
{
"terminal_uuid": "terminal-p2",
"element_uuid": "device-a",
"slot_name_hint": "P2",
"terminal_label": "P2",
},
{
"terminal_uuid": "terminal-p1",
"element_uuid": "device-a",
"slot_name_hint": "P1",
"terminal_label": "P1",
},
],
}
],
"terminals": [
{
"terminal_uuid": "terminal-p2",
"element_uuid": "device-a",
"instance_id": "instance-a",
"slot_name_hint": "P2",
"terminal_label": "P2",
},
{
"terminal_uuid": "terminal-p1",
"element_uuid": "device-a",
"instance_id": "instance-a",
"slot_name_hint": "P1",
"terminal_label": "P1",
},
],
}
)
@ -723,18 +756,16 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
"device_instance_id": "instance-a",
"terminals": [
{
"terminal_uuid": "terminal-p1",
"element_uuid": "device-a",
"terminal_display": "P1",
},
],
}
],
"terminals": [
{
"terminal_uuid": "terminal-p1",
"element_uuid": "device-a",
"instance_id": "instance-a",
"terminal_display": "P1",
},
],
}
)
@ -822,16 +853,14 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
}
],
"terminals": [
{
"terminal_uuid": "terminal-real-p1",
"element_uuid": "device-a",
"instance_id": "instance-a",
"slot_name_hint": "P1",
"device_instance_id": "instance-a",
"terminals": [
{
"terminal_uuid": "terminal-real-p1",
"element_uuid": "device-a",
"slot_name_hint": "P1",
}
],
}
],
}

@ -113,6 +113,28 @@ class WiringImportTest(unittest.TestCase):
terminal_objects.ensure_root_group(doc, "project-1")
payload = {
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"display_tag": "TAa",
"terminals": [
{
"element_uuid": "device-a",
"terminal_uuid": "device-a:terminal-a",
}
],
},
{
"device_instance_id": "instance-b",
"display_tag": "PEN001",
"terminals": [
{
"element_uuid": "device-b",
"terminal_uuid": "device-b:terminal-b",
}
],
},
],
"wires": [
{
"wire_id": "wire-1",
@ -123,11 +145,9 @@ class WiringImportTest(unittest.TestCase):
"net_uuid": "net-1",
"group_uuid": "group-1",
"start_element_uuid": "device-a",
"start_instance_id": "instance-a",
"start_terminal_uuid": "device-a:terminal-a",
"start_terminal_display": "A1",
"end_element_uuid": "device-b",
"end_instance_id": "instance-b",
"end_terminal_uuid": "device-b:terminal-b",
"end_terminal_display": "B1",
}
@ -153,8 +173,26 @@ class WiringImportTest(unittest.TestCase):
payload = {
"project_uuid": "project-1",
"devices": [
{"element_uuid": "device-a", "display_tag": "TAa"},
{"element_uuid": "device-b", "display_tag": "PEN001"},
{
"device_instance_id": "instance-a",
"display_tag": "TAa",
"terminals": [
{
"element_uuid": "device-a",
"terminal_uuid": "device-a:terminal-a",
}
],
},
{
"device_instance_id": "instance-b",
"display_tag": "PEN001",
"terminals": [
{
"element_uuid": "device-b",
"terminal_uuid": "device-b:terminal-b",
}
],
},
],
"wires": [
{
@ -209,6 +247,50 @@ class WiringImportTest(unittest.TestCase):
self.assertEqual("W001-updated", task_group.Group[0].QetWireMark)
self.assertEqual("Routed", task_group.Group[0].RouteStatus)
def test_import_wire_tasks_maps_labels_from_nested_device_terminals(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, wiring_import = _reload_modules()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
payload = {
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-qf1",
"display_tag": "QF1",
"terminals": [
{
"element_uuid": "symbol-qf1-a",
"terminal_uuid": "terminal-a",
"terminal_display": "1",
},
{
"element_uuid": "symbol-qf1-b",
"terminal_uuid": "terminal-b",
"terminal_display": "2",
},
],
}
],
"wires": [
{
"wire_id": "wire-1",
"start_element_uuid": "symbol-qf1-b",
"start_terminal_uuid": "terminal-b",
"start_terminal_display": "2",
"end_element_uuid": "symbol-qf1-a",
"end_terminal_uuid": "terminal-a",
"end_terminal_display": "1",
}
],
}
wiring_import.import_wire_tasks_from_payload(payload, doc)
task = doc.getObject("QETWiring_01_Tasks").Group[0]
self.assertEqual("QF1:2 -> QF1:1", task.QetEndpointLabel)
def test_reimport_keeps_stale_wire_tasks_for_sync_marking(self):
_install_fake_freecad()
terminal_objects, wiring_objects, wiring_import = _reload_modules()

@ -158,7 +158,7 @@ class FakeDocument:
def _reload_modules():
for name in ["TerminalObjects", "WiringObjects", "ManualWiring", "ExchangeWriteBack"]:
for name in ["TerminalObjects", "WiringObjects", "ManualWiring", "ExchangeWriteBack", "DeviceImport"]:
sys.modules.pop(name, None)
import TerminalObjects
import WiringObjects
@ -435,6 +435,81 @@ class WiringTest(unittest.TestCase):
else:
os.environ["QET_2D_TO_3D_JSON"] = old_json
def test_writeback_file_uses_v2_binding_field_names_only(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules()
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="instance-a",
)
terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_A")
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
"device-a",
"terminal-a",
"terminal-instance-a",
label="A",
)
with tempfile.TemporaryDirectory() as tmp_dir:
output_path = Path(tmp_dir) / "3d_to_2d.json"
report = write_back.write_back_document(
doc,
scene_path=str(Path(tmp_dir) / "scene.FCStd"),
payload={"project_uuid": "project-1"},
)
payload = json.loads(output_path.read_text(encoding="utf-8"))
self.assertEqual(str(output_path), report["output_path"])
self.assertEqual("2.0", payload["schema_version"])
self.assertEqual(
[{"element_uuid": "device-a", "device_instance_id": "instance-a"}],
payload["instances"],
)
self.assertEqual(
[
{
"terminal_uuid": "terminal-a",
"device_instance_id": "instance-a",
"terminal_instance_id": "terminal-instance-a",
}
],
payload["terminals"],
)
def keys_from(value):
if isinstance(value, dict):
for key, child in value.items():
yield key
yield from keys_from(child)
elif isinstance(value, list):
for child in value:
yield from keys_from(child)
self.assertNotIn("instance_id", set(keys_from(payload)))
def test_writeback_skips_local_terminal_bindings(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules()

Loading…
Cancel
Save