zhanghao 4 days ago
commit 52d9cf7596

@ -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`
第一版不回写:

@ -147,6 +147,10 @@ terminal_uuid
点击自动布线面板中的 `检查布线准备度`FreeCAD 还会在树目录 `QETWiring_05_Diagnostics` 下写入一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示本次诊断是否通过;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 保存中文摘要;`QetDiagnosticJson` 保存压缩后的最新预检结果,包括导线任务数量、工程端子数量、路径网络段数、布线源摘要、柜内边界摘要、导线样式库状态、问题码 `issue_codes`、缺失端点样例等。重复检查时旧的 `RoutingPreflight` 会被替换,只保留最新一次结果。
如果当前 FreeCAD 文档里多个工程端子对象带有同一个 `QetTerminalUuid`,预检会追加 `duplicate_3d_terminal_uuids / 3D端子UUID重复`。这表示单靠 `terminal_uuid` 已经不足以唯一定位 3D 端子,后续匹配必须依赖导线端点里的 `element_uuid`、当前 3D 设备 `instance_id`、端子显示名/脚号等上下文。当前第一阶段会按这些上下文消歧;如果导线任务缺少设备上下文,可能会进入缺端点或端子 UUID 不匹配诊断,而不是随便连到第一个同 UUID 端子。
预检还会对 `devices[].terminals[]``wires[]` 做一致性检查。如果 JSON 中某个设备端子没有被任何导线端点引用,会追加 `payload_terminals_without_wires / 输入端子未被导线引用`,中文报告显示“未被 wires[] 引用的端子”,并在 `QetDiagnosticJson.unreferenced_payload_terminal_samples[]` 中保留设备标签、端子显示名、端子 UUID 和端子实例 ID。这个诊断用于暴露类似 `ID:27/as` 这类“现场预期有线,但当前 wires[] 没有任务”的疑点;它不是硬错误,因为端子也可能本来就是未接线端子。若现场确认该端子确实应该接线,应把样例发给 QET 侧核对导线导出逻辑或原理图连接数据。
`RoutingPreflight` 还会附带 compact 路径网络诊断。若已标记 `CabinetInterior`,但主路径 carrier 或工程端子越出柜内边界,预检报告会直接追加 `route_carriers_outside_boundary``terminals_outside_boundary`,并在中文摘要中给出“越界路径”或“越界端子”样例。这样用户在生成导线前就能发现装配态问题。
预检的端点缺失示例会同时显示导线标签和端子对,例如 `导线 N4111terminal-start -> terminal-missing`。这用于第一时间判断问题来自哪条 QET 导线任务、哪个端子 UUID 没有绑定到 FreeCAD 工程端子。
@ -206,6 +210,8 @@ QetTemplateSlotName
4. 如果同一设备下 2D 端子数量和 3D 模板槽位数量一致,允许按顺序兜底匹配,但必须写诊断提示。
5. 仍无法匹配时,保留为 `local:*` 本地端子,不参与可靠自动布线。
这里的“端子显示名/脚号”就是 QET 设备属性窗口和“编辑接线处”里看到的端子号,对应 `2d_to_3d.json` 中的 `terminal_display`,以及导线端点里的 `start_terminal_display / end_terminal_display`。例如 QET 接线处端子号为 `1`、`2`,导出后应分别作为对应端子的 `terminal_display`FreeCAD 用它匹配 3D 模板中的 `QetTemplateSlotName=1/2`,也用它在 `terminal_uuid` 重复时辅助判断应该连接到哪一个 3D 端子。
#### 1.5.4 QET 需要配合提供的数据
第一版数据库仍只使用 `project_2d3d_symbol_binding``project_2d3d_terminal_binding`。交换 JSON 中当前应优先使用已有的端子显示字段作为匹配提示:
@ -221,8 +227,12 @@ QetTemplateSlotName
这些字段只作为 FreeCAD 匹配模板槽位的提示,不写入第一版绑定表,也不能替代 `terminal_uuid`
其中 `terminal_display` 的来源就是 QET 端子号;它是人可读的端子/接线处编号,也是 3D 模板槽位匹配和重复端子消歧的重要提示字段。
`slot_name_hint` 只是 FreeCAD 侧预留的可选扩展字段。当前 QET 如果没有该字段,不需要为了第一版专门增加;只要 `terminal_display` / `start_terminal_display` / `end_terminal_display` 能稳定表示设备脚号,就可以完成槽位匹配。
当前测试工程的 v2 JSON 已经暴露出一个长期边界:`terminal_uuid` 和输入侧 `terminal_instance_id` 可能重复。FreeCAD 第一阶段会短期兜底,用 `device_instance_id / element_uuid / terminal_uuid / terminal_display` 识别具体 3D 端子,并在回写时生成不重复的 `terminal_instance_id`。但是如果 QET 侧仍按 `project_2d3d_terminal_binding(project_uuid, terminal_uuid)` 作为唯一键落库,就无法完整保存同一个 `terminal_uuid` 下的多个 3D 端子绑定。第一阶段 FreeCAD 不要求立即修改 QET 代码;后续 QET 若要完整消费 `3d_to_2d.json.terminals[]`,需要提供真正唯一的 2D 端子实例标识,或明确以 `terminal_instance_id` 作为 3D 端子绑定写回键。
QET 侧还需要保证导线任务中继续提供:
```json
@ -332,7 +342,7 @@ FreeCAD 的 `3D 布线连接` 面板提供“主路径桥接容差 mm”数值
`TerminalAccess` 定位为端子局部接入线,只用于把端子出口引到柜内主路径附近。最终导线的主路径搜索不会把 `TerminalAccess` 当作公共 transit carrier也不会用它桥接两段线槽或 `UserPath` 的缺口;入口候选排序也会优先选择线槽、`UserPath`、过线孔等真实主路径,避免导线贴到其它端子的局部接入线上起步。这类缺口应通过线槽、`UserPath`、过线孔或主路径自动桥接来解决。
路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。
路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,并显示接入目标路径,例如远处线槽、`UserPath` 或 `RoutingRange` 布线面。`long_terminal_accesses[]` 会保留 `target_kind / target_name / target_label / target_rule / target_distance_mm`,用于判断问题是主路径入口离端子太远、局部出线路径缺失,还是当前只能退回布线面。处理建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。
面板还提供“并行线间距 mm”、“并行线最大偏移 mm”和“并行线方向”用于控制多根导线共用同一路径时的可视 lane 偏移。方向默认 `auto`,也可以手动指定 `x`、`y`、`z`。最大偏移用于限制密集共路时的显示错位范围,避免 lane 序号过大时把导线显示到线槽或柜体外。这些设置只影响 3D 显示上导线之间的错位方式,不代表真实线槽截面内的排布位置。
@ -528,7 +538,9 @@ terminal_access_max_distance = 1000.0
`terminal_exit_length` 决定端子出线段长度;`terminal_access_max_distance` 决定端子出线点到最近路由网络的最大允许接入距离,并同时约束最终导线路由入口候选,避免用户调小端子接入距离后,最终求路仍跨很远接入孤立网络。两个参数都只保存在当前 FreeCAD 面板/调用选项中,不写数据库。
网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。
网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;同时保留 `terminal_origin`、`terminal_exit_point`、`terminal_access_points`、`nearest_network_point`、`nearest_network_carrier_kind/name/label` 和接入折线的轴向长度。这样手动验收时可以直接判断问题来自端子 LCS/出线方向、设备摆放距离、主路径入口缺失,还是最近的线槽/UserPath/过线孔已经存在但没有接到端子局部路径。面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。
网络检查和批量布线摘要也会记录端子出线校正/截断样例。`corrected_terminal_exits[]` 与 `capped_terminal_exits[]` 会保留 `origin`、`exit_point`、`exit_direction`、`original_exit_direction`、`requested_exit_length_mm`、`actual_exit_length_mm`、`max_exit_length_mm`、`device_exit_required_length_mm`、`local_route_used` 和 `local_route_point_count`。这样可以直接判断问题是默认 LCS 朝向不合适、显式方向朝向设备内部、设备包围盒过大,还是已经使用局部出线路径但局部路径本身不合理。
### 4.4 悬空线策略
@ -639,11 +651,11 @@ QetWiringCutOutBridgeExtensionMm = 20.0
`route_samples[]` 不是简单截取前几条导线,而是优先保留带 `issue_codes` 的问题路线;问题数量相同或没有问题时,再按原生成顺序保留。这样当一次布线有很多正常线、少量异常线时,压缩诊断对象仍会优先给出异常样例,避免手动测试复制 JSON 后看不到真正需要处理的导线。
一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `CabinetInterior`,批量报告会额外显示“越界路径:<路径标签> N 个越界点”,便于直接定位错误的线槽中心线或 `UserPath`。如果工程端子越出边界,批量报告会显示“越界端子:<端子对象/UUID> N 个越界点”,便于直接定位未装配到柜内的设备端子。
一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。`auto_diagnostic_bridges` 摘要会保留未接入端子接入段的目标数、生成数、重复数和配对标签;中文报告会显示“未接入端子接入段 X 个,生成 Y 条”,便于判断自动补的是端子接入段到最近路径的短桥,而不是普通线槽孤岛桥接。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `CabinetInterior`,批量报告会额外显示“越界路径:<路径标签> N 个越界点”,便于直接定位错误的线槽中心线或 `UserPath`。如果工程端子越出边界,批量报告会显示“越界端子:<端子对象/UUID> N 个越界点”,便于直接定位未装配到柜内的设备端子。
真实工程中路径 carrier 数量可能达到数百个,入口候选组合会直接影响批量布线耗时。第一版保留单根布线的 `network_entry_candidate_limit`,同时在批量布线中增加 `batch_network_entry_candidate_limit`,默认按更保守的候选数求路,避免 `入口候选 x 出口候选 x 导线数量` 过度放大。批量入口候选还增加了总量保护 `batch_network_entry_total_candidate_limit`,当前默认值为 6它会限制单根导线最终参与组合评分的入口/出口候选总量,避免“距离候选 + 柜内候选 + 避障候选”叠加后把一次布线放大成几十次 Dijkstra 求路。缺路径重试仍可以按 `missing_route_retry_candidate_limit` 临时放宽候选数量,但正常批量路径优先受总上限保护。批量布线还会复用本次已构建的基础路径图,避免每根导线重复构建同一套网络;碰撞障碍物也会先收集成候选缓存,再按每根导线的端点设备和端点附近规则过滤,避免重复扫描数千个模型对象。当前批量默认采用性能优先的 `batch_avoid_obstacles=false`:不额外构建障碍过滤图,但仍会在生成后做碰撞诊断并输出 `collision_warnings`;需要更激进避障时再开启批量障碍过滤。相关参数会写入 `RoutingConnectionBatch.QetDiagnosticJson.batch_network_entry_candidate_limit`、`batch_network_entry_total_candidate_limit`、`batch_avoid_obstacles` 和 `batch_obstacle_candidates`,便于手测时确认当前性能保护是否生效。
线槽接入主网络采用保守桥接策略。当前 `adjoining_duct_tolerance` 默认只允许 5mm 内的相邻端点或端点到主路径中段投影自动桥接,不会为了让线槽被使用而把远距离线槽强行接到布线面或端子接入网络。这样可以避免误把柜内无关路径连成一个错误网络。若诊断出现 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`,第一版推荐用户显式添加 UserPath、线槽开口或桥接路径诊断会在 `bridge_suggestion` 中给出建议连接的两段 carrier、最近点和距离。面板已提供 `按诊断建议生成桥接`,用于先刷新诊断再按明确建议生成桥接;也提供 `选中两路径生成桥接`,用于在用户选中的两个路径 carrier 最近点之间生成一段 `UserPath`。这两个能力都属于半自动路径网络编辑,不会扫描全柜并自动连接所有远距离线槽。对于 UserPath 端点正好落在线槽中段的 0mm 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。
线槽接入主网络采用保守桥接策略。当前 `adjoining_duct_tolerance` 默认只允许 5mm 内的相邻端点或端点到主路径中段投影自动桥接,不会为了让线槽被使用而把远距离线槽强行接到布线面或端子接入网络。这样可以避免误把柜内无关路径连成一个错误网络。若诊断出现 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`,第一版推荐用户显式添加 UserPath、线槽开口或桥接路径诊断会在 `bridge_suggestion` 中给出建议连接的两段 carrier、最近点和距离。面板已提供 `按诊断建议生成桥接`,用于先刷新诊断再按明确建议生成桥接;也提供 `选中两路径生成桥接`,用于在用户选中的两个路径 carrier 最近点之间生成一段 `UserPath`。这两个能力都属于半自动路径网络编辑,不会扫描全柜并自动连接所有远距离线槽。对于 `unconnected_terminals[]` 中已经明确记录 `access_carrier``nearest_network_carrier_name/label` 的样例,诊断桥接会生成 `UnconnectedTerminalAccessBridge`,把该端子的 `TerminalAccess` 接入段补到最近路径;报告会单独输出 `unconnected_terminal_access_bridge_targets`、`unconnected_terminal_access_user_path_bridges`、`unconnected_terminal_access_bridge_duplicates` 和 `unconnected_terminal_access_bridge_pair_labels`,用于审计这类桥接和普通线槽桥接、端子退回补桥的区别。这个动作仍只依赖 FreeCAD 当前几何网络,不要求 QET 提供 3D 路径。对于 UserPath 端点正好落在线槽中段的 0mm 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。
孤立路径网络诊断只针对可行动的路径组件。线槽、UserPath、过线孔、辅助路径和端子接入如果分成多个组件会继续输出 `isolated_network_components`;但纯 `RoutingRange` 布线面孤岛只作为兜底网格保留在 `components` 明细中,不再单独触发“存在孤立路径网络”问题码。这样可以避免真实工程中安装板/布线面网格被误当作主路径断网问题,手测时优先处理线槽、用户路径和端子局部接入。
@ -834,7 +846,11 @@ tests/python/freecad_exchange_auto_routing_test.py
29. 自动布线支持通过 `adjoining_duct_tolerance` 调整相邻主路径端点桥接和端点到中段投影桥接容差,并在网络结果中记录桥接段数量。
30. `3D 布线连接` 面板提供“主路径桥接容差 mm”设置面板生成/检查/布线流程会使用该值;网络检查报告会显示自动桥接段数。
31. `3D 布线连接` 面板提供“端子接入最大距离 mm”和“端子出线长度 mm”设置用于适配真实机柜里端子离线槽远近不同、设备端子方向不同的情况。
32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离和端子出线长度,并在中文报告里显示最大接入距离。
32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离、端子出线长度、端子出口点、端子接入折线、最近网络点和最近网络对象 kind/name/label并在中文报告里显示最大接入距离。
32.1. 当 `TerminalAccess` 只能退回 `RoutingRange` 布线面时,路径网络诊断会记录退回目标、最近线槽/UserPath/过线孔主路径、最近主路径距离和当前最大接入距离;这些字段只来自 FreeCAD 当前几何网络,不要求 QET 提供。
32.2. 当 `TerminalAccess` 为了避开端点设备包围盒而绕行时,路径网络诊断会记录 `endpoint_device_avoided`、`endpoint_device_bbox`、`access_points[]` 和 `access_length_mm`,用于判断接入段是否仍可能穿过设备或是否需要修正设备模板局部路径。
32.3. `按诊断建议生成桥接` 处理 `terminal_access_fallback_targets[]` 时,会优先使用诊断样例中的 `nearest_main_path_name / nearest_main_path_label` 作为桥接目标;如果该对象在当前文档中找不到,才退回到最近主路径。这样能让“诊断看到的问题”和“自动补桥的对象”保持一致。
32.4. `unconnected_terminals[]` 如果同时记录 `access_carrier``nearest_network_carrier_name/label`,推荐动作会提示点击 `按诊断建议生成桥接`,并生成 `UnconnectedTerminalAccessBridge` 把该端子的接入段补到最近路径;控制器报告会保留目标数、生成数、重复数和 `接入段 -> 最近路径` 配对标签,方便手动验收时确认补的是哪个端子的局部接入缺口。
33. 批量布线报告会显示路径网络自动桥接段数和主动避障屏蔽段数,方便核对调参和避障是否实际参与求路。
34. 批量布线报告会显示最大 lane 编号、lane 间距和最大偏移,方便确认多根线共路时是否发生了可视错位,以及偏移上限是否参与显示。
35. `QetRouteTrackJson` 的 carrier payload 会记录 `capacity`,方便后续分析线槽容量偏好和共路绕行。
@ -881,7 +897,7 @@ tests/python/freecad_exchange_auto_routing_test.py
68. 面板提供“选择碰撞导线”,从最新批量诊断 `collision_samples[]` 和带 `collision_warnings``route_samples[]` 中反向选择 RoutedConnection 导线对象,便于和高发碰撞对象一起核对穿模位置。该功能只定位导线,不重新求路。
68. 面板提供“选择缺主路径导线”,从最新 `route_samples[]` 和导线对象自身的 `QetRouteIssueCodes` 中选择带 `main_path_detour_missing` 的 RoutedConnection 导线。该功能用于定位“选择性避障重算本可减少碰撞,但会退回到辅助路径/布线面兜底,因此被当前主路径优先策略拒绝”的导线;下一步应补 `UserPath`、桥接主路径、调整线槽入口或完善设备局部出线路径,不自动接受 fallback 结果。
68. 自动布线会对明确的 `main_path_detour_missing` 做一次收敛处理:当选择性避障已经得到碰撞更少的 fallback 折线,但该折线因包含 `RoutingRange` 被拒绝时,系统会把这条折线固化为 `MainPathDetourPath` 类型的 `UserPath`,再按 `兜底区域 -> 当前主路径` 生成 `MainPathDetourBridge`,随后只重试受影响导线。这样保持主路径优先,不直接接受宽泛布线面兜底,同时避免整批导线二次全量重跑。
69. 面板提供“选择长接入端子”,从最新批量诊断 `routing_path_network_diagnostic.long_terminal_accesses[]` 中反向选择端子对象,便于检查端子高度、设备装配和局部出线路径。该功能只定位端子,不修改端子或路径数据。
69. 面板提供“选择长接入端子”,从最新批量诊断 `routing_path_network_diagnostic.long_terminal_accesses[]` 中反向选择端子对象;当样例记录了 `access_carrier``target_name/target_label` 时,会同时选中端子的 `TerminalAccess` 接入段和目标路径,便于直接判断长接入是设备局部路径缺失、主路径入口过远,还是退回了布线面。该功能只定位对象,不修改端子或路径数据。
70. 面板提供“选择缺端子设备”,从最新批量诊断 `missing_endpoint_samples[]` 的缺失侧读取 `*_instance_id` / `*_element_uuid` 并反向选择 3D 设备,便于补工程端子或检查 2D/3D 绑定。若缺失设备不在当前场景中,控制器仍会返回 `missing_terminal_device_instance_ids[]`、`missing_terminal_device_element_uuids[]` 和可读标签,状态栏也会显示 instance_id便于把缺设备清单交给装配/绑定流程。该功能只定位设备,不自动创建端子、不修改 QET 数据。
71. 面板提供“选择缺端子另一端”,从缺端子样例中选择已找到的另一端工程端子,便于确认失败导线本来要连接到哪里,再对照缺失侧设备和端子脚号。该功能只定位端子,不自动补端子、不写数据库。
72. 面板提供“选择缺端子候选端子”,从 `*_instance_terminal_samples` / `*_element_terminal_samples` 中反向选择同设备或同实例已有工程端子,便于排查 `terminal_uuid_not_in_element` 这类“同设备已有端子但 UUID 不匹配”的问题。该功能只定位候选端子,不自动改绑定、不写数据库。
@ -1123,7 +1139,7 @@ QetTerminalLocalRoutePointsJson
导入/更新工程端子时FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。
路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。
路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。如果局部路径点数有效,但最后一个局部出口点仍落在端点所属设备包围盒内,诊断原因会记录为 `local_route_end_inside_device_bbox`,并保留 `local_route_end_point``endpoint_device_bbox`;实际生成 TerminalAccess 时会忽略这条无效局部路径,回退到设备感知默认出线,避免继续从设备内部接入主路径。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。
如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数:
@ -1300,6 +1316,14 @@ PE 线优先路径
13. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。
14. 如果 `wires[].wire_style_id` 能在 `wire_properties` 中解析,生成导线会使用对应的显示颜色、线宽和线型;解析失败时诊断显示 `Missing`,但仍按默认蓝色样式生成导线。
15. “生成布线连接”后的 `RoutingConnectionBatch` 诊断对象保存最终 report包括 `hidden_route_carriers`、`routing_path_network_updated`、路径网络检查结果和 `no_routed_connections` 等问题码。
16. “生成布线连接”后会默认隐藏 `WireDuct` / `RoutingRange` / `TerminalAccess` 等 route carrier 辅助对象,同时强制显示 `QETWiring_04_Routed``RoutedConnection` 导线对象;批量诊断会写入 `routed_wire_visibility``route_carrier_visibility`
17. 如果导线带有 `wire_style_id` 或已解析的 `QetWireStyleJson`,但没有实际写入 `ViewObject` 显示样式,诊断会写入 `wire_style_application.missing_application` 并追加 `wire_styles_not_applied` 问题码。
18. 黑色导线通过 `QetWireStyleApplied``QetAppliedWireLineColorRgb` 判断:`QetWireStyleApplied=true` 且 RGB 为 `0,0,0` 表示数据库样式本身是黑色;有样式 ID 但 `missing_application>0` 才表示样式未实际渲染。
19. 对当前 v2 JSON 中 `terminal_uuid``terminal_instance_id` 重复的短期风险FreeCAD 会在导入和布线预检时按 `device_instance_id / element_uuid / terminal_uuid / terminal_display / slot_name_hint` 消歧,并为重复或缺失的 `terminal_instance_id` 生成稳定 3D 端子实例 ID该 ID 不依赖 QET 导出顺序,`3d_to_2d.json` 回写时禁止退化成设备 `device_instance_id`
20. 正式 QET 工程中的端子排、断路器批量装配优先走 `待装配设备 -> 批量插入同组到选中目标`,沿导轨按显示编号顺序插入真实 QET 设备并同步工程端子;`3D手动布线` 面板中的批量端子排/断路器按钮只作为旧流程或无 QET 数据时的演示兜底。
21. 对已保存的旧工程或现场调试工程,`整理验收视图` 可以不重跑布线地隐藏 route carrier 辅助对象,并显示/重刷 `04_Routed` 导线样式;它用于 GUI 验收视图整理,不改变 QET 数据和布线路径结果。
22. 端子导入和重导入时FreeCAD 只把 `QETDevice_*` 设备组当作父设备;历史端子对象即使带有相同 `QetElementUuid`,也不能被误当成设备组。这样可以避免多 2D 元件共用同一 3D 设备实例时,把新端子挂到旧端子对象下面。
23. 写回 `3d_to_2d.json` 前,如果能从传入 payload、`QET_2D_TO_3D_JSON` 或场景同目录读取当前 `2d_to_3d.json`FreeCAD 会先按该快照同步工程端子,再收集 `instances[] / terminals[]`。保存 FCStd 时会在保存开始阶段先执行同一同步,使端子修复也进入 FreeCAD 工程文件。这样旧工程保存时也能回写完整端子绑定,例如当前测试工程应输出 `instances=86 / terminals=142`,且 `terminal_instance_id` 不重复。
## 10. 开发验证命令

@ -468,14 +468,16 @@ QET模板 -> 导入模板实例
1. 从 QET 点击 `3D视图` 打开 FreeCAD确认树目录中已经有断路器设备。
2. 选中要安装断路器的导轨。
3. 切换到 `QET模板`
4. 打开 `3D手动布线`。
5. 点击 `批量断路器`。
6. `QET断路器前缀` 中输入实际设备前缀,例如 `QF`。
7. 输入断路器间距和起始偏移
8. 确认后,系统会把 QET 已导入的真实断路器沿导轨排布
9. 如果状态提示 `已排布 QET 断路器`,说明没有生成假设备,原有 QET 绑定仍保留
4. 打开 `待装配设备`。
5. 在清单中选中同组断路器中的任意一个,例如 `QF1``QF:1`。
6. 设置 `批量间距``起点偏移`。
7. 点击 `批量插入同组到选中目标`
8. 系统会按显示编号顺序,把同一前缀的 QET 真实设备沿导轨排布,并在插入后同步工程端子
9. 如果只想插入单个设备,仍使用 `插入到选中目标`
只有当前工程没有 QET 断路器数据、只是做 3D 演示时,才使用兜底数量和兜底端子号生成本地演示对象。
批量插入完成后,状态栏会显示本次匹配到的同组前缀、插入数量和前几个设备标签,例如 `已批量插入同组 QF 待装配设备 8 个QF1、QF2...`。如果只插入了 1 个,通常表示当前待装配清单中只有这个前缀的一个设备,或选中的设备标签没有和其它同组设备形成同一前缀,应先刷新清单并检查 QET 设备显示名。
`3D手动布线` 面板里的 `批量断路器` 属于旧流程/演示兜底:它会优先排布已有 QET 设备,但找不到匹配设备时会生成本地演示对象。正式 QET 工程优先使用 `待装配设备`,避免生成没有真实 2D 端子绑定的假设备。
常见间距:
@ -495,12 +497,14 @@ QET模板 -> 导入模板实例
2. 确认树目录中已经有 `UD`、`ID` 等端子排相关设备。
3. 选中要安装端子排的导轨。
4. 切换到 `QET模板`
5. 打开 `3D手动布线`
6. 点击 `批量端子排`
7. 在 `QET端子排名称/前缀` 中输入 `UD``ID`
8. 输入端子片间距,例如 `5.2 mm`,以及起始偏移。
9. 确认后,系统会把匹配的 QET 真实端子片沿导轨按顺序排布。
10. 如果状态提示 `已排布 QET 端子排`,说明工程端子和 `terminal_uuid` 没有被替换成本地端子。
5. 打开 `待装配设备`
6. 在清单中选中同组端子排中的任意一个,例如 `UD:1`、`UD:8` 或 `ID:6`
7. 设置 `批量间距`,端子片常用 `5.2 mm`,再设置 `起点偏移`
8. 点击 `批量插入同组到选中目标`
9. 系统会按显示编号顺序,把同一前缀的 QET 真实端子片沿导轨排布,并在插入后同步工程端子。
10. 如果只想插入单个端子片,仍使用 `插入到选中目标`
状态栏会显示端子排前缀和前几个端子片标签,便于确认是否插入了正确的 `UD`、`ID` 等同组真实 QET 设备。这个批量入口不会生成本地假端子片;它只排布仍处于待装配状态的 QET 设备,并在插入后同步工程端子。
例如本仓库生成的端子片:
@ -522,6 +526,8 @@ data/examples/qet_terminal_block/qet_terminal_slice.FCStd
4. X 方向间距设为 `5.2 mm`
5. 这种方式生成的端子通常是本地演示端子,不作为正式 QET 布线匹配主流程。
`3D手动布线` 面板里的 `批量端子排` 仍保留,主要用于旧工程和无 QET 数据的演示兜底。第一阶段正式验收时,端子排批量装配应优先走 `待装配设备 -> 批量插入同组到选中目标`,这样 2D 端子、3D 设备和工程端子仍然来自同一份 QET v2 交换数据。
### 9.4 摆放电流互感器
电流互感器一般装在安装板或导轨附近。
@ -747,7 +753,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
11. 选择线槽,点击 `标记为线槽`
12. 用 `QET模板 -> 导入模板实例` 导入断路器、互感器、端子排等 FCStd 模板。
13. 用 `Assembly``Placement` 把设备摆到导轨上。
14. 点击 `QET模板 -> 生成工程端子`
14. 正常 QET 流程下FreeCAD 会在导入 `2d_to_3d.json` 时自动生成/更新工程端子;只有预检提示缺工程端子,或设备是手工导入模板时,才点击 `QET模板 -> 生成工程端子` 兜底
15. 打开 `3D手动布线`
16. 选择导线任务,或手动选起点端子。
17. 沿线槽添加折点。
@ -763,7 +769,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
2. 导轨已经贴到安装板或背板上。
3. 线槽已经放到柜内,或已经用草图/Draft 线定义用户主路径。
4. QET 导入的真实设备实例已经摆到导轨或安装板上。
5. 已点击 `生成工程端子`工程端子能在 `QETTerminals_*` 分组中看到。
5. 工程端子能在 `QETTerminals_*` 分组中看到;正常 QET 导入会自动生成,`生成工程端子` 只作为异常兜底按钮
6. 如需限制导线不能跑出柜外,选择柜内空间、柜体或辅助包围盒,点击 `选中对象作为柜内边界`
完成后按下面顺序检查:
@ -789,6 +795,12 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
每次点击 `检查布线准备度`,树目录 `QETWiring_05_Diagnostics` 下会刷新一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示预检是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 是中文摘要;展开属性中的 `QetDiagnosticJson`,可以查看缺失端点、路径源数量、柜内边界数量、路径网络诊断和导线样式库状态。这个对象只保存最新一次预检结果,避免多次测试后诊断对象堆积。
如果报告出现 `duplicate_3d_terminal_uuids / 3D端子UUID重复`,说明当前 FreeCAD 文档里多个工程端子对象带有同一个 `QetTerminalUuid`。这不是立刻禁止布线的硬错误,但说明不能只靠端子 UUID 定位端子FreeCAD 会继续使用导线端点的 `element_uuid`、3D 设备 `instance_id` 和端子显示名/脚号来消歧。如果后续同时出现缺端点、端子 UUID 不匹配或只接到错误设备,应优先检查 QET 导线端点是否带有 `element_uuid`,以及当前 3D 设备是否已经正确装配和绑定。
这里的端子显示名/脚号就是 QET 界面“编辑接线处”里的端子号,对应 `2d_to_3d.json``terminal_display`,以及 `wires[]` 端点中的 `start_terminal_display / end_terminal_display`。手测缺端子或接错端子时,可以直接用诊断里的 `*_terminal_display` 回到 QET 界面核对端子号。
如果报告出现 `payload_terminals_without_wires / 输入端子未被导线引用`,说明 `2d_to_3d.json``devices[].terminals[]` 中存在某个端子,但当前 `wires[]` 没有任何导线端点引用它。面板中文报告会显示“未被 wires[] 引用的端子”,诊断 JSON 的 `unreferenced_payload_terminal_samples[]` 会给出设备标签和端子显示名,例如 `ID:27/as`。这不一定是错误,因为端子可能本来未接线;如果现场确认该端子应该有线,应把这个样例交给 QET 侧检查原理图连接或 `wires[]` 导出。
`检查布线准备度` 默认不再抽样求解导线可达性,避免真实机柜中大量设备、路径 carrier 和障碍对象导致预检长时间卡住。需要排查少量导线是否能连通时,再把面板里的 `可达性抽样` 数量从 `0` 调到 1、5 或更高;这个抽样只用于诊断,不影响正式点击 `生成布线连接` 时的全量布线。
预检阶段也会读取路径网络诊断摘要。如果已经标记 `CabinetInterior`,但工程端子或主路径 carrier 越出柜内边界,`检查布线准备度` 会显示“路径网络检查提示”,并带出“越界端子”或“越界路径”样例。这样可以在生成导线前发现装配位置、端子 LCS 或用户路径本身的问题。
@ -805,6 +817,8 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
面板中的 `端子接入警告距离 mm` 用于判断“端子接入过长”。设为 `0` 时按默认规则自动计算;如果当前机柜尺度较大,且 600-700mm 的端子接入属于可接受的设备局部出线,可以把该值调到 700mm 左右再检查。这个参数只影响质量告警,不会放宽 `端子接入最大距离 mm`,也不会让超过最大距离的端子强行接入。
如果路径网络诊断包含 `unconnected_terminals`,点击 `选择未接入端子`。系统会从最新 `RoutingPathNetwork` 诊断中选择未接入路由网络、或端子出口到最近网络距离超过 `端子接入最大距离 mm` 的端子及所属设备;如果诊断样例里有 `access_carrier`,会同时选中该端子的 `TerminalAccess` 接入段;如果诊断样例里有 `nearest_network_carrier_name/label`,也会同时选中最近的线槽、过线孔、黄色 `UserPath` 或其它路径 carrier。状态栏会显示本次样例里的最大最近网络距离、接入段数量和最近路径数量。`QetDiagnosticJson.unconnected_terminals[]` 会保存 `terminal_origin`、`terminal_exit_point`、`terminal_access_points`、`nearest_network_point`、`nearest_network_distance_mm`、`nearest_network_carrier_kind/name/label` 和接入折线的主轴长度,便于判断是端子 LCS/出线方向问题、设备摆放距离过远,还是最近线槽、过线孔、黄色 `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 +907,52 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
如果某条线已经生成但端子附近拉出很长一段斜线或折线,选中该导线对象查看 `QetRouteEntryDistanceMm`、`QetRouteExitDistanceMm`、`QetRouteAccessWarningDistanceMm` 和 `QetRouteAccessStatus`。其中 `LongAccessWarning` 表示起点或终点到主路径网络的接入距离超过当前告警阈值;`QetRouteAccessWarningSides` 会显示触发侧,`entry` 是起点侧,`exit` 是终点侧。出现该提示时,优先检查设备是否已经装配到正确位置、端子局部出线路径是否存在、用户路径或线槽是否离设备端子太远。
`检查布线路径网络` 和批量布线的 `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 方向,或为该设备补局部出线路径。
端子默认出线方向来自工程端子 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、父设备、原始方向、实际方向、端子原点 `origin`、实际出线点 `exit_point`、请求长度、实际长度、上限、是否使用局部路径 `local_route_used` 和局部路径点数,批量布线摘要也会保留这些字段。手动验收时可以先定位设备端子,再决定是修模板 CPoint、设置工程端子局部出线还是补主路径入口。
如果 `QetTerminalExitDirectionJson` 格式错误、方向向量无法解析或方向长度为 0路径网络诊断会额外输出 `invalid_terminal_exit_directions[]`。这种情况不会让 FreeCAD 依赖 QET 计算 3D 路径,而是明确提示当前 FreeCAD 文档或设备模板中的 CPoint 方向元数据需要修正;可以用 `选中端子设置出线方向` 重写当前工程端子的显式方向,或回到设备模板中修正后重新导入。
如果 `QetTerminalLocalRoutePointsJson` 能解析出点,但局部路径终点仍在端点所属设备包围盒内,路径网络诊断会把该端子记入 `invalid_terminal_local_routes[]`,原因是 `local_route_end_inside_device_bbox`,并保留 `local_route_end_point``endpoint_device_bbox`。实际生成 `TerminalAccess` 时会忽略这条无效局部路径,回退到设备感知默认出线,避免继续从设备内部拉线。手动验收时应重新画一条真正离开设备外轮廓的局部路径,或修正设备模板端子 CPoint/RPoint。
如果要直接定位这些端子,点击 `选择出线问题端子`。系统会从最新 `RoutingPathNetwork` 诊断中合并选择 `corrected_terminal_exits[]`、`capped_terminal_exits[]`、`invalid_terminal_exit_directions[]` 和 `invalid_terminal_local_routes[]` 对应的端子及父设备;这个操作只负责定位,不会自动改端子方向或重新布线。选中后先看端子 LCS 朝向、显式 `QetTerminalExitDirectionJson`、局部路径 `QetTerminalLocalRoutePointsJson`、设备包围盒是否过大,再决定是否设置显式出线方向、设置局部出线路径或回到设备模板修正 CPoint。
如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。
每个自动生成的 `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`、过线孔还是设备局部出线路径。退回样例会记录 `target_kind / target_label / target_distance_mm`,并在存在主路径时记录 `nearest_main_path_kind / nearest_main_path_label / nearest_main_path_distance_mm / terminal_access_max_distance_mm`;如果最近主路径距离大于最大接入距离,通常说明需要在端子和线槽/UserPath 之间补桥接路径,或在确认工程允许时调大 `端子接入最大距离 mm`
如果这些退回目标只是缺一小段到主路径的入口,可以直接点击 `按诊断建议生成桥接`。该按钮现在既能读取批量布线诊断里的 `terminal_access_fallback_target_samples[]`,也能读取刚执行 `检查布线路径网络` 后生成的 `RoutingPathNetwork.terminal_access_fallback_targets[]`,自动在退回布线面和最近线槽、`UserPath`、过线孔等主路径之间生成 `TerminalAccessFallbackBridge`。如果诊断样例里已经记录了 `nearest_main_path_name / nearest_main_path_label`,桥接会优先连接这条诊断推荐主路径;找不到推荐对象时才退回到当前文档里最近的主路径。对于 `unconnected_terminals[]` 中同时存在 `access_carrier``nearest_network_carrier_name/label` 的样例,该按钮也会在端子 `TerminalAccess` 接入段和最近路径之间生成 `UnconnectedTerminalAccessBridge`,用于补齐端子接入段到线槽/UserPath 的短缺口;这类样例存在时,汇总诊断的推荐动作会明确提示点击 `按诊断建议生成桥接`。按钮报告会单独统计 `unconnected_terminal_access_bridge_targets`、`unconnected_terminal_access_user_path_bridges`、`unconnected_terminal_access_bridge_duplicates` 和 `unconnected_terminal_access_bridge_pair_labels`,状态栏会显示“未接入端子接入段 X 个,生成 Y 条”,便于区分这是未接端子补桥,不是线槽孤岛补桥或端子退回布线面补桥。生成后重新执行 `生成布线路径网络``生成布线连接`,端子接入会优先走补出的桥接路径;如果仍然退回布线面或未接入,说明需要补更明确的主路径入口或设备局部出线路径。
如果要直接定位端点设备避让问题,点击 `选择端点避让接入`。系统会读取最新 `RoutingPathNetwork` 诊断中的 `terminal_access_endpoint_device_avoidance[]`,选中对应端子、父设备、目标主路径和 `TerminalAccess` 接入段;这个按钮主要服务手动验收和开发侧复查,只定位对象,不重新布线、不写 QET 数据库。诊断样例会记录 `endpoint_device_avoided`、`endpoint_device_bbox`、`access_points[]` 和 `access_length_mm`;如果后续仍看到导线穿设备,可以对照这些点和包围盒判断是端子局部路径方向不合理、父设备包围盒过大,还是主路径入口仍在设备背后。
`检查布线路径网络` 和批量布线的 `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`,以及接入目标 `target_kind / target_label / target_distance_mm`。如果目标是远处线槽或远处 `UserPath`,优先补设备局部出线路径或把主路径入口靠近端子;如果目标是 `RoutingRange` 布线面,优先补线槽入口、过线孔或黄色 `UserPath`。如果 `terminal_access_dominant_axis``z`,且 `z` 方向长度占大头,通常表示端子点和柜内主路径平面高度差过大;优先检查该设备装配高度、端子 LCS 方向,或为该设备补局部出线路径。
如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中;如果样例里记录了 `access_carrier``target_name/target_label`,会同时选中该端子的 `TerminalAccess` 接入段和目标线槽/`UserPath`/布线面。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。
如果要从设备角度排查,点击 `选择长接入设备`。系统会读取长接入样例里的 `parent_device_name / parent_device_label` 并选中对应设备。通常先用 `选择长接入端子` 看具体端子点,再用 `选择长接入设备` 检查该设备整体是否装配到正确高度、端子 LCS 是否随设备移动,以及设备附近是否需要补局部出线路径。
这两个长接入定位按钮既能读取批量布线诊断内嵌的 `routing_path_network_diagnostic.long_terminal_accesses[]`,也能直接读取独立 `RoutingPathNetwork` 诊断里的 `long_terminal_accesses[]`。因此只执行 `检查布线路径网络`、还没有生成导线时,也可以先定位长接入端子和设备,适合在正式布线前先修装配高度、端子方向和局部出线路径。
如果确认是某个工程端子缺少设备局部出线路径,可以直接在当前装配工程里补:
1. 选中一个可布线工程端子。
@ -945,6 +999,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` 这样的零件属于前门、柜体、安装板还是具体设备;确认是装配辅助件或可穿过结构后,再手动标记为忽略碰撞对象。
@ -1055,7 +1111,7 @@ FreeCAD 版本/运行目录:
3. 校验端子。
4. 保存为 FCStd。
5. 回到工程场景重新导入或更新设备。
6. 点击 `生成工程端子`
6. 如果设备是通过 `待装配设备` 面板插入,当前版本会在插入后自动同步工程端子;如果设备是旧流程手工导入、模板刚修改过,或诊断提示缺工程端子,再点击 `生成工程端子` 做一次补生成
### 15.2 为什么不能布线?
@ -1118,7 +1174,29 @@ scene.FCStd
QET 侧只依赖最小绑定字段找到对应设备和端子。
### 15.5 当前截图里的 `Gears` 应该怎么处理?
### 15.5 为什么黑色线和辅助网格要看诊断字段?
生成布线连接后,系统会把导线显示状态和辅助路径显示状态写入 `RoutingConnectionBatch` 诊断:
- `routed_wire_visibility`:统计 `QETWiring_04_Routed` 下导线是否可见。GUI 中如果 `hidden>0`,说明生成后仍有导线不可见。
- `route_carrier_visibility`:统计 `WireDuct`、`RoutingRange`、`TerminalAccess` 等辅助 carrier 是否仍可见。默认应隐藏,避免手动验收时把网格误认为导线。
- `wire_style_application`:统计有样式数据的导线是否已经实际应用到 `ViewObject`
- `available_terminals` / `available_terminal_objects`:统计当前 FreeCAD 工程里的实际 3D 工程端子对象数量。`unique_terminal_uuids` 只表示去重后的 `terminal_uuid` 数量;当前 QET v2 数据里同一个 `terminal_uuid` 可能对应多个设备/脚号,所以手测时应优先看 `available_terminals` 是否接近 `2d_to_3d.json` 中的端子总数。
- `duplicate_payload_terminal_instance_id_count`:统计 `2d_to_3d.json` 输入里重复的 `terminal_instance_id` 组数。第一阶段 FreeCAD 会按设备实例、端子 UUID、端子显示名生成稳定兜底 ID保证 `3d_to_2d.json` 不把端子 ID 退化成设备 ID但这仍表示 QET 输入数据有重复风险,后续建议 QET 侧提供真正唯一的端子实例 ID。
保存或手动执行写回时,如果 FreeCAD 能找到当前 `2d_to_3d.json`,会先按 JSON 快照同步工程端子,再生成 `3d_to_2d.json`。保存流程会在写入 FCStd 前执行这一步,因此保存下来的 FreeCAD 工程也会带上端子修复结果,而不是只修正回写 JSON。因此正常 QET 流程下不需要为了回写再单独点一次 `生成工程端子`;只有旧流程手工导入模型、模板刚改过、或诊断明确提示缺工程端子时,才把 `生成工程端子` 当作兜底修复按钮。
端子对象本身也会带 `QetElementUuid`,但它不能作为设备父级。当前版本查找父设备时只接受 `QETDevice_*` 设备组,避免旧工程里同 UUID 端子对象被误当作设备,导致新端子挂到旧端子下面。手测时如果发现 `QETTerminal_*` 下面又出现 `QETTerminals_*` 分组,说明工程可能来自旧版本,需要重新导入或保存触发端子同步后再检查。
黑色导线不一定是错误。若导线对象上 `QetWireStyleApplied=true`,并且 `QetAppliedWireLineColorRgb=0,0,0`,表示 `wire_properties` 中的线色本来就是黑色;若 `wire_style_application.missing_application>0`,才表示样式没有真正渲染。
批量布线中文报告也会显示 `黑色导线N 条来自 wire_properties 样式`。看到这条提示时,表示这些黑线已经成功从导线样式库解析并应用,不是默认未渲染;只有同时出现 `导线样式实际应用异常``wire_styles_not_applied` 时,才按样式渲染失败处理。
在 FreeCADCmd 这类无 GUI 验证环境中,可见性可能显示为 `unknown_visibility`,这是因为 headless 模式读不到 `ViewObject.Visibility`,不代表 GUI 里一定隐藏。
如果打开旧工程后仍能看到很多布线路径网格、`RoutingRange` 面网格或 `TerminalAccess` 辅助线,但导线已经存在于 `04_Routed` 下,可以在 `3D布线连接` 面板点击 `整理验收视图`。该按钮不会重新布线、不会删除对象、不会写数据库;它只隐藏 route carrier 辅助对象,并显示/重刷 `04_Routed` 导线和导线样式,适合手动验收前整理最终视图。
### 15.6 当前截图里的 `Gears` 应该怎么处理?
这是 Assembly 的齿轮约束任务,不适合机柜装配。
@ -1139,7 +1217,7 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。
4. 导轨、线槽、机柜可作为纯几何资产。
5. 正式 QET 工程中,端子排和断路器优先排布 QET 已导入的真实实例Draft 阵列只作为无 QET 数据时的手工演示方式。
6. 每完成一段装配就保存一次 `scene.FCStd`
7. 布线前先生成工程端子
7. 布线前确认工程端子已经生成:通过 `待装配设备` 插入的设备会自动同步工程端子;旧流程手工导入或诊断提示缺端子时,再点击 `生成工程端子`
8. 生成布线连接前先建立布线路径网络。
9. 不要手动改工程绑定 UUID。
10. 不要依赖旧 3D 场景表保存位姿。

@ -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

@ -2,6 +2,7 @@ import json
import os
from pathlib import Path
import uuid
import json
from datetime import datetime
import FreeCAD as App
@ -29,6 +30,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):
@ -468,10 +471,17 @@ def _find_device_group(doc, element_uuid):
preferred_name = DEVICE_GROUP_PREFIX + _safe_token(target_uuid)
obj = doc.getObject(preferred_name)
if obj is not None:
if obj is not None and getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return obj
for candidate in doc.Objects:
if not getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
continue
try:
if not candidate.isDerivedFrom("App::DocumentObjectGroup"):
continue
except Exception:
continue
if "QetElementUuid" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetElementUuid", "").strip() == target_uuid:
return candidate
@ -526,18 +536,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
@ -736,6 +740,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)
@ -808,6 +822,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
@ -941,6 +1231,11 @@ def _is_exchange_sidecar_group(obj):
child_name = _object_name(obj)
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
return True
try:
if TerminalObjects.is_terminal_hint_object(obj):
return True
except Exception:
pass
return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}
@ -1291,15 +1586,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
@ -1396,7 +1685,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")
@ -1428,6 +1717,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": [],
}
@ -1483,8 +1774,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(
@ -1619,7 +1908,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:
@ -1703,6 +2010,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(
@ -1793,3 +2101,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))

@ -571,7 +571,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
@ -595,7 +595,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,
}
@ -608,8 +608,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
@ -622,23 +622,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:
@ -727,10 +710,8 @@ def _normalize_wires(payload):
"wire_style_id": _optional_text(item, "wire_style_id"),
"wire_style": _normalize_wire_style(item, entry_label),
"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),
@ -764,7 +745,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):
@ -791,8 +771,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": (
@ -923,16 +902,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(),
@ -941,7 +921,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),
}
@ -953,13 +932,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"]
@ -1244,8 +1223,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", "")
)
)

@ -5,12 +5,18 @@ import os
from datetime import datetime
from pathlib import Path
import traceback
import uuid
import FreeCAD as App
import DeviceImport
import TerminalObjects as TerminalObjects
try:
import TerminalImport
except ImportError:
TerminalImport = None
try:
import FreeCADGui as Gui
except ImportError:
@ -140,6 +146,68 @@ def _output_path_for_exchange_json():
return str(Path(json_path).with_name("3d_to_2d.json"))
def _input_path_for_scene(scene_path):
scene_path = (scene_path or "").strip()
if not scene_path:
return ""
path = Path(scene_path)
if path.suffix.lower() == ".fcstd":
return str(path.with_name("2d_to_3d.json"))
if path.is_dir():
return str(path / "2d_to_3d.json")
return str(path.parent / "2d_to_3d.json")
def _load_json_payload(path):
path_text = (path or "").strip()
if not path_text:
return None
try:
candidate = Path(path_text)
if not candidate.is_file():
return None
return json.loads(candidate.read_text(encoding="utf-8"))
except Exception as exc:
_append_debug_log("write-back could not load payload {0}: {1}".format(path_text, exc))
return None
def _payload_for_writeback(scene_path, payload=None):
if isinstance(payload, dict):
return payload
env_path = os.environ.get(ENV_JSON_PATH, "").strip()
loaded = _load_json_payload(env_path)
if isinstance(loaded, dict):
return loaded
loaded = _load_json_payload(_input_path_for_scene(scene_path))
if isinstance(loaded, dict):
return loaded
return payload
def _sync_terminals_for_writeback(doc, scene_path, payload):
if TerminalImport is None or not isinstance(payload, dict):
return None
if not isinstance(payload.get("devices"), list) or not payload.get("devices"):
return None
try:
# 保存/写回以当前 2d_to_3d.json 为端子快照,先同步 3D 工程端子,避免旧工程继续回写缺失或重复端子。
return TerminalImport.import_terminals_from_payload(payload, scene_path)
except Exception as exc:
_append_debug_log("write-back terminal sync failed: {0}".format(exc))
_append_debug_log(traceback.format_exc())
return None
def sync_terminals_from_current_payload(doc, scene_path="", payload=None):
scene_path = _scene_path_from_doc(doc, scene_path)
payload = _payload_for_writeback(scene_path, payload)
return _sync_terminals_for_writeback(doc, scene_path, payload)
def _format_timestamp():
return datetime.now().astimezone().isoformat(timespec="seconds")
@ -173,9 +241,59 @@ def _collect_instance_bindings(doc):
return bindings
def _stable_terminal_instance_id(project_uuid, device_instance_id, terminal_obj):
values = [
project_uuid,
device_instance_id,
getattr(terminal_obj, "QetElementUuid", "").strip(),
getattr(terminal_obj, "QetTerminalUuid", "").strip(),
getattr(terminal_obj, "QetTemplateSlotName", "").strip(),
getattr(terminal_obj, "Label", "").strip(),
getattr(terminal_obj, "Name", "").strip(),
]
seed = "qet-freecad-writeback-terminal|" + "|".join(values)
return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
def _writeback_terminal_instance_id(project_uuid, terminal_obj, device_instance_id, used_ids):
terminal_instance_id = (
getattr(terminal_obj, "QetTerminalInstanceId", "").strip()
or getattr(terminal_obj, "QetInstanceId", "").strip()
or ""
)
if (
not terminal_instance_id
or terminal_instance_id == device_instance_id
or terminal_instance_id in used_ids
):
terminal_instance_id = _stable_terminal_instance_id(
project_uuid,
device_instance_id,
terminal_obj,
)
suffix = 1
while terminal_instance_id in used_ids:
terminal_instance_id = str(uuid.uuid5(
uuid.NAMESPACE_URL,
"{0}|{1}".format(terminal_instance_id, suffix),
))
suffix += 1
TerminalObjects.ensure_string_property(
terminal_obj,
"QetTerminalInstanceId",
"QET Exchange",
"Stable 3D terminal instance UUID",
terminal_instance_id,
)
used_ids.add(terminal_instance_id)
return terminal_instance_id
def _collect_terminal_bindings(doc):
bindings = []
seen = set()
used_terminal_instance_ids = set()
project_uuid = _project_uuid_from_doc(doc)
for device_group in _iter_device_groups(doc):
instance_id = getattr(device_group, "QetInstanceId", "").strip()
for terminal_obj in _iter_terminal_objects(device_group):
@ -186,7 +304,12 @@ def _collect_terminal_bindings(doc):
or binding_mode == TerminalObjects.TERMINAL_BINDING_MODE_LOCAL
):
continue
terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id
terminal_instance_id = _writeback_terminal_instance_id(
project_uuid,
terminal_obj,
instance_id,
used_terminal_instance_ids,
)
if not terminal_uuid or not terminal_instance_id:
continue
key = (terminal_uuid, terminal_instance_id)
@ -235,6 +358,9 @@ def write_back_document(doc=None, scene_path="", payload=None):
"Cannot determine the 3d_to_2d.json output path."
)
payload = _payload_for_writeback(scene_path, payload)
_sync_terminals_for_writeback(doc, scene_path, payload)
project_uuid = _project_uuid_from_doc(doc, payload)
if not project_uuid:
raise ExchangeWriteBackError(
@ -343,6 +469,19 @@ class _WriteBackObserver:
_is_exchange_document(doc),
)
)
if not _is_exchange_document(doc):
return
try:
sync_terminals_from_current_payload(doc, scene_path=name)
except Exception as exc:
_append_debug_log("write-back terminal sync before save failed: {0}".format(exc))
_append_debug_log(traceback.format_exc())
try:
App.Console.PrintError(
"[FreeCADExchange] terminal sync before save failed: {0}\n".format(exc)
)
except Exception:
pass
def slotFinishSaveDocument(self, doc, name):
_append_debug_log(

@ -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,613 @@
# 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
try:
import TerminalImport
except ImportError:
TerminalImport = None
try:
import TemplateInstantiation
except ImportError:
TemplateInstantiation = None
try:
import BatchAssembly
except ImportError:
BatchAssembly = None
COMMAND_NAME = "QET_Exchange_OpenPendingDevicePanel"
STATE_PAYLOAD = "_qet_exchange_payload"
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
def _imported_model_objects(result):
objects = []
for obj in list((result or {}).get("imported_objects", []) or []):
if obj is not None and getattr(obj, "Name", ""):
objects.append(obj)
return objects
def focus_inserted_result(doc, result):
imported_objects = _imported_model_objects(result)
if not imported_objects:
raise PendingDeviceAssemblyPanelError(
"没有导入任何可显示模型对象,请检查该设备绑定的 3D 模型文件。"
)
device = (result or {}).get("device")
for obj in [device] + imported_objects:
view_object = getattr(obj, "ViewObject", None)
if view_object is not None:
try:
view_object.Visibility = True
except Exception:
pass
if Gui is not None and hasattr(Gui, "Selection"):
try:
Gui.Selection.clearSelection()
except Exception:
pass
for obj in imported_objects:
try:
Gui.Selection.addSelection(doc.Name, obj.Name)
except Exception:
pass
if Gui is not None and hasattr(Gui, "SendMsgToActiveView"):
try:
Gui.SendMsgToActiveView("ViewSelection")
except Exception:
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
def _sync_engineering_terminals_for_inserted_device(doc, result):
device = result.get("device") if isinstance(result, dict) else None
payload = getattr(App, STATE_PAYLOAD, None)
if TerminalImport is not None and isinstance(payload, dict):
project_uuid = (payload.get("project_uuid") or "").strip()
if project_uuid:
return TerminalImport.import_terminals_from_payload(payload, "")
if TemplateInstantiation is not None and device is not None:
return TemplateInstantiation.ensure_engineering_terminals_for_device(doc, device)
return {
"imported_terminals": 0,
"created_terminals": 0,
"updated_terminals": 0,
"warnings": ["端子同步模块不可用,已跳过工程端子生成。"],
}
def insert_device_and_sync_terminals(doc, device, **insert_kwargs):
result = DeviceImport.insert_pending_device(doc, device, **insert_kwargs)
result["terminal_report"] = _sync_engineering_terminals_for_inserted_device(doc, result)
return result
def _terminal_report_count(report, *keys):
for key in keys:
try:
return int(report.get(key, 0) or 0)
except Exception:
continue
return 0
def _terminal_status_suffix(result):
report = result.get("terminal_report", {}) if isinstance(result, dict) else {}
created = _terminal_report_count(report, "imported_terminals", "created_terminals")
updated = _terminal_report_count(report, "updated_terminals")
skipped = sum(
_terminal_report_count(report, key)
for key in (
"skipped_missing_slot",
"skipped_devices_without_template_slots",
"skipped_unbound_slots",
"skipped_missing_device",
)
)
return ",工程端子新增 {0} 个,更新 {1} 个,跳过 {2}".format(
created,
updated,
skipped,
)
def _pending_batch_key(device):
if BatchAssembly is not None:
try:
strip_name, order = BatchAssembly._parse_strip_name_and_order(device)
if strip_name:
return strip_name, order
except Exception:
pass
text = (
getattr(device, "QetDisplayTag", "")
or getattr(device, "Label", "")
or getattr(device, "Name", "")
or ""
).strip()
prefix = ""
number = None
for index, char in enumerate(text):
if char.isdigit():
prefix = text[:index].strip(" :_-")
try:
number = int("".join(ch for ch in text[index:] if ch.isdigit()) or "0")
except Exception:
number = None
break
return prefix or text, number
def matching_pending_batch_devices(doc, seed_device):
seed_prefix, _seed_order = _pending_batch_key(seed_device)
seed_prefix_key = (seed_prefix or "").strip().lower()
if not seed_prefix_key:
return [seed_device]
matches = []
for item in DeviceImport.list_pending_devices(doc):
device = item.get("device")
if device is None:
continue
prefix, order = _pending_batch_key(device)
if (prefix or "").strip().lower() == seed_prefix_key:
matches.append((order if order is not None else 10**9, getattr(device, "Label", "") or getattr(device, "Name", ""), device))
matches.sort(key=lambda item: (item[0], item[1]))
return [device for _order, _label, device in matches] or [seed_device]
def _sync_engineering_terminals_after_batch(doc, devices):
payload = getattr(App, STATE_PAYLOAD, None)
if TerminalImport is not None and isinstance(payload, dict) and (payload.get("project_uuid") or "").strip():
return TerminalImport.import_terminals_from_payload(payload, "")
totals = {"imported_terminals": 0, "created_terminals": 0, "updated_terminals": 0, "warnings": []}
if TemplateInstantiation is None:
totals["warnings"].append("端子同步模块不可用,已跳过工程端子生成。")
return totals
for device in devices:
report = TemplateInstantiation.ensure_engineering_terminals_for_device(doc, device)
for key in ("imported_terminals", "created_terminals", "updated_terminals"):
try:
totals[key] += int(report.get(key, 0) or 0)
except Exception:
pass
totals["warnings"].extend(list(report.get("warnings", []) or []))
return totals
def insert_matching_pending_batch_to_target(
doc,
seed_device,
target,
pitch_mm=5.2,
start_offset_mm=0.0,
mount_offset_mm=20.0,
):
if BatchAssembly is None:
raise PendingDeviceAssemblyPanelError("批量排布模块不可用。")
if target is None:
raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择导轨或安装目标。")
rail = BatchAssembly._ensure_rail(target)
devices = matching_pending_batch_devices(doc, seed_device)
batch_prefix, _seed_order = _pending_batch_key(seed_device)
base = BatchAssembly._base_point(rail)
axis = BatchAssembly._axis_vector(rail)
source_doc_cache = {}
results = []
for index, device in enumerate(devices):
point = BatchAssembly._point_at(
base,
axis,
float(start_offset_mm or 0.0) + index * float(pitch_mm or 0.0),
)
result = DeviceImport.insert_pending_device(
doc,
device,
source_doc_cache=source_doc_cache,
mount_target=rail,
mount_placement=BatchAssembly._placement_at(rail, point),
mount_offset_mm=mount_offset_mm,
)
results.append(result)
terminal_report = _sync_engineering_terminals_after_batch(doc, [result["device"] for result in results])
for result in results:
result["terminal_report"] = terminal_report
try:
doc.recompute()
except Exception:
pass
return {
"devices": [result["device"] for result in results],
"results": results,
"terminal_report": terminal_report,
"batch_prefix": batch_prefix,
"device_labels": [
getattr(result["device"], "QetDisplayTag", "")
or getattr(result["device"], "Label", "")
or getattr(result["device"], "Name", "")
for result in results
],
"inserted_count": len(results),
}
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("插入到选中目标")
self.batch_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(20.0)
offset_row.addWidget(self.mount_offset_input)
layout.addLayout(offset_row)
batch_row = QtWidgets.QHBoxLayout()
batch_row.addWidget(QtWidgets.QLabel("批量间距"))
self.batch_pitch_input = QtWidgets.QDoubleSpinBox()
self.batch_pitch_input.setRange(0.1, 10000.0)
self.batch_pitch_input.setDecimals(1)
self.batch_pitch_input.setSingleStep(1.0)
self.batch_pitch_input.setSuffix(" mm")
self.batch_pitch_input.setValue(5.2)
batch_row.addWidget(self.batch_pitch_input)
batch_row.addWidget(QtWidgets.QLabel("起点偏移"))
self.batch_start_offset_input = QtWidgets.QDoubleSpinBox()
self.batch_start_offset_input.setRange(-10000.0, 10000.0)
self.batch_start_offset_input.setDecimals(1)
self.batch_start_offset_input.setSingleStep(1.0)
self.batch_start_offset_input.setSuffix(" mm")
self.batch_start_offset_input.setValue(0.0)
batch_row.addWidget(self.batch_start_offset_input)
layout.addLayout(batch_row)
layout.addWidget(self.insert_to_target_button)
layout.addWidget(self.batch_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.batch_insert_to_target_button.clicked.connect(self.insert_matching_batch_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:
doc = _document()
result = insert_device_and_sync_terminals(doc, self._selected_device())
focus_inserted_result(doc, result)
self.refresh()
_set_status(
self.status_label,
"已插入设备:{0},导入模型对象 {1}{2}".format(
getattr(result["device"], "Label", "") or getattr(result["device"], "Name", ""),
len(_imported_model_objects(result)),
_terminal_status_suffix(result),
),
)
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def insert_selected_device_to_target(self):
try:
doc = _document()
device = self._selected_device()
context = selected_mount_context(exclude_device=device)
target = context.get("target")
if target is None:
raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择安装板、导轨、线槽或柜体安装面。")
result = insert_device_and_sync_terminals(
doc,
device,
mount_target=target,
mount_placement=context.get("placement"),
mount_normal=context.get("normal"),
mount_offset_mm=self.mount_offset_input.value(),
)
focus_inserted_result(doc, result)
self.refresh()
_set_status(
self.status_label,
"已插入设备到选中目标:{0},导入模型对象 {1}{2}".format(
getattr(result["device"], "Label", "") or getattr(result["device"], "Name", ""),
len(_imported_model_objects(result)),
_terminal_status_suffix(result),
),
)
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def insert_matching_batch_to_target(self):
try:
doc = _document()
device = self._selected_device()
context = selected_mount_context(exclude_device=device)
target = context.get("target")
report = insert_matching_pending_batch_to_target(
doc,
device,
target,
pitch_mm=self.batch_pitch_input.value(),
start_offset_mm=self.batch_start_offset_input.value(),
mount_offset_mm=self.mount_offset_input.value(),
)
imported_count = sum(len(_imported_model_objects(result)) for result in report.get("results", []))
device_labels = list(report.get("device_labels", []) or [])
label_preview = "".join(str(label) for label in device_labels[:5] if str(label))
if len(device_labels) > 5:
label_preview += ""
if label_preview:
label_preview = "{0}".format(label_preview)
self.refresh()
_set_status(
self.status_label,
"已批量插入同组 {0} 待装配设备 {1}{2},导入模型对象 {3}{4}".format(
report.get("batch_prefix", "") or "设备",
len(report.get("devices", []) or []),
label_preview,
imported_count,
_terminal_status_suffix({"terminal_report": report.get("terminal_report", {})}),
),
)
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")

@ -1,7 +1,8 @@
# FreeCADExchange terminal import helpers.
from collections import OrderedDict
from collections import Counter, OrderedDict
import json
import uuid
import FreeCAD as App
@ -38,12 +39,16 @@ 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()
terminal_display = (
item.get("terminal_display")
or item.get("terminal_label")
or item.get("slot_name_hint")
or item.get("slot_name")
or ""
).strip()
terminal_instance_id = (item.get("terminal_instance_id") or "").strip()
slot_name_hint = (
item.get("slot_name_hint")
or item.get("terminal_display")
@ -55,8 +60,9 @@ 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_instance_id": terminal_instance_id,
"terminal_display": terminal_display,
"slot_name_hint": slot_name_hint,
}
@ -70,10 +76,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 +102,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 +111,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 +146,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,28 +155,29 @@ 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[] 下。
# 直接调用本模块时也要读取它,避免正式布线匹配退回 local:* 端子。
key = (element_uuid, terminal_uuid)
if key in seen:
continue
seen.add(key)
terminal_display = (
terminal.get("terminal_display")
or terminal.get("terminal_label")
or terminal.get("slot_name_hint")
or terminal.get("slot_name")
or ""
)
key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display)
if key in seen:
continue
seen.add(key)
entries.append(
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"instance_id": instance_id,
"device_instance_id": device_instance_id,
"terminal_instance_id": (terminal.get("terminal_instance_id") or "").strip(),
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
@ -216,26 +199,25 @@ 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)
if key in seen:
continue
seen.add(key)
terminal_display = (
wire.get("{0}_terminal_display".format(side))
or wire.get("{0}_terminal_label".format(side))
or ""
)
key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display)
if key in seen:
continue
seen.add(key)
entries.append(
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"instance_id": instance_id,
"device_instance_id": device_instance_id,
"terminal_instance_id": "",
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
@ -244,7 +226,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"]:
@ -305,6 +287,110 @@ def _terminal_existing_index(container):
return index
def _terminal_existing_by_instance(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_instance_id = getattr(obj, "QetTerminalInstanceId", "").strip()
if terminal_instance_id and terminal_instance_id not in index:
index[terminal_instance_id] = obj
return index
def _terminal_existing_by_element_uuid(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
element_uuid = getattr(obj, "QetElementUuid", "").strip()
key = (element_uuid, terminal_uuid)
if element_uuid and terminal_uuid and key not in index:
index[key] = obj
return index
def _terminal_context_key(element_uuid, terminal_uuid, terminal_display):
return (
(element_uuid or "").strip(),
(terminal_uuid or "").strip(),
(terminal_display or "").strip(),
)
def _terminal_entry_context_key(entry):
return _terminal_context_key(
entry.get("element_uuid", ""),
entry.get("terminal_uuid", ""),
entry.get("terminal_display", ""),
)
def _terminal_object_display(obj):
if "QetTerminalDisplay" in getattr(obj, "PropertiesList", []):
return getattr(obj, "QetTerminalDisplay", "").strip()
return (
getattr(obj, "Label", "").strip()
or getattr(obj, "QetTemplateSlotName", "").strip()
)
def _terminal_existing_by_context(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
element_uuid = getattr(obj, "QetElementUuid", "").strip()
terminal_display = _terminal_object_display(obj)
key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display)
if element_uuid and terminal_uuid and terminal_display and key not in index:
index[key] = obj
return index
def _terminal_instance_id_seed(project_uuid, entry):
values = [
project_uuid,
entry.get("device_instance_id", ""),
entry.get("element_uuid", ""),
entry.get("terminal_uuid", ""),
entry.get("terminal_display", ""),
entry.get("slot_name_hint", ""),
]
return "qet-freecad-terminal|" + "|".join(str(value or "").strip() for value in values)
def _stable_terminal_instance_id(project_uuid, entry):
return str(uuid.uuid5(uuid.NAMESPACE_URL, _terminal_instance_id_seed(project_uuid, entry)))
def _terminal_instance_counts(items):
values = []
for item in list(items or []):
if not isinstance(item, dict):
continue
value = str(item.get("terminal_instance_id", "") or "").strip()
if value:
values.append(value)
return Counter(values)
def _repair_terminal_instance_id(entry, project_uuid, raw_counts, used_ids):
raw_value = str(entry.get("terminal_instance_id", "") or "").strip()
must_repair = (
not raw_value
or raw_counts.get(raw_value, 0) > 1
or raw_value in used_ids
)
if must_repair:
# QET 当前 v2 快照里可能出现重复 terminal_uuid / terminal_instance_id。
# FreeCAD 侧短期修复必须避开导出顺序所以用设备实例、2D 元件、端子 UUID 和脚号生成稳定 ID。
raw_value = _stable_terminal_instance_id(project_uuid, entry)
suffix = 1
while raw_value in used_ids:
raw_value = str(uuid.uuid5(uuid.NAMESPACE_URL, _terminal_instance_id_seed(project_uuid, entry) + "|{0}".format(suffix)))
suffix += 1
entry["terminal_instance_id"] = raw_value
used_ids.add(raw_value)
return must_repair
def _terminal_existing_local_by_slot(container):
index = {}
for obj in TerminalObjects.collect_terminal_objects(container):
@ -324,7 +410,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
@ -458,6 +544,8 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro
instance_id,
label=terminal_label,
slot_name=slot.get("name", ""),
terminal_instance_id=entry.get("terminal_instance_id", ""),
terminal_display=entry.get("terminal_display", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
@ -465,6 +553,45 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro
return terminal_obj
def _remove_terminal_from_group(doc, terminal_group, terminal_obj):
try:
children = list(getattr(terminal_group, "Group", []) or [])
if terminal_obj in children:
children.remove(terminal_obj)
terminal_group.Group = children
except Exception:
try:
terminal_group.Group.remove(terminal_obj)
except Exception:
pass
try:
if doc.getObject(terminal_obj.Name) is not None:
doc.removeObject(terminal_obj.Name)
except Exception:
pass
def _terminal_object_context_key(terminal_obj):
return _terminal_context_key(
getattr(terminal_obj, "QetElementUuid", ""),
getattr(terminal_obj, "QetTerminalUuid", ""),
_terminal_object_display(terminal_obj),
)
def _remove_stale_qet_terminals(doc, terminal_group, expected_contexts, used_objects):
removed = 0
for terminal_obj in list(TerminalObjects.collect_terminal_objects(terminal_group)):
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
if TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
# 当前 2d_to_3d.json 是端子快照;不在快照中的历史 QET 端子会干扰回写和布线匹配,需要清理。
if terminal_obj not in used_objects or _terminal_object_context_key(terminal_obj) not in expected_contexts:
_remove_terminal_from_group(doc, terminal_group, terminal_obj)
removed += 1
return removed
def import_terminals_from_payload(payload, scene_path=""):
_append_debug_log("TerminalImport.import_terminals_from_payload entered")
@ -486,17 +613,25 @@ def import_terminals_from_payload(payload, scene_path=""):
continue
element_uuid = (item.get("element_uuid") or "").strip()
terminal_uuid = (item.get("terminal_uuid") or "").strip()
terminal_display = (
item.get("terminal_display")
or item.get("terminal_label")
or item.get("slot_name_hint")
or item.get("slot_name")
or ""
).strip()
if element_uuid and terminal_uuid:
terminal_entry_keys.add((element_uuid, terminal_uuid))
terminal_entry_keys.add(_terminal_context_key(element_uuid, terminal_uuid, terminal_display))
embedded_entries = _device_embedded_terminal_entries(payload, terminal_entry_keys)
terminal_entries.extend(embedded_entries)
terminal_entry_keys.update(
(entry["element_uuid"], entry["terminal_uuid"])
_terminal_entry_context_key(entry)
for entry in embedded_entries
if entry.get("element_uuid") and entry.get("terminal_uuid")
)
synthesized_entries = _wire_endpoint_terminal_entries(payload, terminal_entry_keys)
terminal_entries.extend(synthesized_entries)
raw_terminal_instance_counts = _terminal_instance_counts(terminal_entries)
device_lookup = _payload_device_lookup(payload)
@ -517,10 +652,12 @@ def import_terminals_from_payload(payload, scene_path=""):
"skipped_missing_device": 0,
"skipped_invalid_entry": 0,
"skipped_unmatched_parent": 0,
"repaired_terminal_instance_ids": 0,
"warnings": [],
}
grouped = OrderedDict()
used_terminal_instance_ids = set()
for index, item in enumerate(terminal_entries):
report["total_terminals"] += 1
try:
@ -533,6 +670,13 @@ def import_terminals_from_payload(payload, scene_path=""):
if not _terminal_belongs_to_payload_devices(entry, device_lookup):
report["skipped_unmatched_parent"] += 1
continue
if _repair_terminal_instance_id(
entry,
project_uuid,
raw_terminal_instance_counts,
used_terminal_instance_ids,
):
report["repaired_terminal_instance_ids"] += 1
device_group = _locate_device_group(doc, entry)
if device_group is None:
@ -567,7 +711,12 @@ def import_terminals_from_payload(payload, scene_path=""):
terminal_group = _terminal_container_for_device(doc, device_group, project_uuid)
existing_by_uuid = _terminal_existing_index(terminal_group)
existing_by_instance = _terminal_existing_by_instance(terminal_group)
existing_by_context = _terminal_existing_by_context(terminal_group)
existing_by_element_uuid = _terminal_existing_by_element_uuid(terminal_group)
existing_local_by_slot = _terminal_existing_local_by_slot(terminal_group)
entry_uuid_counts = Counter(entry.get("terminal_uuid", "") for entry in entries)
expected_contexts = set(_terminal_entry_context_key(entry) for entry in entries)
used_uuids = set()
used_objects = set()
used_slot_names = set()
@ -617,18 +766,40 @@ def import_terminals_from_payload(payload, scene_path=""):
if slot_name:
used_slot_names.add(slot_name)
terminal_obj = existing_by_uuid.get(terminal_uuid)
terminal_obj = existing_by_instance.get(entry.get("terminal_instance_id", ""))
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = existing_by_context.get(_terminal_entry_context_key(entry))
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = existing_by_element_uuid.get((entry.get("element_uuid", ""), terminal_uuid))
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = (
existing_by_uuid.get(terminal_uuid)
if entry_uuid_counts.get(terminal_uuid, 0) == 1
else None
)
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = existing_local_by_slot.get(slot_name)
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is not None:
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
device_element_uuid,
entry.get("element_uuid", "") or device_element_uuid,
terminal_uuid,
device_instance_id,
label=_terminal_entry_label(entry, slot, terminal_uuid),
slot_name=slot.get("name", ""),
terminal_instance_id=entry.get("terminal_instance_id", ""),
terminal_display=entry.get("terminal_display", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
@ -646,7 +817,7 @@ def import_terminals_from_payload(payload, scene_path=""):
slot,
terminal_group,
project_uuid,
device_element_uuid,
entry.get("element_uuid", "") or device_element_uuid,
device_instance_id,
)
report["imported_terminals"] += 1
@ -654,12 +825,14 @@ def import_terminals_from_payload(payload, scene_path=""):
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
device_element_uuid,
entry.get("element_uuid", "") or device_element_uuid,
terminal_uuid,
device_instance_id,
label=_terminal_entry_label(entry, slot, terminal_uuid),
slot_name=slot.get("name", ""),
)
label=_terminal_entry_label(entry, slot, terminal_uuid),
slot_name=slot.get("name", ""),
terminal_instance_id=entry.get("terminal_instance_id", ""),
terminal_display=entry.get("terminal_display", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
try:
@ -679,6 +852,13 @@ def import_terminals_from_payload(payload, scene_path=""):
_hide_object(source_obj)
report["reused_template_hints"] += 1
if entries:
report["removed_terminals"] += _remove_stale_qet_terminals(
doc,
terminal_group,
expected_contexts,
used_objects,
)
TerminalObjects.sort_group_children(terminal_group)
TerminalObjects.sort_group_children(root_group)

@ -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)
@ -498,6 +548,8 @@ def set_terminal_semantics(
instance_id,
label="",
slot_name="",
terminal_instance_id="",
terminal_display="",
):
ensure_string_property(
obj,
@ -539,6 +591,27 @@ def set_terminal_semantics(
"Parent instance UUID for this terminal",
instance_id,
)
# QetInstanceId 表示父设备实例QetTerminalInstanceId 才是 3D 端子对象自己的稳定实例 ID。
# 旧工程可能没有该字段,缺省时先兼容退回父设备实例,导入器会尽量写入真正端子实例 ID。
terminal_instance_id = (
(terminal_instance_id or "").strip()
or getattr(obj, "QetTerminalInstanceId", "").strip()
or instance_id
)
ensure_string_property(
obj,
"QetTerminalInstanceId",
"QET Exchange",
"Stable 3D terminal instance UUID",
terminal_instance_id,
)
ensure_string_property(
obj,
"QetTerminalDisplay",
"QET Exchange",
"QET terminal display text / terminal number",
(terminal_display or "").strip(),
)
ensure_string_property(
obj,
"Role",

@ -128,13 +128,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:
@ -142,11 +175,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")
@ -170,6 +204,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, ""
)
@ -192,12 +234,12 @@ def _normalize_wire_entry(item, index, device_labels=None):
"wire_style": wire_style,
"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,
@ -400,9 +442,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,622 @@ 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_does_not_treat_existing_engineering_terminals_as_model_objects(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),
"A6300-2",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_existing")
terminal.Role = "Terminal"
terminal.addProperty("App::PropertyString", "QetTerminalUuid", "QET Exchange", "")
terminal.QetTerminalUuid = "terminal-a"
terminal.addProperty("App::PropertyBool", "CanWire", "QET Exchange", "")
terminal.CanWire = True
device_group.addObject(terminal)
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.assertFalse(result["already_placed"])
self.assertEqual(["DeviceBody"], [obj.Name for obj in result["imported_objects"]])
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_pending_device_panel_focuses_imported_objects_after_insert(self):
_install_fake_freecad(None)
gui = sys.modules["FreeCADGui"]
selection_calls = []
view_messages = []
gui.Selection = types.SimpleNamespace(
clearSelection=lambda: selection_calls.append(("clear",)),
addSelection=lambda doc_name, obj_name: selection_calls.append((doc_name, obj_name)),
)
gui.SendMsgToActiveView = lambda message: view_messages.append(message)
device_import, _ = _reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
device = doc.addObject("App::Part", "QETDevice_A6300_2")
body = doc.addObject("Part::Feature", "Solid")
body.ViewObject.Visibility = False
pending_panel.focus_inserted_result(doc, {"device": device, "imported_objects": [body]})
self.assertTrue(body.ViewObject.Visibility)
self.assertEqual([("clear",), ("QETScene", "Solid")], selection_calls)
self.assertEqual(["ViewSelection"], view_messages)
def test_pending_device_panel_rejects_insert_result_without_visible_model_objects(self):
_install_fake_freecad(None)
_reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
device = doc.addObject("App::Part", "QETDevice_A6300_2")
with self.assertRaisesRegex(
pending_panel.PendingDeviceAssemblyPanelError,
"没有导入任何可显示模型对象",
):
pending_panel.focus_inserted_result(doc, {"device": device, "imported_objects": []})
def test_pending_device_panel_syncs_engineering_terminals_after_insert(self):
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _ = _reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
app.ActiveDocument = doc
app._qet_exchange_payload = {"project_uuid": "project-1", "devices": []}
device = doc.addObject("App::Part", "QETDevice_A6300_2")
body = doc.addObject("Part::Feature", "Solid")
calls = []
def fake_insert(insert_doc, insert_device, **kwargs):
calls.append(("insert", insert_doc, insert_device, kwargs))
return {"device": insert_device, "imported_objects": [body]}
class FakeTerminalImport:
@staticmethod
def import_terminals_from_payload(payload, scene_path=""):
calls.append(("terminals", payload, scene_path))
return {"imported_terminals": 1, "updated_terminals": 2, "warnings": []}
original_insert = device_import.insert_pending_device
original_terminal_import = pending_panel.TerminalImport
try:
device_import.insert_pending_device = fake_insert
pending_panel.TerminalImport = FakeTerminalImport
result = pending_panel.insert_device_and_sync_terminals(
doc,
device,
mount_offset_mm=20.0,
)
finally:
device_import.insert_pending_device = original_insert
pending_panel.TerminalImport = original_terminal_import
self.assertIs(device, result["device"])
self.assertEqual([body], result["imported_objects"])
self.assertEqual({"imported_terminals": 1, "updated_terminals": 2, "warnings": []}, result["terminal_report"])
self.assertEqual("insert", calls[0][0])
self.assertEqual("terminals", calls[1][0])
self.assertEqual(app._qet_exchange_payload, calls[1][1])
def test_pending_device_panel_batch_inserts_same_prefix_qet_devices_to_target(self):
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _template_semantics = _reload_modules()
terminal_objects = importlib.import_module("TerminalObjects")
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
app.ActiveDocument = doc
app._qet_exchange_payload = {"project_uuid": "project-1", "devices": []}
root = device_import._ensure_root_group(doc, None, "project-1")
def pending_device(element_uuid, instance_id, display_tag):
device, _created = device_import._ensure_device_group(
doc,
root,
element_uuid,
instance_id,
r"D:\models\terminal.FCStd",
display_tag,
0,
)
device_import._set_device_assembly_state(
device,
device_import.ASSEMBLY_STATE_PENDING,
)
return device
ud2 = pending_device("element-ud2", "instance-ud2", "UD:2")
pending_device("element-id1", "instance-id1", "ID:1")
pending_device("element-ud1", "instance-ud1", "UD:1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(10, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
calls = []
body = doc.addObject("Part::Feature", "ImportedBody")
def fake_insert(insert_doc, insert_device, **kwargs):
calls.append((getattr(insert_device, "QetDisplayTag", ""), kwargs))
return {"device": insert_device, "imported_objects": [body]}
class FakeTerminalImport:
@staticmethod
def import_terminals_from_payload(payload, scene_path=""):
calls.append(("terminals", payload, scene_path))
return {"imported_terminals": 2, "updated_terminals": 0, "warnings": []}
original_insert = device_import.insert_pending_device
original_terminal_import = pending_panel.TerminalImport
try:
device_import.insert_pending_device = fake_insert
pending_panel.TerminalImport = FakeTerminalImport
report = pending_panel.insert_matching_pending_batch_to_target(
doc,
ud2,
rail,
pitch_mm=5.2,
start_offset_mm=1.0,
mount_offset_mm=20.0,
)
finally:
device_import.insert_pending_device = original_insert
pending_panel.TerminalImport = original_terminal_import
self.assertEqual(["UD:1", "UD:2"], [call[0] for call in calls[:2]])
self.assertEqual("terminals", calls[2][0])
self.assertEqual(2, len(report["devices"]))
self.assertEqual("UD", report["batch_prefix"])
self.assertEqual(["UD:1", "UD:2"], report["device_labels"])
self.assertEqual(2, report["inserted_count"])
self.assertEqual({"imported_terminals": 2, "updated_terminals": 0, "warnings": []}, report["terminal_report"])
self.assertAlmostEqual(1.0, calls[0][1]["mount_placement"].Base.x - rail.Placement.Base.x)
self.assertAlmostEqual(6.2, calls[1][1]["mount_placement"].Base.x - rail.Placement.Base.x)
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 +1463,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",
}
],
}
],
}
@ -194,6 +192,426 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource)
self.assertFalse(terminals[0].ViewObject.Visibility)
def test_import_repairs_duplicate_terminal_instance_ids(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-a",
"terminal_instance_id": "shared-instance",
"terminal_display": "A1",
},
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-b",
"terminal_instance_id": "shared-instance",
"terminal_display": "B1",
},
],
}
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
terminal_instance_ids = [terminal.QetTerminalInstanceId for terminal in terminals]
self.assertEqual(2, report["imported_terminals"])
self.assertEqual(2, report["repaired_terminal_instance_ids"])
self.assertEqual(2, len(set(terminal_instance_ids)))
self.assertNotIn("shared-instance", terminal_instance_ids)
self.assertEqual({"element-a", "element-b"}, {terminal.QetElementUuid for terminal in terminals})
def test_repaired_terminal_instance_ids_are_stable_when_payload_order_changes(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
def import_and_collect(entries):
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": entries,
}
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
return {
(terminal.QetElementUuid, terminal.Label): terminal.QetTerminalInstanceId
for terminal in terminal_objects.collect_terminal_objects(terminal_group)
}
first_order = [
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-a",
"terminal_instance_id": "shared-instance",
"terminal_display": "A1",
},
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-b",
"terminal_instance_id": "shared-instance",
"terminal_display": "B1",
},
]
second_order = list(reversed(first_order))
self.assertEqual(
import_and_collect(first_order),
import_and_collect(second_order),
)
def test_import_does_not_treat_existing_terminal_object_as_parent_device(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"element-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
stale_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Stale")
terminal_group.addObject(stale_terminal)
terminal_objects.set_terminal_semantics(
stale_terminal,
"project-1",
"element-b",
"terminal-old",
"device-instance-a",
label="old",
slot_name="old",
terminal_instance_id="terminal-old-instance",
terminal_display="old",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "terminal-b",
"element_uuid": "element-b",
"terminal_instance_id": "terminal-instance-b",
"terminal_display": "B1",
},
],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(
{("element-b", "terminal-b", "B1")},
{
(
terminal.QetElementUuid,
terminal.QetTerminalUuid,
terminal.QetTerminalDisplay,
)
for terminal in terminals
},
)
def test_reimport_creates_new_terminal_when_existing_uuid_belongs_to_other_element(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"element-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
old_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Shared")
terminal_group.addObject(old_terminal)
terminal_objects.set_terminal_semantics(
old_terminal,
"project-1",
"element-a",
"shared-terminal",
"device-instance-a",
label="A1",
slot_name="A1",
terminal_instance_id="old-terminal-instance",
terminal_display="A1",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-a",
"terminal_instance_id": "old-terminal-instance",
"terminal_display": "A1",
},
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-b",
"terminal_instance_id": "new-terminal-instance",
"terminal_display": "B1",
},
],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(1, report["updated_terminals"])
self.assertEqual(
{("element-a", "shared-terminal", "A1"), ("element-b", "shared-terminal", "B1")},
{
(
terminal.QetElementUuid,
terminal.QetTerminalUuid,
terminal.QetTerminalDisplay,
)
for terminal in terminals
},
)
def test_reimport_removes_stale_duplicate_qet_terminal_contexts(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"element-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
stale_duplicate = terminal_objects.create_lcs_object(doc, "QETTerminal_StaleDuplicate")
terminal_group.addObject(stale_duplicate)
terminal_objects.set_terminal_semantics(
stale_duplicate,
"project-1",
"element-a",
"terminal-a",
"device-instance-a",
label="A1-old",
slot_name="A1",
terminal_instance_id="stale-terminal-instance",
terminal_display="A1",
)
stale_not_in_payload = terminal_objects.create_lcs_object(doc, "QETTerminal_StaleMissing")
terminal_group.addObject(stale_not_in_payload)
terminal_objects.set_terminal_semantics(
stale_not_in_payload,
"project-1",
"element-c",
"terminal-c",
"device-instance-a",
label="C1",
slot_name="C1",
terminal_instance_id="stale-missing-instance",
terminal_display="C1",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "element-a",
"terminal_instance_id": "terminal-instance-a",
"terminal_display": "A1",
},
{
"terminal_uuid": "terminal-b",
"element_uuid": "element-b",
"terminal_instance_id": "terminal-instance-b",
"terminal_display": "B1",
},
],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["removed_terminals"])
self.assertEqual(
{("element-a", "terminal-a", "A1"), ("element-b", "terminal-b", "B1")},
{
(
terminal.QetElementUuid,
terminal.QetTerminalUuid,
terminal.QetTerminalDisplay,
)
for terminal in terminals
},
)
def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
@ -253,11 +671,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": [],
}
)
@ -267,6 +684,74 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual([local_terminal], terminals)
self.assertEqual("local:instance-a:P1", local_terminal.QetTerminalUuid)
def test_import_preserves_existing_qet_terminals_when_payload_has_no_entry_for_device(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
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",
)
existing_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Existing")
terminal_group.addObject(existing_terminal)
terminal_objects.set_terminal_semantics(
existing_terminal,
"project-1",
"device-a",
"terminal-a",
"instance-a",
label="P1",
slot_name="P1",
terminal_instance_id="terminal-instance-a",
terminal_display="P1",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(0, report["removed_terminals"])
self.assertEqual([existing_terminal], terminals)
self.assertEqual("terminal-a", existing_terminal.QetTerminalUuid)
def test_import_accepts_nested_device_terminals_without_top_level_terminals(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
@ -326,6 +811,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 +879,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 +927,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 +970,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 +1001,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 +1051,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 +1154,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 +1244,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 +1341,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()
@ -485,6 +560,65 @@ class WiringTest(unittest.TestCase):
label="P1",
)
with tempfile.TemporaryDirectory() as tmp_dir:
report = write_back.write_back_document(
doc,
scene_path=str(Path(tmp_dir) / "scene.FCStd"),
payload={"project_uuid": "project-1"},
)
self.assertEqual(1, len(report["terminals"]))
self.assertEqual("terminal-a", report["terminals"][0]["terminal_uuid"])
self.assertEqual("instance-a", report["terminals"][0]["device_instance_id"])
self.assertNotEqual("instance-a", report["terminals"][0]["terminal_instance_id"])
def test_writeback_prefers_terminal_instance_id_property(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",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-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",
"device-instance-a",
label="A",
)
terminal_objects.ensure_string_property(
terminal,
"QetTerminalInstanceId",
"QET Exchange",
"Stable 3D terminal instance UUID",
"terminal-instance-a",
)
with tempfile.TemporaryDirectory() as tmp_dir:
report = write_back.write_back_document(
doc,
@ -495,8 +629,8 @@ class WiringTest(unittest.TestCase):
self.assertEqual(
[{
"terminal_uuid": "terminal-a",
"device_instance_id": "instance-a",
"terminal_instance_id": "instance-a",
"device_instance_id": "device-instance-a",
"terminal_instance_id": "terminal-instance-a",
}],
report["terminals"],
)
@ -567,6 +701,132 @@ class WiringTest(unittest.TestCase):
report["instances"],
)
def test_writeback_syncs_terminals_from_payload_before_collecting_bindings(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",
)
def sync_from_payload(payload, _scene_path=""):
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid=payload["project_uuid"],
instance_id="instance-a",
)
terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_B")
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
payload["project_uuid"],
"device-b",
"terminal-b",
"instance-a",
label="B",
terminal_instance_id="terminal-instance-b",
terminal_display="B",
)
return {"imported_terminals": 1}
write_back.TerminalImport = types.SimpleNamespace(
import_terminals_from_payload=sync_from_payload
)
with tempfile.TemporaryDirectory() as tmp_dir:
report = write_back.write_back_document(
doc,
scene_path=str(Path(tmp_dir) / "scene.FCStd"),
payload={
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [
{
"element_uuid": "device-b",
"terminal_uuid": "terminal-b",
"terminal_display": "B",
}
],
}
],
},
)
self.assertEqual(
[
{"element_uuid": "device-a", "device_instance_id": "instance-a"},
{"element_uuid": "device-b", "device_instance_id": "instance-a"},
],
report["instances"],
)
def test_writeback_observer_syncs_terminals_before_document_save(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
calls = []
class FakeTerminalImport:
@staticmethod
def import_terminals_from_payload(payload, scene_path=""):
calls.append((payload, scene_path))
return {"imported_terminals": 1}
original_terminal_import = write_back.TerminalImport
try:
write_back.TerminalImport = FakeTerminalImport
with tempfile.TemporaryDirectory() as tmp_dir:
scene_path = Path(tmp_dir) / "scene.FCStd"
payload_path = Path(tmp_dir) / "2d_to_3d.json"
payload_path.write_text(
json.dumps(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [
{
"element_uuid": "device-a",
"terminal_uuid": "terminal-a",
}
],
}
],
}
),
encoding="utf-8",
)
observer = write_back._WriteBackObserver()
observer.slotStartSaveDocument(doc, str(scene_path))
finally:
write_back.TerminalImport = original_terminal_import
self.assertEqual(1, len(calls))
self.assertEqual("project-1", calls[0][0]["project_uuid"])
self.assertEqual(str(scene_path), calls[0][1])
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save