feature/解决冲突-0612

dev
zhanghao 2 weeks ago
commit e861c2408a

@ -19,7 +19,7 @@ The wire duct is a gray open duct for cabinet routing:
- Width: `40 mm` - Width: `40 mm`
- Height: `40 mm` - Height: `40 mm`
It includes a base plate, two side walls, comb-style side slots, and mounting hole markers. It is generated as one FreeCAD tree object, `WireDuct_Body`, with the comb-style side slots and mounting holes cut into the body.
## DIN Rail ## DIN Rail

@ -113,60 +113,60 @@ def _create_wire_duct():
y0 = -width / 2.0 y0 = -width / 2.0
light_gray = (0.72, 0.74, 0.74) light_gray = (0.72, 0.74, 0.74)
dark_gray = (0.18, 0.2, 0.22)
objects = [ base = Part.makeBox(length, width, wall, App.Vector(x0, y0, 0.0))
_box(doc, "WireDuct_BasePlate", length, width, wall, x0, y0, 0.0, light_gray), left_wall = Part.makeBox(length, wall, height, App.Vector(x0, y0, 0.0))
_box(doc, "WireDuct_LeftWall", length, wall, height, x0, y0, 0.0, light_gray), right_wall = Part.makeBox(length, wall, height, App.Vector(x0, width / 2.0 - wall, 0.0))
_box(doc, "WireDuct_RightWall", length, wall, height, x0, width / 2.0 - wall, 0.0, light_gray), body = base.fuse(left_wall).fuse(right_wall)
]
slot_count = 18 slot_count = 18
slot_pitch = length / slot_count slot_pitch = length / slot_count
finger_width = slot_pitch * 0.45 finger_width = slot_pitch * 0.45
for index in range(slot_count): for index in range(slot_count):
center_x = x0 + slot_pitch * (index + 0.5) center_x = x0 + slot_pitch * (index + 0.5)
objects.append( body = body.cut(
_box( Part.makeBox(
doc,
"WireDuct_LeftCombSlot_{0:02d}".format(index + 1),
finger_width, finger_width,
wall + 0.2, wall + 0.4,
height - 8.0, height - 8.0,
center_x - finger_width / 2.0, App.Vector(center_x - finger_width / 2.0, y0 - 0.2, 8.0),
y0 - 0.1,
8.0,
dark_gray,
) )
) )
objects.append( body = body.cut(
_box( Part.makeBox(
doc,
"WireDuct_RightCombSlot_{0:02d}".format(index + 1),
finger_width, finger_width,
wall + 0.2, wall + 0.4,
height - 8.0, height - 8.0,
center_x - finger_width / 2.0, App.Vector(center_x - finger_width / 2.0, width / 2.0 - wall - 0.2, 8.0),
width / 2.0 - wall - 0.1,
8.0,
dark_gray,
) )
) )
for center_x in (-60.0, 0.0, 60.0): for center_x in (-60.0, 0.0, 60.0):
objects.append(_cylinder_z(doc, "WireDuct_MountHole_{0:g}".format(center_x), 2.2, wall + 0.2, center_x, 0.0, 0.0, dark_gray)) body = body.cut(
Part.makeCylinder(
2.2,
wall + 0.4,
App.Vector(center_x, 0.0, -0.2),
App.Vector(0, 0, 1),
)
)
body_obj = doc.addObject("Part::Feature", "WireDuct_Body")
body_obj.Shape = body
_style(body_obj, light_gray, 0)
doc.recompute() doc.recompute()
fcstd = OUT_DIR / "qet_wire_duct.FCStd" fcstd = OUT_DIR / "qet_wire_duct.FCStd"
step = OUT_DIR / "qet_wire_duct.step" step = OUT_DIR / "qet_wire_duct.step"
doc.saveAs(str(fcstd)) doc.saveAs(str(fcstd))
_export_step(objects, step) _export_step([body_obj], step)
return { return {
"name": "wire_duct", "name": "wire_duct",
"fcstd": str(fcstd), "fcstd": str(fcstd),
"step": str(step), "step": str(step),
"dimensions_mm": {"length": length, "width": width, "height": height}, "dimensions_mm": {"length": length, "width": width, "height": height},
"objects": [obj.Name for obj in objects], "objects": [body_obj.Name],
"object_count": 1,
} }

@ -10,49 +10,9 @@
"height": 40.0 "height": 40.0
}, },
"objects": [ "objects": [
"WireDuct_BasePlate", "WireDuct_Body"
"WireDuct_LeftWall", ],
"WireDuct_RightWall", "object_count": 1
"WireDuct_LeftCombSlot_01",
"WireDuct_RightCombSlot_01",
"WireDuct_LeftCombSlot_02",
"WireDuct_RightCombSlot_02",
"WireDuct_LeftCombSlot_03",
"WireDuct_RightCombSlot_03",
"WireDuct_LeftCombSlot_04",
"WireDuct_RightCombSlot_04",
"WireDuct_LeftCombSlot_05",
"WireDuct_RightCombSlot_05",
"WireDuct_LeftCombSlot_06",
"WireDuct_RightCombSlot_06",
"WireDuct_LeftCombSlot_07",
"WireDuct_RightCombSlot_07",
"WireDuct_LeftCombSlot_08",
"WireDuct_RightCombSlot_08",
"WireDuct_LeftCombSlot_09",
"WireDuct_RightCombSlot_09",
"WireDuct_LeftCombSlot_10",
"WireDuct_RightCombSlot_10",
"WireDuct_LeftCombSlot_11",
"WireDuct_RightCombSlot_11",
"WireDuct_LeftCombSlot_12",
"WireDuct_RightCombSlot_12",
"WireDuct_LeftCombSlot_13",
"WireDuct_RightCombSlot_13",
"WireDuct_LeftCombSlot_14",
"WireDuct_RightCombSlot_14",
"WireDuct_LeftCombSlot_15",
"WireDuct_RightCombSlot_15",
"WireDuct_LeftCombSlot_16",
"WireDuct_RightCombSlot_16",
"WireDuct_LeftCombSlot_17",
"WireDuct_RightCombSlot_17",
"WireDuct_LeftCombSlot_18",
"WireDuct_RightCombSlot_18",
"WireDuct_MountHole__60",
"WireDuct_MountHole_0",
"WireDuct_MountHole_60"
]
}, },
{ {
"name": "din_rail", "name": "din_rail",

@ -1,7 +1,7 @@
ISO-10303-21; ISO-10303-21;
HEADER; HEADER;
FILE_DESCRIPTION(('FreeCAD Model'),'2;1'); FILE_DESCRIPTION(('FreeCAD Model'),'2;1');
FILE_NAME('Open CASCADE Shape Model','2026-05-26T19:26:07',(''),(''), FILE_NAME('Open CASCADE Shape Model','2026-05-31T14:15:58',(''),(''),
'Open CASCADE STEP processor 7.8','FreeCAD','Unknown'); 'Open CASCADE STEP processor 7.8','FreeCAD','Unknown');
FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));
ENDSEC; ENDSEC;
@ -247,8 +247,8 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#226 = ITEM_DEFINED_TRANSFORMATION('','',#11,#15); #226 = ITEM_DEFINED_TRANSFORMATION('','',#11,#15);
#227 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#228 #227 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#228
); );
#228 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('43','DINRail_CenterTop','',#5,#63 #228 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','DINRail_CenterTop','',#5,#63,
,$); $);
#229 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#65)); #229 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#65));
#230 = SHAPE_DEFINITION_REPRESENTATION(#231,#237); #230 = SHAPE_DEFINITION_REPRESENTATION(#231,#237);
#231 = PRODUCT_DEFINITION_SHAPE('','',#232); #231 = PRODUCT_DEFINITION_SHAPE('','',#232);
@ -424,8 +424,8 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#395 = ITEM_DEFINED_TRANSFORMATION('','',#11,#19); #395 = ITEM_DEFINED_TRANSFORMATION('','',#11,#19);
#396 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#397 #396 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#397
); );
#397 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('44','DINRail_LeftWeb','',#5,#232, #397 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','DINRail_LeftWeb','',#5,#232,$
$); );
#398 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#234)); #398 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#234));
#399 = SHAPE_DEFINITION_REPRESENTATION(#400,#406); #399 = SHAPE_DEFINITION_REPRESENTATION(#400,#406);
#400 = PRODUCT_DEFINITION_SHAPE('','',#401); #400 = PRODUCT_DEFINITION_SHAPE('','',#401);
@ -601,8 +601,8 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#564 = ITEM_DEFINED_TRANSFORMATION('','',#11,#23); #564 = ITEM_DEFINED_TRANSFORMATION('','',#11,#23);
#565 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#566 #565 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#566
); );
#566 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('45','DINRail_RightWeb','',#5,#401 #566 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('3','DINRail_RightWeb','',#5,#401,
,$); $);
#567 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#403)); #567 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#403));
#568 = SHAPE_DEFINITION_REPRESENTATION(#569,#575); #568 = SHAPE_DEFINITION_REPRESENTATION(#569,#575);
#569 = PRODUCT_DEFINITION_SHAPE('','',#570); #569 = PRODUCT_DEFINITION_SHAPE('','',#570);
@ -778,7 +778,7 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#733 = ITEM_DEFINED_TRANSFORMATION('','',#11,#27); #733 = ITEM_DEFINED_TRANSFORMATION('','',#11,#27);
#734 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#735 #734 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#735
); );
#735 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('46','DINRail_LeftFlange','',#5, #735 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('4','DINRail_LeftFlange','',#5,
#570,$); #570,$);
#736 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#572)); #736 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#572));
#737 = SHAPE_DEFINITION_REPRESENTATION(#738,#744); #737 = SHAPE_DEFINITION_REPRESENTATION(#738,#744);
@ -955,7 +955,7 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#902 = ITEM_DEFINED_TRANSFORMATION('','',#11,#31); #902 = ITEM_DEFINED_TRANSFORMATION('','',#11,#31);
#903 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#904 #903 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#904
); );
#904 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('47','DINRail_RightFlange','',#5, #904 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('5','DINRail_RightFlange','',#5,
#739,$); #739,$);
#905 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#741)); #905 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#741));
#906 = SHAPE_DEFINITION_REPRESENTATION(#907,#913); #906 = SHAPE_DEFINITION_REPRESENTATION(#907,#913);
@ -1133,8 +1133,8 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#1071 = ITEM_DEFINED_TRANSFORMATION('','',#11,#35); #1071 = ITEM_DEFINED_TRANSFORMATION('','',#11,#35);
#1072 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item', #1072 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',
#1073); #1073);
#1073 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('48','DINRail_LeftReturnLip','', #1073 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('6','DINRail_LeftReturnLip','',#5
#5,#908,$); ,#908,$);
#1074 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#910)); #1074 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#910));
#1075 = SHAPE_DEFINITION_REPRESENTATION(#1076,#1082); #1075 = SHAPE_DEFINITION_REPRESENTATION(#1076,#1082);
#1076 = PRODUCT_DEFINITION_SHAPE('','',#1077); #1076 = PRODUCT_DEFINITION_SHAPE('','',#1077);
@ -1311,7 +1311,7 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#1240 = ITEM_DEFINED_TRANSFORMATION('','',#11,#39); #1240 = ITEM_DEFINED_TRANSFORMATION('','',#11,#39);
#1241 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item', #1241 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',
#1242); #1242);
#1242 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('49','DINRail_RightReturnLip','', #1242 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('7','DINRail_RightReturnLip','',
#5,#1077,$); #5,#1077,$);
#1243 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1079)); #1243 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1079));
#1244 = SHAPE_DEFINITION_REPRESENTATION(#1245,#1251); #1244 = SHAPE_DEFINITION_REPRESENTATION(#1245,#1251);
@ -1673,8 +1673,8 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#1592 = ITEM_DEFINED_TRANSFORMATION('','',#11,#43); #1592 = ITEM_DEFINED_TRANSFORMATION('','',#11,#43);
#1593 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item', #1593 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',
#1594); #1594);
#1594 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('50','DINRail_MountSlot__60','', #1594 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('8','DINRail_MountSlot__60','',#5
#5,#1246,$); ,#1246,$);
#1595 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1248)); #1595 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1248));
#1596 = SHAPE_DEFINITION_REPRESENTATION(#1597,#1603); #1596 = SHAPE_DEFINITION_REPRESENTATION(#1597,#1603);
#1597 = PRODUCT_DEFINITION_SHAPE('','',#1598); #1597 = PRODUCT_DEFINITION_SHAPE('','',#1598);
@ -2034,7 +2034,7 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#1944 = ITEM_DEFINED_TRANSFORMATION('','',#11,#47); #1944 = ITEM_DEFINED_TRANSFORMATION('','',#11,#47);
#1945 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item', #1945 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',
#1946); #1946);
#1946 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('51','DINRail_MountSlot_0','',#5, #1946 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('9','DINRail_MountSlot_0','',#5,
#1598,$); #1598,$);
#1947 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1600)); #1947 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1600));
#1948 = SHAPE_DEFINITION_REPRESENTATION(#1949,#1955); #1948 = SHAPE_DEFINITION_REPRESENTATION(#1949,#1955);
@ -2396,7 +2396,7 @@ SHAPE_REPRESENTATION_RELATIONSHIP() );
#2296 = ITEM_DEFINED_TRANSFORMATION('','',#11,#51); #2296 = ITEM_DEFINED_TRANSFORMATION('','',#11,#51);
#2297 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item', #2297 = PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',
#2298); #2298);
#2298 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('52','DINRail_MountSlot_60','',#5 #2298 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('10','DINRail_MountSlot_60','',#5
,#1950,$); ,#1950,$);
#2299 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1952)); #2299 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#1952));
ENDSEC; ENDSEC;

File diff suppressed because it is too large Load Diff

@ -363,6 +363,11 @@ QET 在导出时负责:
- 不再混入几何 `Conductor` UUID 作为导线主标识 - 不再混入几何 `Conductor` UUID 作为导线主标识
- `wire_style_id` 只取 `start_terminal` 所连接导线的样式 - `wire_style_id` 只取 `start_terminal` 所连接导线的样式
- 如果 FreeCAD 需要直接渲染导线颜色/线宽,可在顶层额外提供 `wire_style_database_path`,指向包含 `wire_properties` 表的项目 SQLite 数据库FreeCAD 会按 `wires[].wire_style_id -> wire_properties.id` 查询样式。该字段是可选字段,也可以通过环境变量 `QET_WIRE_PROPERTIES_DB` 提供。
- 如果顶层没有提供数据库路径FreeCAD 导入 `2d_to_3d.json` 时会尝试扫描 JSON 同目录下的 `.sqlite / .sqlite3 / .db` 文件;只有确认其中存在 `wire_properties` 表时,才会自动使用该库作为 `wire_style_database_path`,并在 `_qet_exchange_summary.wire_style_database_path`、批量布线 report 与 compact 诊断中记录最终路径。这是 FreeCAD 侧便利推断,不是 QET 必填输出字段。
- 当前 FreeCAD 会读取 `wire_properties.line_color / line_width / diameter_mm / line_type / area_or_spec` 做第一版显示渲染;颜色支持 `#RRGGBB`、`RRGGBB`、`0xRRGGBB`、`#AARRGGBB`、`0xAARRGGBB`、十进制颜色整数、`rgb(...)`、逗号 RGB 和常见英文色名ARGB 的 alpha 暂不参与线颜色。显示线宽优先使用 `line_width`,缺失时用 `diameter_mm`,两者都缺失时会尝试从 `area_or_spec``2.5mm2 / 2.5mm^2 / 2.5mm²` 等截面积文本估算。
- 查到样式后FreeCAD 会把常用样式字段展开成 3D 导线对象属性:`QetWireStyleName`、`QetWireSpecText`、`QetWireColorText`、`QetWireLineType`、`QetWireType`、`QetWireFormat`、`QetWireDiameterMm`、`QetWireLineWidth`;这些是 3D 侧查看/调试属性,不是 QET 必填输出字段。
- FreeCAD 会在生成导线对象上写入 `QetWireStyleStatus=Resolved/Missing`,并同步写入 `QetRouteDiagnosticsJson.wire_style_status`,用于判断 `wire_style_id` 是否成功回查到 `wire_properties`;批量报告会汇总 `wire_style_status_counts`,已解析样式会进入批量 `routes[].wire_style` 和 compact `route_samples[].wire_style`compact 诊断的 `missing_wire_style_samples[]` 会列出缺失样式样例,`route_samples[]` 会保留 `wire_style_id``wire_style_status`。这是 3D 侧诊断结果,不是 QET 必填输出字段。
- 不按整条几何路径聚合多个样式 - 不按整条几何路径聚合多个样式
- `wires` 是交换 JSON 的扩展层 - `wires` 是交换 JSON 的扩展层

File diff suppressed because it is too large Load Diff

@ -9,7 +9,7 @@
- 导轨、线槽等柜内附件摆放 - 导轨、线槽等柜内附件摆放
- 工程端子生成 - 工程端子生成
- 手动布线与保存回写 - 手动布线与保存回写
- 自动布线的基础准备 - 布线连接的基础准备
本文档遵守当前第一版 2D/3D 协同约束: 本文档遵守当前第一版 2D/3D 协同约束:
@ -53,7 +53,7 @@
- 导入 FCStd 设备模板实例。 - 导入 FCStd 设备模板实例。
- 从模板端子生成工程端子。 - 从模板端子生成工程端子。
- 打开 `3D手动布线` 面板。 - 打开 `3D手动布线` 面板。
- 打开 `3D自动布线` 面板。 - 打开 `3D布线连接` 面板。
- 保存并回写。 - 保存并回写。
当前工具栏/菜单中常见命令: 当前工具栏/菜单中常见命令:
@ -68,7 +68,7 @@
| `生成工程端子` | 把模板端子转换成当前工程里的可布线端子 | | `生成工程端子` | 把模板端子转换成当前工程里的可布线端子 |
| `连接选中端子` | 连接两个已选工程端子 | | `连接选中端子` | 连接两个已选工程端子 |
| `3D手动布线` | 打开手动布线面板 | | `3D手动布线` | 打开手动布线面板 |
| `3D自动布线` | 打开自动布线面板 | | `3D布线连接` | 打开布线连接面板 |
### 1.3 `Draft` ### 1.3 `Draft`
@ -109,7 +109,7 @@
-> 放置设备 -> 放置设备
-> 为设备生成工程端子 -> 为设备生成工程端子
-> 标记线槽/导轨/柜面 -> 标记线槽/导轨/柜面
-> 手动布线或自动布线 -> 手动布线或布线连接
-> 保存并回写 -> 保存并回写
``` ```
@ -117,7 +117,7 @@
- `Assembly` 管“东西放哪儿”。 - `Assembly` 管“东西放哪儿”。
- `QET模板` 管“哪里能接线”。 - `QET模板` 管“哪里能接线”。
- `3D手动布线` / `3D自动布线` 管“线怎么走”。 - `3D手动布线` / `3D布线连接` 管“线怎么走”。
- `scene.FCStd` 是 3D 状态真相源。 - `scene.FCStd` 是 3D 状态真相源。
--- ---
@ -146,6 +146,8 @@
导轨也通常不需要端子 LCS。它是安装基准件。 导轨也通常不需要端子 LCS。它是安装基准件。
当前版本会优先按 QET 传入语义、对象名称、Label、模型路径自动识别导轨。名称中包含 `rail`、`din`、`导轨` 等关键词时,系统会自动补充导轨语义;`标记为导轨` 按钮主要用于纠错或兜底。
本仓库已有示例资产: 本仓库已有示例资产:
```text ```text
@ -166,7 +168,9 @@ data/examples/qet_cabinet_assets/qet_wire_duct.FCStd
data/examples/qet_cabinet_assets/qet_wire_duct.step data/examples/qet_cabinet_assets/qet_wire_duct.step
``` ```
线槽需要在工程里标记为“线槽”,这样自动布线或路径分析才能把它当作走线路径参考。 线槽需要在工程里具备“线槽”语义,这样布线连接或路径分析才能把它当作走线路径参考。
当前版本会优先按 QET 传入语义、对象名称、Label、模型路径自动识别线槽。名称中包含 `wire_duct`、`duct`、`trunking`、`线槽` 等关键词时,系统会自动补充线槽语义;`标记为线槽` 按钮主要用于纠错或兜底。
### 3.4 有接线点的设备 ### 3.4 有接线点的设备
@ -364,6 +368,36 @@ Z = 1200 mm
不要选择 `Gears`。导轨不是运动部件。 不要选择 `Gears`。导轨不是运动部件。
### 7.4 现场机柜中的配合关系
从现场沟通和安装板/导轨/设备位置关系视频看,柜内对象不是“飘在空间里”的独立几何,而是通过机械配合关系装到柜体内:
| 对象 | 常见宿主 | 典型配合关系 | 对布线的意义 |
| --- | --- | --- | --- |
| 安装板 | 柜体框架、背板梁、连接件 | 平面贴合、平行、距离、螺丝孔对齐 | 可作为低优先级布线支撑面 `RoutingRange` |
| DIN 导轨 | 安装板、梁 | 背面贴合、平行、距离、孔位固定 | 作为设备安装基准,不作为导线主路径 |
| 线槽 | 安装板、柜内侧边、梁 | 底面贴合、平行、距离、螺丝孔固定 | 作为导线主路径 `WireDuct` |
| 设备 | DIN 导轨或安装板 | 卡扣贴合、面贴合、孔位固定、固定间距排列 | 提供工程端子位置和出线方向 |
| 过线孔/穿线孔 | 安装板、柜体隔板 | 与开孔同轴或共面 | 作为跨区域路径 `WiringCutOut` |
当前 FreeCADExchange 的能力边界:
1. FreeCAD 原生 `Assembly` 工作台可以做平面对齐、距离、同轴/共线等配合关系。
2. FreeCADExchange 目前主要保存对象最终 `Placement`,并提供轻量的 `贴合到选中面` 辅助。
3. `贴合到选中面` 是一次性位姿调整,不是持久 Assembly 约束求解器。
4. 执行 `贴合到选中面` 后,被移动对象会记录一组轻量宿主元数据:`QetMountMode`、`QetMountKind`、`QetMountHostName`、`QetMountHostKind`、`QetMountHostSubElement`、`QetMountContactSubElement`。这些信息保存在 FreeCAD 文档对象属性中,不写入第一版数据库。
5. `3D手动布线` 面板提供 `刷新宿主装配`。宿主对象平移后,已记录宿主关系的导轨、线槽或设备会按保存的局部偏移跟随更新。
6. 当前 `刷新宿主装配` 只处理平移联动,不处理宿主旋转、导轨槽位约束或复杂 Assembly 求解。
7. 自动布线读取 `scene.FCStd` 里的最终几何位置,不从装配约束反推位置。
推荐建模习惯:
1. 先把柜体或安装板固定好。
2. 导轨、线槽都贴合到安装板或柜体内部结构上。
3. 设备必须装到导轨或安装板上,避免悬空。
4. 装配调整后重新生成布线路径网络,让 `WireDuct`、`RoutingRange` 和 `TerminalAccess` 跟随最新位置刷新。
5. 如果后续要做自动装配,应优先在 FreeCAD 文档内增加宿主语义,例如 `Cabinet`、`MountingPlate`、`DINRail`、`WireDuct`、`Device`,不要扩展第一版数据库绑定表。
--- ---
## 8. 放置线槽 ## 8. 放置线槽
@ -390,7 +424,7 @@ Z = 1200 mm
### 8.3 标记柜面 ### 8.3 标记柜面
如果希望后续自动布线知道哪些面是柜内障碍或辅助区域: 如果希望后续布线连接知道哪些面是柜内障碍或辅助区域:
1. 选择机柜背板或安装板对象。 1. 选择机柜背板或安装板对象。
2. 在 `3D手动布线` 面板点击 `标记为柜面` 2. 在 `3D手动布线` 面板点击 `标记为柜面`
@ -429,10 +463,19 @@ QET模板 -> 导入模板实例
### 9.2 摆放断路器 ### 9.2 摆放断路器
1. 导入小型断路器 FCStd 模板。 正式 QET 工程中,如果 QET 已经传入真实断路器设备,不要再重复批量生成断路器。推荐操作:
2. 移动到导轨前方。
3. 用 `Assembly` 对齐到导轨。 1. 从 QET 点击 `3D视图` 打开 FreeCAD确认树目录中已经有断路器设备。
4. 多个断路器并排时,使用固定间距复制。 2. 选中要安装断路器的导轨。
3. 切换到 `QET模板`
4. 打开 `3D手动布线`
5. 点击 `批量断路器`
6. 在 `QET断路器前缀` 中输入实际设备前缀,例如 `QF`
7. 输入断路器间距和起始偏移。
8. 确认后,系统会把 QET 已导入的真实断路器沿导轨排布。
9. 如果状态提示 `已排布 QET 断路器`,说明没有生成假设备,原有 QET 绑定仍保留。
只有当前工程没有 QET 断路器数据、只是做 3D 演示时,才使用兜底数量和兜底端子号生成本地演示对象。
常见间距: 常见间距:
@ -444,7 +487,20 @@ QET模板 -> 导入模板实例
### 9.3 摆放接线端子 ### 9.3 摆放接线端子
端子片可按固定间距复制。 正式 QET 工程中,端子排通常已经由 QET 传入到 FreeCAD例如树目录中出现 `UD:1`、`UD:2`、`ID:6` 这类端子片设备。此时不要再手动复制一批新端子片,应该排布 QET 已导入的真实端子片。
推荐操作:
1. 从 QET 点击 `3D视图` 打开 FreeCAD。
2. 确认树目录中已经有 `UD`、`ID` 等端子排相关设备。
3. 选中要安装端子排的导轨。
4. 切换到 `QET模板`
5. 打开 `3D手动布线`
6. 点击 `批量端子排`
7. 在 `QET端子排名称/前缀` 中输入 `UD``ID`
8. 输入端子片间距,例如 `5.2 mm`,以及起始偏移。
9. 确认后,系统会把匹配的 QET 真实端子片沿导轨按顺序排布。
10. 如果状态提示 `已排布 QET 端子排`,说明工程端子和 `terminal_uuid` 没有被替换成本地端子。
例如本仓库生成的端子片: 例如本仓库生成的端子片:
@ -458,13 +514,13 @@ data/examples/qet_terminal_block/qet_terminal_slice.FCStd
5.2 mm 5.2 mm
``` ```
操作建议 无 QET 数据的手工演示流程
1. 导入一个端子片。 1. 导入一个端子片。
2. 移动到导轨上。 2. 移动到导轨上。
3. 用 Draft 阵列或 Link 复制。 3. 用 Draft 阵列或 Link 复制。
4. X 方向间距设为 `5.2 mm` 4. X 方向间距设为 `5.2 mm`
5. 需要端子排编号时,后续在 QET 2D 侧维护端子 UUID 和端子名称 5. 这种方式生成的端子通常是本地演示端子,不作为正式 QET 布线匹配主流程
### 9.4 摆放电流互感器 ### 9.4 摆放电流互感器
@ -587,14 +643,14 @@ QETExchangeDevices
--- ---
## 12. 自动布线 ## 12. 布线连接
自动布线适合在端子和走线网络准备好后使用。 布线连接适合在端子和走线网络准备好后使用。
### 12.1 打开自动布线面板 ### 12.1 打开布线连接面板
1. 切换到 `QET模板` 1. 切换到 `QET模板`
2. 点击 `3D自动布线`。 2. 点击 `3D布线连接`。
常用按钮: 常用按钮:
@ -604,15 +660,14 @@ QETExchangeDevices
| `从线槽实体生成中心路径` | 从线槽实体生成可走线路径 | | `从线槽实体生成中心路径` | 从线槽实体生成可走线路径 |
| `从线槽/草图创建路由路径` | 从选中线槽或草图生成路径 | | `从线槽/草图创建路由路径` | 从选中线槽或草图生成路径 |
| `从选中面创建辅助路由区域` | 生成辅助路由区域 | | `从选中面创建辅助路由区域` | 生成辅助路由区域 |
| `测试布线选中两个端子` | 对两个选中端子做单条自动布线测试 | | `生成布线连接` | 根据 QET 导线任务批量生成布线连接 |
| `按导线任务自动布线全部` | 根据 QET 导线任务批量布线 | | `清除布线连接` | 删除生成的布线连接 |
| `清除自动布线` | 删除自动生成导线 |
| `清除走线路径` | 删除路由载体 | | `清除走线路径` | 删除路由载体 |
| `保存` | 保存文档和回写结果 | | `保存` | 保存文档和回写结果 |
### 12.2 自动布线前置条件 ### 12.2 布线连接前置条件
自动布线前建议先满足: 布线连接前建议先满足:
1. 设备已经摆放到位。 1. 设备已经摆放到位。
2. 工程端子已经生成。 2. 工程端子已经生成。
@ -623,20 +678,23 @@ QETExchangeDevices
### 12.3 生成线槽中心路径 ### 12.3 生成线槽中心路径
1. 选择线槽对象。 1. 选择线槽对象。
2. 打开 `3D自动布线` 面板。 2. 打开 `3D布线连接` 面板。
3. 点击 `从线槽实体生成中心路径` 3. 点击 `从线槽实体生成中心路径`
4. 点击 `扫描端子/网络` 4. 点击 `扫描端子/网络`
如果扫描结果显示有 carrier / segment / node说明走线网络已建立。 如果扫描结果显示有 carrier / segment / node说明走线网络已建立。
### 12.4 批量自动布线 ### 12.4 批量生成布线连接
1. 确认 QET 已导入导线任务。 1. 确认 QET 已导入导线任务。
2. 点击 `扫描端子/网络` 2. 点击 `扫描端子/网络`
3. 点击 `按导线任务自动布线全部`。 3. 点击 `生成布线连接`。
4. 查看状态中的 routed、collision_warnings、missing_terminals。 4. 查看状态中的 routed、collision_warnings、missing_terminals。
5. 若有 missing terminals说明某些 2D 端子没有对应工程端子。 5. 若有 missing terminals说明某些 2D 端子没有对应工程端子。
6. 保存。 6. 在树目录 `QETWiring_05_Diagnostics` 下查看 `RoutingConnectionBatch`。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示本次批量布线是否没有问题码;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 是本次批量布线中文摘要;`QetDiagnosticJson` 是 compact 诊断明细,包含 `runtime_version`、`issue_codes`、缺失端点、碰撞、路径质量、容量、柜内边界和路径约束样例。重启 FreeCAD 后手测时,可以先看 `3D 布线连接` 面板状态摘要中的“版本:...”或诊断 JSON 里的 `runtime_version` 是否为当前开发版本,避免旧模块未刷新导致误判。
- 真实工程批量布线还会记录 `batch_network_entry_candidate_limit`、`batch_avoid_obstacles` 和 `batch_obstacle_candidates`。前者表示批量求路时每端最多采用多少个路径入口候选,第二个字段表示是否额外构建障碍过滤路径图,第三个字段表示本次复用的碰撞障碍物候选数量;当前默认性能优先,仍会在结果中输出碰撞诊断。如果批量按钮长时间无响应,优先把这三个字段和 `route_network_carriers / route_network_segments` 一起反馈给开发侧。
7. 如果没有导线任务,也会生成 `RoutingConnectionBatch` 诊断对象,并在 `QetDiagnosticMessage` 中提示“没有导线任务”,便于确认问题来自 QET `wires[]``QETWiring_01_Tasks`
8. 保存。
--- ---
@ -660,7 +718,7 @@ scene.FCStd
保存并回写 保存并回写
``` ```
或在自动布线面板点击: 或在布线连接面板点击:
```text ```text
保存 保存
@ -697,6 +755,285 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
19. 点击 `检查最近导线``检查全部导线` 19. 点击 `检查最近导线``检查全部导线`
20. 点击 `保存并回写` 20. 点击 `保存并回写`
### 14.1 自动布线前的最小可测装配
如果目标是测试 `3D布线连接` 的自动布线效果,空机柜本身还不够。至少需要先完成下面的最小装配:
1. 安装板或背板已经放入机柜,并作为设备安装基准。
2. 导轨已经贴到安装板或背板上。
3. 线槽已经放到柜内,或已经用草图/Draft 线定义用户主路径。
4. QET 导入的真实设备实例已经摆到导轨或安装板上。
5. 已点击 `生成工程端子`,工程端子能在 `QETTerminals_*` 分组中看到。
6. 如需限制导线不能跑出柜外,选择柜内空间、柜体或辅助包围盒,点击 `选中对象作为柜内边界`
完成后按下面顺序检查:
```text
3D布线连接
-> 检查布线准备度
-> 准备布线布局空间
-> 生成布线路径网络
-> 检查布线路径网络
-> 生成布线连接
```
如果 `检查布线准备度` 显示:
```text
路径网络0 段
布线源:未识别到线槽/布线面/用户路径
柜内边界:未标记
```
说明当前问题仍是装配或路径源准备不足,不应把此时的布线效果当作自动布线算法结果。
每次点击 `检查布线准备度`,树目录 `QETWiring_05_Diagnostics` 下会刷新一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示预检是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 是中文摘要;展开属性中的 `QetDiagnosticJson`,可以查看缺失端点、路径源数量、柜内边界数量、路径网络诊断和导线样式库状态。这个对象只保存最新一次预检结果,避免多次测试后诊断对象堆积。
`检查布线准备度` 默认不再抽样求解导线可达性,避免真实机柜中大量设备、路径 carrier 和障碍对象导致预检长时间卡住。需要排查少量导线是否能连通时,再把面板里的 `可达性抽样` 数量从 `0` 调到 1、5 或更高;这个抽样只用于诊断,不影响正式点击 `生成布线连接` 时的全量布线。
预检阶段也会读取路径网络诊断摘要。如果已经标记 `CabinetInterior`,但工程端子或主路径 carrier 越出柜内边界,`检查布线准备度` 会显示“路径网络检查提示”,并带出“越界端子”或“越界路径”样例。这样可以在生成导线前发现装配位置、端子 LCS 或用户路径本身的问题。
如果通过 QET 的 `3D` 按钮启动 FreeCAD随后关闭它新建的工程再手动打开已经装配好的 `FCStd`,可以直接在装配工程里点击 `检查布线准备度`。中文报告会显示 `导线来源`:显示 `QET 会话交换数据` 表示 FreeCAD 仍在读取 QET 按钮传入的 `2d_to_3d.json / wires[]`;显示 `当前 FreeCAD 文档任务` 表示读取的是当前文档中已保存的 `QETWiring_01_Tasks`;如果导线任务为 0则需要重新从 QET 导入或确认当前会话交换数据是否还在。
同一份中文报告还会显示 `运行版本`。如果截图中没有 `运行版本``导线来源`,说明当前 FreeCAD 窗口仍在使用旧的 `AutoRouting.py`,需要完全关闭 FreeCAD 后重新从 QET 3D 按钮启动,再打开已装配工程。
如果预检发现导线端点没有匹配到 3D 工程端子,`QetDiagnosticMessage` 的“端点缺失示例”会同时显示导线标签和起终点端子,例如 `导线 N4111terminal-start -> terminal-missing`。这样可以先回到 QET 导线任务或 FreeCAD 工程端子绑定处排查,而不必只靠 terminal UUID 猜是哪根线。
端点缺失明细还会显示 `FreeCAD同设备端子=N`。如果该值为 `0`,说明这根导线端点所属的 2D 设备在当前 FreeCAD 工程里没有任何 3D 工程端子,优先检查该设备是否已装配、是否已生成端子 LCS、以及 `project_2d3d_terminal_binding` 是否包含对应 `terminal_uuid`。如果该值大于 `0` 但目标端子仍缺失,通常表示同设备已有端子,但这一个端子的 `terminal_uuid` 没有绑定或不一致。
每次点击 `检查布线路径网络`,同一目录下会刷新 `RoutingPathNetwork` 诊断对象。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示路径网络检查是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 会直接提示空路径网络、端子未接入、端子接入过长、端子越出柜内边界、路径对象几何无效、路径越出柜内边界、孤立路径网络等问题;`QetDiagnosticJson` 保存完整诊断明细和 `issue_codes`
面板中的 `端子接入警告距离 mm` 用于判断“端子接入过长”。设为 `0` 时按默认规则自动计算;如果当前机柜尺度较大,且 600-700mm 的端子接入属于可接受的设备局部出线,可以把该值调到 700mm 左右再检查。这个参数只影响质量告警,不会放宽 `端子接入最大距离 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优先补线槽到端子主网络的桥接路径或手动画柜内主路径后点击 `选中路径作为用户路径`
`生成布线连接` 会先按路径网络诊断里的 `wire_ducts_without_terminal_access.bridge_suggestion` 自动生成一次 `UserPath` 桥接,再重新生成路径网络并开始布线。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,避免绕过面板按钮后又退回布线面兜底。报告中如果出现 `自动诊断桥接:生成 UserPath N 条`,表示系统已经自动把孤立线槽组件接入端子主网络。这个动作只修改 FreeCAD 文档中的路径 carrier不写 QET 数据库;重复点击时已存在的同点桥接不会重复生成。
端子局部接入会优先连接 `WireDuct / UserPath / WiringCutOut` 等主路径;即使附近有更近的 `RoutingRange` 布线面,只要主路径仍在 `端子接入最大距离 mm` 内,系统会优先接主路径。`RoutingRange` 只作为没有线槽、没有用户主路径、或主路径距离超限时的兜底区域。这一规则用于贴近 SW/EPLAN 的工程习惯:设备端子先接入线槽/主路径,柜内大路径再沿主网络走。
手动桥接建议流程:
1. 在树目录或 3D 视图中找到诊断提示的线槽 carrier例如 `QETRouteCarrier`、`QETRouteCarrier_1`。
2. 优先点击 `按诊断建议生成桥接`。系统会先刷新路径网络诊断,再按 `bridge_suggestion` 自动生成对应的 `UserPath` 桥接路径;如果重复点击时网络已经接通,不会再重复生成同一条桥。
3. 如果诊断没有建议,或建议对象已经失效,再找到端子接入所在的主网络,通常是靠近设备端子、安装板或布线面的 `TerminalAccess` / `RoutingRange` carrier如果报告已经显示“建议桥接到 xxx”优先选中这个目标 carrier。
4. 同时选中线槽 carrier 和目标 carrier点击 `选中两路径生成桥接`。系统会在两条路径最近点之间生成一段 `UserPath` 桥接路径。
5. 如果选不到已生成的 carrier也可以选中能找到 live carrier 的源路径对象若仍无法生成再用草图线、Draft 线或已有线段,从线槽开口附近画到主网络附近,然后点击 `选中路径作为用户路径`
6. 桥接线段不需要变成实体线槽,但应落在合理柜内空间,避免悬空穿过设备。
7. 点击 `生成布线路径网络` 或直接重新点击 `生成布线连接`
8. 再点击 `检查布线路径网络`,确认 `wire_ducts_without_terminal_access` 消失或数量减少。
9. 重新生成导线后,选中导线查看 `QetRouteQualityStatus`;如果从 `FallbackPathWarning` 变成更少兜底,说明线槽接入已经改善。
完成 `检查布线准备度`、`检查布线路径网络` 和 `生成布线连接` 后,可以点击 `汇总布线诊断`。该按钮不会重新布线,也不会修改 QET 数据;它只读取 `QETWiring_05_Diagnostics` 下最新的 `RoutingPreflight`、`RoutingPathNetwork`、`RoutingConnectionBatch`,合并 `issue_codes` 并在面板中显示“通过/未通过、缺了哪类诊断、主要问题是什么”。同时会刷新一个 `RoutingDiagnosticSummary` 诊断对象,保存 `QetDiagnosticIssueCodes`、`QetDiagnosticIssueLabels`、`QetDiagnosticMessage` 和 `QetDiagnosticJson`,便于手动测试后在树目录中复查或复制给开发侧分析。汇总消息会显示本次诊断采用的 `runtime_version`,优先取 `RoutingConnectionBatch`,用于确认当前工程确实由最新自动布线模块生成。手动测试截图或录屏时,建议最后点一次这个按钮,方便快速判断问题来自装配准备、路径网络,还是批量布线结果。
如果只点击了 `生成布线连接`,没有单独点击 `检查布线准备度``检查布线路径网络``汇总布线诊断` 会把最新 `RoutingConnectionBatch` 当作最终诊断入口;只要批量报告内已经包含路径网络诊断摘要,就不会再因为缺少独立的 `RoutingPreflight / RoutingPathNetwork` 对象而判定失败。也就是说,真实手测可以采用“生成布线连接 -> 汇总布线诊断”的简化流程;汇总结果仍会显示端子缺失、路径网络、碰撞、柜内越界等真正问题。
批量报告里的 `布线布局空间` 表示本次点击时新生成或刷新的路径 carrier 数量;如果线槽路径已经存在,可能会显示 `线槽路径 0 条`。这不等于当前工程没有线槽路径。继续看下一行 `当前路径网络`,它表示本次真实参与求路的全部路径 carrier例如 `当前路径网络:线槽路径 4 条,线槽开口 8 条,用户路径 2 条,端子接入 132 条,布线面 391 条`。判断是否识别到线槽时,以 `当前路径网络``路径采用:线槽/主路径 N 条` 为准。
如果旧版工程中已经存在空白的诊断对象,`汇总布线诊断` 会把它标记为 `diagnostic_json_empty / 诊断 JSON 为空`。这通常表示该诊断对象不是当前版本完整生成的,应重新执行对应步骤,例如重新点击 `检查布线准备度`、`检查布线路径网络` 或 `生成布线连接`
如果工程里已有旧版 `RoutedConnection` 导线对象,但单根导线缺少 `QetRouteDiagnosticsJson`,汇总诊断会提示 `routed_wire_diagnostics_missing / 导线诊断缺失`,并给出一条导线 Label 示例。这类旧线可以显示在模型里,但无法提供碰撞、越界、接入距离等新诊断,应重新执行 `生成布线连接`
如果单根导线的 `QetRouteDiagnosticsJson` 存在但不是合法 JSON汇总诊断会提示 `routed_wire_diagnostics_invalid / 导线诊断 JSON 无效`,并给出一条导线 Label 示例。这通常表示对象来自旧版本、手工修改过属性,或导线对象保存过程中诊断字段损坏,应重新生成布线连接。
`RoutingConnectionBatch``QetDiagnosticJson.route_samples[]` 会保留少量样例导线。样例里可以直接看 `access`、`collision_summary`、`quality`、`capacity`、`boundary` 分组,分别对应长接入、碰撞/安全间隙、路径兜底、容量压力和柜内越界状态。手动测试后如果只想快速反馈问题,可以复制这个诊断对象 JSON开发侧不一定需要逐根在模型里点选导线。
样例中的 `wire_object_label` 与左侧树目录中导线对象 Label 一致。复制诊断 JSON 给开发侧时,可以同时说明这个 Label开发侧就能更快在 `QETWiring_04_Routed` 下定位对应导线。
接入距离、路径质量、候选入口碰撞风险、柜内边界、路径约束、容量压力和碰撞等 warning sample 都会尽量带 `wire_object_label`。手动反馈时优先复制这个字段,比只说“第几条线”更稳定。对于 `missing_route_network_samples[]`、`error_samples[]`、`missing_endpoint_samples[]` 这类失败样例,`wire_object_label` 是任务侧最接近对象标题的显示名,不一定已经生成出真实 3D 导线对象。
中文报告中,碰撞示例和缺失端点示例会优先显示 `wire_object_label`,便于直接在左侧树目录定位对象;导线样式、路径示例和统计类提示仍优先显示短导线号,避免报告过长。
`route_samples[]` 会优先保留有问题码的导线样例。也就是说,如果本次布线大多数线正常、少数线穿模或接入过长,压缩诊断会优先把异常线放进样例里,便于后续排查。
如果批量报告出现 `MissingTerminal / 缺失端子`,先看 `missing_endpoint_samples[]`。每个缺失侧会记录 `*_element_uuid`、`*_instance_id`、`*_terminal_display`、`*_device_in_scene`、`*_device_name`、`*_element_terminal_count`、`*_instance_terminal_count`、`*_missing_endpoint_reason_code` 和中文 `*_missing_endpoint_reason_label`。其中 `missing_device_binding_metadata / 导线端点缺少 2D 设备绑定信息` 表示导线任务端点缺少 `element_uuid`FreeCAD 无法判断缺失端子属于哪个 2D 设备;第一版不要求 QET 在 `wires[]` 端点提供 `start/end_instance_id`。`device_not_in_3d_scene / 该 2D 设备未在 FreeCAD 场景中找到` 表示这条导线引用的设备当前没有对应 3D 设备实例,应优先检查该设备是否已导入、装配并完成 2D/3D 绑定;`no_3d_terminals_for_element / 该 2D 设备在 FreeCAD 中没有工程端子` 表示设备实例在场景中,但没有生成工程端子,应重新生成端子或检查模板端子。这些都不是线槽、用户路径或 Dijkstra 路径网络问题。
`汇总布线诊断` 会把批量报告里的缺端子信息汇总成类似 `缺端子4 条(该 2D 设备未在 FreeCAD 场景中找到 4 处)``缺端子2 条(该 2D 设备在 FreeCAD 中没有工程端子 2 处)` 的文本。这个统计能帮助快速判断当前问题是不是设备未导入/未绑定、设备端子未生成,还是路径网络不连通。
当缺端点原因是 `device_not_in_3d_scene` 时,`汇总布线诊断` 的建议会优先提示“检查缺失 3D 设备是否已导入、装配并完成 2D/3D 绑定”。这种情况下场景里没有可选中的缺失设备,不能靠调整线槽或点击路径按钮解决;应先回到设备导入、装配和绑定流程。只有原因是 `no_3d_terminals_for_element``no_3d_terminals_for_instance` 时,才优先使用 `选择缺端子设备` 去定位已存在但缺工程端子的设备。
当缺端点原因是 `missing_device_binding_metadata` 时,`汇总布线诊断` 的建议会提示“检查 QET 导线端点是否提供 element_uuid 和 terminal_uuid第一版不要求 start/end_instance_id”。这表示 FreeCAD 无法判断缺失端子属于哪个 2D 设备,应由 QET 导线任务补齐端点设备标识;第一版只要求 `wires[]` 每个端点携带 `terminal_uuid``element_uuid``instance_id` 由 FreeCAD 通过 `devices[]`、端子绑定或当前 3D 文档回查。
直接点击 `生成布线连接` 时,如果本次没有生成任何有效导线且缺端子原因包含 `missing_device_binding_metadata`,面板中文报告也会直接提示 `QET 导线端点缺少 element_uuid`,并注明第一版不要求 `start/end_instance_id`。这时不要去调整线槽、柜内边界或 Dijkstra 参数,应先检查 QET 导出的 `wires[]` 端点数据是否完整。
如果本次大多数导线已经成功、只有少量导线缺端子,`生成布线连接` 的中文报告也会显示 `缺端子原因提示`。例如真实工程中 `total_wires=75, routed=71, missing_terminals=4` 时,如果缺失侧设备未在当前 FreeCAD 场景中找到,报告会直接提示先检查设备导入、装配和 2D/3D 绑定,而不是只显示一条缺失样例。报告还会按设备聚合输出类似 `需补端子设备UD:8 缺 2 处as、saUD:10 缺 1 处saUD:5 缺 1 处1` 的文本;这表示自动布线本身已经完成可布的 71 条,剩余 4 条应先补这些 3D 设备/端子场景数据,再重新生成布线连接。
批量诊断的 `issue_codes` 会把缺端子原因提升到顶层,例如 `device_not_in_3d_scene / 3D场景缺少设备`、`missing_device_binding_metadata / 端点缺少绑定信息`、`terminal_uuid_not_in_element / 端子UUID不匹配`、`no_3d_terminals_for_element / 设备缺少工程端子`。手测时如果看到 `device_not_in_3d_scene`,先补设备导入、装配和 2D/3D 绑定;如果看到 `terminal_uuid_not_in_element`,再用 `选择缺端子候选端子` 核对同设备端子 UUID 和脚号。
批量诊断还会写入 `missing_terminal_summary`。其中 `reason_code_counts` 汇总每类缺端子原因,`device_groups[]` 按缺失侧设备归并,包含 `element_uuid`、`instance_id`、缺失端子显示名、端子 UUID 和相关导线号。把该分组发给装配或绑定相关同事时,比逐条复制 `missing_endpoint_samples[]` 更容易定位需要补哪个设备。
这个 `missing_terminal_summary` 不只存在于 `RoutingConnectionBatch.QetDiagnosticJson`,也会直接存在于本次批量布线返回的原始 report 中。后续脚本、面板按钮或调试探针如果要拿缺设备清单,应优先读取 `report.missing_terminal_summary.device_groups[]`,不要解析中文 `需补端子设备` 文本。
如果布线是直接从 FCStd 里的任务对象发起而任务对象没有保存完整设备列表FreeCAD 会尝试从当前 QET 交换上下文的 `2d_to_3d.json` 回补 `devices[]`,用于补齐 `device_groups[].instance_id` 和设备标签。这个回补只读 JSON不写数据库、不覆盖当前导线任务如果项目 UUID 不一致,则不会回补。批量 report 会写入 `context_devices_loaded`、`context_device_count` 和 `context_devices_json_path`,用于判断本次是否真的加载了上下文设备列表。若缺设备分组里 `instance_id` 仍为空,优先检查当前 FreeCAD 会话是否还保留正确的交换 JSON 上下文,或环境变量 `QET_2D_TO_3D_JSON` 是否指向当前项目。
如果工程里保存的是旧版批量诊断,缺少 `*_missing_endpoint_reason_code / label``汇总布线诊断` 会尝试根据当前 FreeCAD 文档现场回填原因。只要样例里还有 `terminal_uuid`、`element_uuid` 或 `instance_id`通常可以直接回填成“QET 端点绑定信息缺失”“设备未在 3D 场景中找到”“设备存在但无工程端子”等原因。只有样例里连这些基础字段也缺失时,才会显示 `缺端点原因未记录`,此时需要重新点击 `生成布线连接` 刷新诊断。
如果要快速定位这些缺端子设备,点击 `选择缺端子设备`。系统会从最新批量布线诊断的 `missing_endpoint_samples[]` 中读取缺失侧的 `*_instance_id``*_element_uuid`,并在 FreeCAD 中选中对应 3D 设备。若缺失原因是 `device_not_in_3d_scene`,当前场景里没有可选中的 3D 对象,面板会优先显示可读设备标签,例如 `UD:8`、`UD:10`,并在状态栏补充对应 `instance_id`,而不是只显示很长的 UUID这类提示表示要先补设备导入、装配和 2D/3D 绑定。控制器返回值也会包含 `missing_terminal_device_instance_ids[]``missing_terminal_device_element_uuids[]`,便于脚本直接交给装配/绑定流程处理。该按钮只做定位,不会自动创建端子;选中后应检查该设备是否已有工程端子、端子是否来自 QET 绑定、以及设备是否装配在当前工程中。
如果点击 `选择缺端子设备` 后没有选中任何对象,并且诊断原因是 `device_not_in_3d_scene`,面板会直接提示缺失侧 2D 设备未在当前 FreeCAD 场景中找到。这表示没有可选中的设备对象,应先补设备导入、装配和 2D/3D 绑定;如果原因是 `missing_device_binding_metadata`,面板会提示 QET 导线端点缺少 `element_uuid`,应先补齐 QET 端点绑定信息。
如果缺端子样例中一端已经找到、另一端缺失,可以点击 `选择缺端子另一端`。系统会选择这些失败导线里已找到的工程端子,例如真实工程中起点缺失但终点已找到时,会选中终点端子。这个按钮用于快速确认“这根失败导线本来要连到哪里”,再回到缺失侧设备检查是否没有生成工程端子、端子脚号是否不一致或 `terminal_uuid` 是否绑定错位;它只定位端子,不会自动补端子或改 QET 数据。
如果缺端点原因是 `terminal_uuid_not_in_element / 同设备存在端子,但没有匹配该 terminal_uuid`,可以点击 `选择缺端子候选端子`。系统会从 `*_instance_terminal_samples``*_element_terminal_samples` 中选择同设备/同实例已有的工程端子,便于直接查看这些端子的 `QetTerminalUuid`、`QetTemplateSlotName`、`QetTerminalLabel` 和位置。若候选端子存在但 UUID 不一致,优先检查 QET 端子绑定或重新生成工程端子;若没有候选端子,则回到 `选择缺端子设备` 检查该设备是否真的生成了端子。
如果已经标记柜内边界,自动布线会先在柜内过滤后的路径图上求路;如果这张图不能连通两端,才回退到原始路径图。批量报告里的样例 network 会记录 `boundary_filtered``boundary_filtered_segments`,用于判断本次路线是否使用了柜内过滤图。如果某条导线仍跑出柜内区域,批量报告会提示“柜内边界提示”。选中具体导线对象时,也可以在属性里查看 `QetRouteBoundaryAware`、`QetRouteBoundaryStatus` 和 `QetRouteBoundaryViolationCount`:其中 `BoundaryWarning` 表示该导线存在柜内越界点,应优先补柜内 `UserPath`、线槽或设备局部路径。
导线样式来自 QET 导线任务中的 `wire_style_id`FreeCAD 会按项目数据库 `wire_properties` 查询颜色、线宽、线型和规格文本。手动测试时,选中生成的 `RoutedConnection` 导线,可以先看 `QetWireStyleStatus``Resolved` 表示已查到样式,`Missing` 表示样式 ID 没查到;再看 `QetWireColorText`、`QetWireLineType`、`QetWireLineWidth`、`QetWireDiameterMm` 等属性确认原始样式数据。GUI FreeCAD 中导线会按解析到的颜色、线宽和线型显示;如果用 `FreeCADCmd` 做命令行验证,因为没有 `ViewObject`,只能确认样式属性写入,不能证明界面颜色已经显示。
如果 `检查布线路径网络` 提示 `route_carriers_outside_boundary / 路径越出柜内边界`,说明某条线槽中心线、`UserPath` 或过线孔路径本身已经有点落在 `CabinetInterior` 外。此时应先调整该路径源或重新标记正确的柜内边界,再生成导线;否则后续自动布线即使能连通,也容易把线带到柜外。
如果提示 `terminals_outside_boundary / 端子越出柜内边界`,说明至少一个工程端子的原点或出线末端落在 `CabinetInterior` 外。优先检查该设备是否真的装配到机柜内、端子 LCS 是否跟随设备实例移动,以及柜内边界对象是否过窄或标错。
如果要快速定位这些对象,点击 `选择越界路径/端子`。系统会从最新 `RoutingPathNetwork` 诊断里选择越界的路径 carrier 和工程端子。选中后通常按下面顺序处理:若是 `UserPath` 或线槽越界,调整源草图/线槽或重新标记正确柜内边界;若是端子越界,检查设备是否真正装配到柜内、端子 LCS 是否跟随设备移动;修正后重新点击 `检查布线路径网络``生成布线连接`
如果直接点击 `生成布线连接(全部导线)`,批量报告也会带出同类路径网络检查提示。出现“越界路径:<路径标签> N 个越界点”时,优先在树目录中定位该路径源或其生成的 carrier修正后重新生成布线路径网络和导线。出现“越界端子<端子对象/UUID> N 个越界点”时,优先定位该端子所属设备,确认设备已经装配到柜内且端子 LCS 跟随实例移动。
如果多根导线共用同一路径,选中具体导线对象可以查看 `QetRouteLaneIndex`、`QetRouteLaneOffsetMm`、`QetRouteParallelWireCount`、`QetRouteMinCarrierCapacity` 和 `QetRouteCapacityStatus`。其中 `CapacityWarning` 表示这条线所在共享路径的并行线数已经超过当前路径容量,需要补备用路径、调整线槽容量或优化路径约束。
如果某条线已经生成但端子附近拉出很长一段斜线或折线,选中该导线对象查看 `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 方向,或为该设备补局部出线路径。
如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。
如果要从设备角度排查,点击 `选择长接入设备`。系统会读取长接入样例里的 `parent_device_name / parent_device_label` 并选中对应设备。通常先用 `选择长接入端子` 看具体端子点,再用 `选择长接入设备` 检查该设备整体是否装配到正确高度、端子 LCS 是否随设备移动,以及设备附近是否需要补局部出线路径。
如果确认是某个工程端子缺少设备局部出线路径,可以直接在当前装配工程里补:
1. 选中一个可布线工程端子。
2. 再选中一条表示该端子出线方向的草图、Draft 线、边或连续 Wire。
3. 点击 `选中端子设置局部出线`
4. 系统会把所选路径写入该工程端子的 `QetTerminalLocalRoutePointsJson`,只修改当前 FreeCAD 文档,不写 QET 数据库。
5. 重新点击 `生成布线路径网络``生成布线连接`
这个动作适合处理端子附近拉出长斜线、从设备内部穿模、或默认 LCS 出线方向不符合实物的情况。若同类设备后续会反复使用,应把局部出线路径沉淀到 FCStd 设备模板里;当前装配工程里的按钮更适合现场手动测试和个别端子的快速修正。
如果要确认某根线到底走了哪条线槽或黄色草图路径,选中导线对象查看 `QetRouteSourceLabels`。该属性会优先显示源路径标签;同一个草图拆成多条路径时会显示 `源路径标签(路径2)` 这类后缀。`QetRouteCarrierNames` 则显示实际经过的 carrier 对象名,适合在左侧树目录中继续定位。
生成导线对象在左侧树目录中的 Label 会尽量显示为 `导线号: 起点端子 -> 终点端子 (状态)`,例如 `N4111: terminal-start -> terminal-end (CollisionWarning)`。如果批量报告提示某条线有问题,可以先按导线号或端子 UUID 在 `QETWiring_04_Routed` 下定位对象,再查看它的属性。
选中导线对象后,也可以先看 `QetRouteIssueCodes``QetRouteIssueLabels`。这两个属性会用与批量诊断一致的问题码汇总该线自身的问题,例如端子接入过长、碰撞告警、路径质量告警、路径容量压力或柜内越界。看到问题码后,再展开对应的详细属性。
如果要先把本次批量布线中的问题线全部找出来,点击 `选择异常导线`。系统会从最新批量诊断 `route_samples[]` 中选择所有带 `issue_codes` 的 RoutedConnection 导线,同时也会扫描已生成导线对象自身的 `QetRouteIssueCodes`,避免 compact 样例数量有限时漏掉问题线。这个按钮适合统一排查长接入、柜内越界、容量压力、路径质量或碰撞问题;不会选择没有问题码的正常导线,也不会修改路径。
如果只想定位“主路径网络不够导致无法按主路径绕开碰撞”的线,点击 `选择缺主路径导线`。系统会选择带 `main_path_detour_missing` 的 RoutedConnection 导线;这类线通常表示选择性避障重算发现了可行绕法,但该绕法会退回 `RoutingRange`、`AuxiliaryPath` 或其它兜底空间路径,所以当前版本按主路径优先策略拒绝采用。选中后应优先补黄色草图 `UserPath`、桥接线槽/主路径、调整线槽入口,或给设备端子补局部出线路径,而不是直接把 fallback 结果当作正式布线。
如果要看这些缺主路径导线当前实际走了哪条线槽、`UserPath` 或源草图,点击 `选择缺主路径线路径`。系统会先从带 `main_path_detour_missing` 的样例中选择当前路径 carrier 和源对象,并补充扫描已生成导线对象自身的 `QetRouteIssueCodes / QetRouteTrackJson`,避免 compact 样例数量有限时漏掉问题线;其它异常线的路径不会混进来。这个按钮适合与 `选择拒绝兜底路径` 对照:前者看“现在走哪里”,后者看“算法想绕到哪里但被拒绝”,两边之间通常就是需要补主路径或局部路径的位置。
如果要继续定位异常导线实际经过了哪条线槽、`UserPath` 或源草图,点击 `选择异常导线路径`。系统会从最新异常 `route_samples[]``carrier_names``route_track.segments[].carrier` 中选择路径 carrier并尽量选择其 `source_name / source_label` 对应的源对象。选中后可以检查该路径是否越界、穿模、容量不足,或直接对源路径设置 Required/Forbidden、调整容量、移动草图路径。
如果你已经在树目录或 3D 视图中选中了某一根问题导线,可以直接点击 `选择选中导线路径`。系统会读取该导线对象上的 `QetRouteTrackJson`,反向选择这根线实际经过的路径 carrier 和源草图/线槽;这个功能不依赖 compact `route_samples[]`,适合诊断样例为空、样例数量不足,或你想单独排查某一根穿模/越界/共线导线的场景。该操作只定位对象,不修改 QET 数据库、不重新生成导线。
如果这根线带 `main_path_detour_missing`,可以在选中导线后继续点击 `选择拒绝兜底路径`。系统会读取导线 `QetRouteDiagnosticsJson` 里被拒绝的 `RoutingRange` / `AuxiliaryPath` 标签,并反选对应的路径源对象;状态栏会显示 `需补路径位置`,列出前几个被拒绝的兜底路径标签。这个按钮的用途不是把兜底路线改成正式结果,而是帮助判断算法“想从哪里绕过去”:若选中的是安装板布线面或辅助路径,通常应在该区域补一条明确的黄色草图 `UserPath`、桥接到线槽/主路径,或给设备端子补局部出线路径。
`汇总布线诊断` 也会扫描已生成导线对象的 `QetRouteIssueCodes`,输出类似 `异常导线2/71 条(端子接入过长 1 条、碰撞告警 1 条)` 的统计。这个数量来自实际导线对象,不受 `route_samples[]` 样例数量限制,更适合作为手测总览。
执行 `生成布线连接(全部导线)` 后,批量报告本身也会带出 `缺主路径绕行N 条``需补路径位置`,不用必须再点一次汇总诊断才能看到剩余补路区域;后续若需要更完整的异常线统计,再点击 `汇总布线诊断`。如果系统还能读取到这些导线当前实际经过的主路径,会继续显示 `补路配对:兜底区域 -> 当前主路径`,例如 `FRONT DOOR-R_P00 -> WireDuct_Body001 4 条`。这表示应优先在该门板/布线面区域和对应主线槽或 UserPath 之间补桥接路径,而不是把门板兜底路线直接当成正式布线。
如果存在 `main_path_detour_missing``汇总布线诊断` 会额外显示 `缺主路径绕行N 条`,并把这些导线诊断中被拒绝的 `RoutingRange / AuxiliaryPath` 标签汇总成 `需补路径位置`。如果单线诊断中保存了 `QetRouteTrackJson`,汇总还会显示 `补路配对`,把被拒绝的兜底区域和当前实际主路径成对列出来。这一步适合在逐根选线前先判断问题集中在哪个柜内区域、应桥接到哪条主路径;随后再点击 `选择缺主路径导线``选择拒绝兜底路径` 做单线定位。
如果 `补路配对` 两端都能在当前 FreeCAD 文档里找到 live carrier可以点击 `按诊断建议生成桥接`。该按钮除了处理“线槽未接入端子主网络”的桥接建议,也会按 `兜底区域 -> 当前主路径` 配对生成一段 `UserPath` 桥接。生成的桥接对象会写入 `QetRouteBridgeKind=MainPathDetourBridge`、`QetRouteBridgePairLabel`、左右源对象 `Name/Label`,便于后续在属性面板里确认这条桥到底连接了哪两个区域。生成后重新点击 `生成布线连接(全部导线)`,检查 `main_path_detour_missing` 是否减少。若状态栏提示“未找到配对”,说明对应兜底区域或主路径源对象无法定位,需要先用 `选择缺主路径补路位置` 查看两端是否存在,或手动画 UserPath。
`生成布线连接(全部导线)` 默认只按当前路径网络布线,不会悄悄把诊断建议固化成新的 `UserPath`。如果现场确认某个 `main_path_detour_missing` 确实需要补主路径,可先用面板里的桥接/补路径按钮手动生成,或在脚本选项中显式开启 `auto_create_main_path_detour_bridges`。开启后,如果选择性避障已经找到一条无碰撞或碰撞更少的兜底绕行,但该绕行包含 `RoutingRange` 而被主路径优先策略拒绝,系统会先把这条已验证折线固化成 `QetRouteBridgeKind=MainPathDetourPath``UserPath`,再按 `兜底区域 -> 当前主路径` 生成 `MainPathDetourBridge`,随后只重试受影响的导线,不会整批全量重跑。批量报告中的 `auto_main_path_detour_bridges` 会记录生成数量、重试导线数和替换结果。
碰撞告警会自动过滤一类导入结构件误报:如果障碍物没有 QET `element_uuid`,并且位于 `QET Exchange Devices / QETCabinet / LinkGroup / Compound / NAUO` 这类导入装配上下文,同时名称或父装配命中柜体、门板、支架、盖板等结构关键词,系统会把它视为未绑定结构件,不再让导线变成 `CollisionWarning`。带 `element_uuid` 的真实设备、断路器、端子等仍然保留为碰撞告警,需要通过补局部路径、调整装配或设置路径约束处理。
如果只想从汇总结果直接定位这些区域,点击 `选择缺主路径补路位置`。系统会读取汇总诊断里的 `需补路径位置` 标签,并按对象 `Name / Label` 以及路径源 `QetRouteSourceName / QetRouteSourceLabel` 反选对应的 `RoutingRange / AuxiliaryPath` 来源对象;如果诊断里已经有 `补路配对`,还会同时选择当前实际主路径对应的线槽或 `UserPath` 源对象。状态栏会显示兜底区域、当前主路径和配对统计,例如 `FRONT DOOR-R_P00 -> WireDuct_Body001 4 条`。这个按钮不要求你先选中导线,适合快速查看剩余问题集中在哪个安装板、布线面或辅助路径附近,以及应桥接到哪条主路径。
当汇总诊断已经能读取到缺主路径绕行问题时,建议动作会按顺序提示:先点击 `选择缺主路径导线` 选中问题线,再点击 `选择缺主路径线路径` 对照当前实际路径;如果诊断中还有被拒绝的兜底路径标签,可以先点击 `选择缺主路径补路位置` 快速定位汇总需补区域,也可以在这些导线保持选中的情况下点击 `选择拒绝兜底路径` 查看单线需补路径位置。这个顺序适合处理真实工程中少量剩余碰撞线。
`汇总布线诊断` 还会根据当前问题给出下一步建议,例如 `点击“选择缺端子设备”定位需要补工程端子的设备`、`点击“选择异常导线”定位带问题码的导线`、`点击“选择碰撞父装配”确认结构件后再标记忽略碰撞`。手测时可以先看这一行,再决定下一步点哪个定位按钮。
如果要判断某根线是明显穿模还是只是距离太近,选中导线对象查看 `QetRouteCollisionStatus`、`QetRouteHardIntersectionCount` 和 `QetRouteClearanceWarningCount`。`HardIntersectionWarning` 表示导线穿过障碍包围盒,应优先改路径或设备位置;`ClearanceWarning` 表示导线没有穿过障碍,但低于安全间隙,通常需要微调路径或安全间隙参数。
批量诊断中的 `collision_samples[]` 也会带 `wire_object_label`。如果报告出现“碰撞示例”,可以先复制这个 Label 到树目录中查找对应导线,再结合 `collision_kind` 判断是硬碰撞还是安全间隙。碰撞样例还会带 `obstacle_parent_labels / obstacle_parent_names`,用于判断类似 `NAUO141` 这样的零件属于前门、柜体、安装板还是具体设备;确认是装配辅助件或可穿过结构后,再手动标记为忽略碰撞对象。
批量报告和汇总诊断里的 `top_collision_obstacles[]` 会按碰撞对象聚合高发对象,并保留 `name`、`label`、`collision_kind_counts`、`parent_labels` 和 `parent_names`。中文摘要会显示类似 `NAUO141(FRONT DOOR-R ASS'Y) 6 处`。如果高发对象属于柜体、门板、盖板或安装辅助结构,先确认导线是否真的应该穿过该区域;确认可忽略后再选择对应对象并标记为忽略碰撞。不要只因为对象名像 `NAUOxxx` 就直接全局忽略。
高发碰撞对象还会给出 `resolution_hint_code / resolution_hint_label`。`review_pass_through_structural_obstacle` 表示疑似柜体、门板、支架、盖板等结构件;处理方式是先在模型中定位该对象,确认它不是实际需要避让的设备实体后,选中对象点击 `选中对象忽略碰撞`,再重新生成布线。`review_device_or_layout_collision` 表示更像设备或安装区域穿模,应优先补线槽、`UserPath`、设备局部出线路径,或调整装配位置。
为了避免在树目录里手工查找 `NAUOxxx`,可以先点击 `选择高发碰撞对象`。系统会从最新批量布线诊断的 `top_collision_obstacles[]` 中查找对象,并在 FreeCAD 里选中这些高发碰撞对象。这个按钮只做定位和选择,不会自动忽略碰撞;确认对象确实是柜体、门板、支架或盖板等可穿越结构后,再点击 `选中对象忽略碰撞`
如果诊断里已经能看到 `parent_names / parent_labels`,也可以点击 `选择碰撞父装配`。系统会直接选择高发碰撞对象所属的父装配,例如前门总成或柜体总成;确认这些总成是可穿越结构后,再点击 `选中对象忽略碰撞`,可以一次影响其下层导入子件。
如果汇总诊断已经把高发碰撞对象标记为 `review_pass_through_structural_obstacle / 疑似结构件可确认忽略`,可以点击 `选择结构件碰撞父装配` 先定位检查。确认这些对象确实是柜体、门板、支架、盖板等结构件后,也可以直接点击 `确认结构件忽略碰撞`。该按钮只会把诊断判定为结构件候选的最近结构父装配标记为 `PassThrough`,不会沿父链继续标记 `QET Exchange Devices` 这类工程根组,也不会标记 `review_device_or_layout_collision` 这类疑似设备或布局碰撞。标记完成后需要重新点击 `生成布线连接`,再看碰撞数量是否减少;如果剩余高发对象变成真实设备,应优先补设备局部出线路径、调整 UserPath/线槽入口或检查装配位置。
结构件碰撞处理后,如果 `top_collision_obstacles[]` 剩余对象的 `resolution_hint_code``review_device_or_layout_collision`,可以点击 `选择设备碰撞对象`。该按钮只定位真实设备/布局碰撞对象,不会标记 `PassThrough`,也不会修改数据库。选中后优先检查这些设备附近是否缺少局部出线路径、线槽入口是否离端子过远、用户路径是否穿过设备包围盒,或设备本身是否装配到错误位置。
批量诊断的 `issue_codes` 会把碰撞进一步拆成 `structural_collision_candidates / 结构件碰撞候选``device_or_layout_collisions / 设备/布局碰撞`。前者适合先定位父装配并确认是否可标记 `PassThrough`后者不应直接忽略应回到设备、端子局部出线路径、UserPath 或装配位置处理。
如果碰撞对象是导入总成下的深层子零件,例如 `门板总成 -> Compound -> NAUOxxx`,也可以选择其父装配或中间 Compound 后点击 `选中对象忽略碰撞`。当前版本会沿父装配链递归识别 `PassThrough`,因此父装配被确认可穿越后,其下层导入子件不会继续作为导线障碍。这个操作只改变 FreeCAD 文档内的布线障碍语义,不写入 QET 数据库。
如果要反向查看“哪些导线正在碰撞”,点击 `选择碰撞导线`。系统会从最新批量布线诊断的 `collision_samples[]` 和带 `collision_warnings``route_samples[]` 中查找 RoutedConnection 导线对象并选中。现场排查时可以先点 `选择高发碰撞对象`,再点 `选择碰撞导线`,结合 3D 视图判断是结构件误报、路径离设备太近,还是需要补局部路径。
如果要判断某根线是否真正走了工程主路径,选中导线对象查看 `QetRouteQualityStatus`。`NormalPath` 表示没有使用布线面/辅助路径兜底;`FallbackPathWarning` 表示路线经过了 `RoutingRange``AuxiliaryPath`,可以继续查看 `QetRouteFallbackCarrierKinds``QetRouteFallbackCarrierLabels`。这个状态不是失败,但通常说明需要补线槽、黄色草图 `UserPath`、过线孔或设备局部路径。
### 14.2 第一版自动布线手测验收清单
第一版验收不要只看“模型里有没有线”,而要同时看诊断对象和单线属性。一次有效手测至少记录下面这些结果:
1. 面板状态摘要显示 `版本2026-06-08-runtime-routing-v4` 或更新版本。
2. `检查布线准备度` 能识别到 QET 导线来源、工程端子、路径网络和柜内边界;若有问题,`RoutingPreflight.QetDiagnosticIssueCodes` 能说明原因。
3. `检查布线路径网络` 不应出现空网络、路径对象几何无效、端子越出柜内边界或路径越出柜内边界;如果出现,应先修装配或路径源。
4. 如果使用 `创建布线路径草图`,退出 Sketcher 后点击 `生成布线路径网络`,应能在树目录看到对应 `UserPath` carrier源草图应保留 `QetRouteSketchMode=ManualUserPathSketch`,生成的 carrier 应保留 `QetRouteSourceLabel` / `QetRouteSourceName`
5. 如果在竖直 Face 上创建草图,生成的 `UserPath` 点应沿该 Face 的离面方向偏移,不应出现离面距离翻倍或路径跑到柜外;若出现,优先记录源草图 Label、离面距离和生成 carrier 的 `Points`
6. 如果把布线路径草图里的线段全部删掉,再点击 `生成布线路径网络`,之前由该草图生成的旧 `UserPath` 应被清理,不应继续参与布线。
7. `生成布线连接` 后,`RoutingConnectionBatch.QetDiagnosticJson.runtime_version` 与面板版本一致。
8. 有导线任务时,`RoutingConnectionBatch.routed` 应大于 0如果为 0应优先看 `missing_endpoint_samples`、`missing_route_network_samples` 或 `error_samples`
9. 正常导线的单线 `QetRouteIssueCodes` 应为空;若存在问题码,应能归类到端子接入、碰撞、柜内越界、容量压力、路径质量或路径约束。
10. 已标记 `CabinetInterior` 时,优先要求 `QetRouteBoundaryStatus=InsideBoundary`;出现 `BoundaryWarning` 时,应补柜内主路径或修正边界。
11. 碰撞状态优先看 `QetRouteCollisionStatus``NoCollision` 为理想结果,`ClearanceWarning` 可作为间隙问题记录,`HardIntersectionWarning` 视为穿模问题。
12. 多根线共路时,检查 `QetRouteLaneIndex`、`QetRouteLaneOffsetMm` 和 `QetRouteCapacityStatus`,确认新增导线不会无诊断地贴到旧线上。
13. 最后点击 `汇总布线诊断`,把 `RoutingDiagnosticSummary.QetDiagnosticMessage` 和主要 `issue_codes` 作为本次手测结论。
如果上面 1、4、7、8 成立,且问题导线都能通过诊断字段定位原因,说明当前版本已经具备第一版可测闭环。若仍存在导线穿模、柜外线或未布通,应优先把对应导线的 `wire_object_label`、`QetRouteIssueCodes`、`QetRouteDiagnosticsJson` 和录屏时间点一起反馈。
### 14.3 手测反馈记录模板
建议每次 GUI 手测后按下面格式记录,便于开发侧快速判断问题来自装配、路径网络、端子接入还是导线求路:
```text
测试工程:
FreeCAD 版本/运行目录:
面板 runtime_version
是否从 QET 3D 按钮打开:
是否重新生成工程端子:
是否标记 CabinetInterior
路径输入:
- 线槽数量/是否标记为线槽:
- UserPath 来源:创建布线路径草图 / 选中路径作为用户路径 / 选中点生成正交3D路径 / 诊断桥接
- 是否使用竖直 Face 创建草图:
- 草图离面距离 mm
- 生成布线路径网络后 UserPath carrier 数量:
批量布线结果:
- total_wires
- routed
- missing_terminals
- missing_route_network
- collision_warnings
- boundary warnings
- main_path_not_used / fallback_routes
汇总诊断:
- RoutingDiagnosticSummary.QetDiagnosticMessage
- 主要 issue_codes
- 下一步建议动作:
异常样例:
- 录屏时间点:
- wire_object_label
- QetRouteIssueCodes
- QetRouteSourceLabels / QetRouteCarrierNames
- QetRouteAccessStatus
- QetRouteCollisionStatus
- QetRouteBoundaryStatus
- QetRouteQualityStatus
- 简要现象:未布通 / 穿模 / 跑出柜外 / 接入过长 / 未走主路径 / 线样式不对
```
--- ---
## 15. 常见问题 ## 15. 常见问题
@ -735,22 +1072,39 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
2. 选择其中的工程端子。 2. 选择其中的工程端子。
3. 再执行 `设为起点` / `设为终点并生成` 3. 再执行 `设为起点` / `设为终点并生成`
### 15.3 为什么自动布线找不到路径? ### 15.3 为什么布线连接找不到路径?
常见原因: 常见原因:
- 设备、线槽、导轨还没有装配到机柜内,左侧树中有对象但 3D 位姿还不是柜内真实位置。
- 没有导入线槽。 - 没有导入线槽。
- 线槽没有标记为线槽。 - 线槽没有标记为线槽。
- 没有从线槽实体生成中心路径。 - 没有从线槽实体生成中心路径。
- 没有用草图/Draft 线创建用户主路径。
- 没有生成 `WireDuct`、`RoutingRange`、`UserPath` 或 `TerminalAccess` carrier。
- 端子离线槽太远,缺少过渡路径。 - 端子离线槽太远,缺少过渡路径。
处理: 处理:
1. 选择线槽,点击 `标记为线槽` 1. 先把导轨、线槽和设备摆到机柜内真实位置。
2. 打开 `3D自动布线` 2. 选择线槽,点击 `标记为线槽`
3. 点击 `从线槽实体生成中心路径` 3. 如果没有线槽,或需要补柜内主路径,优先使用 `3D布线连接` 面板的 SW 类路径流程:
4. 点击 `扫描端子/网络` - 选中安装板、柜门、线槽或柜板上的一个 Face。
5. 再尝试自动布线。 - 设置 `草图离面距离 mm`,例如 0、20、50。
- 点击 `创建布线路径草图`
- 在 Sketcher 中画横竖路径线,退出草图。
- 点击 `生成布线路径网络`,系统会自动把明确标记的布线路径草图转换为 `UserPath`
- 如果要画全局 Z 向或竖向过渡路径,优先选柜侧板、门板内侧、线槽侧面等竖直 Face 创建草图Sketcher 里的水平/垂直约束始终相对当前草图平面。
4. 如果需要跨平面或 Z 向过渡路径,按顺序选择多个 3D 点、顶点或边端点,再点击 `选中点生成正交3D路径`。该路径会按 X/Y/Z 折线生成,适合补线槽之间、门板到柜内、设备区域到主路径之间的连接。
5. 如果已经有普通草图或 Draft 线,也可以选中它后点击 `选中路径作为用户路径`。普通 `Sketch001` 这类未标记机械草图不会被 `生成布线路径网络` 自动当成布线路径,避免误转换。
- 通过 `创建布线路径草图` 生成的草图已经写入布线路径标记,可以命名为“安装板布线路径草图”“门板布线路径草图”等;即使名称里带安装板、门板这类支撑面词,也会按 `UserPath` 处理。
- 如果回到草图里把路径线全部删掉,再点击 `生成布线路径网络` 会清理之前生成的旧 `UserPath`,不用手动删除树目录里的旧 carrier。
6. 打开 `3D布线连接`
7. 点击 `检查布线准备度`,确认有布线源。
8. 点击 `准备布线布局空间`
9. 点击 `生成布线路径网络`
10. 点击 `检查布线路径网络`
11. 再尝试生成布线连接。
### 15.4 为什么保存后 QET 看不到 3D 位姿? ### 15.4 为什么保存后 QET 看不到 3D 位姿?
@ -783,10 +1137,10 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。
2. 常用设备都整理成 FCStd 模板。 2. 常用设备都整理成 FCStd 模板。
3. 有接线点的设备一定补模板端子。 3. 有接线点的设备一定补模板端子。
4. 导轨、线槽、机柜可作为纯几何资产。 4. 导轨、线槽、机柜可作为纯几何资产。
5. 端子排优先用单片端子复制,不要每次重建 5. 正式 QET 工程中,端子排和断路器优先排布 QET 已导入的真实实例Draft 阵列只作为无 QET 数据时的手工演示方式
6. 每完成一段装配就保存一次 `scene.FCStd` 6. 每完成一段装配就保存一次 `scene.FCStd`
7. 布线前先生成工程端子。 7. 布线前先生成工程端子。
8. 自动布线前先建立线槽中心路径 8. 生成布线连接前先建立布线路径网络
9. 不要手动改工程绑定 UUID。 9. 不要手动改工程绑定 UUID。
10. 不要依赖旧 3D 场景表保存位姿。 10. 不要依赖旧 3D 场景表保存位姿。
@ -794,4 +1148,4 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。
## 17. 一句话总结 ## 17. 一句话总结
机柜装配用 `Assembly` 把设备放准;端子语义用 `QET模板` 写进 FCStd 模板;工程中点击 `生成工程端子` 后,再用 `3D手动布线``3D自动布线` 连接工程端子;最终保存的是 `scene.FCStd`,它是 3D 装配和布线状态的真相源。 机柜装配用 `Assembly` 把设备放准;端子语义用 `QET模板` 写进 FCStd 模板;工程中点击 `生成工程端子` 后,再用 `3D手动布线``3D布线连接` 连接工程端子;最终保存的是 `scene.FCStd`,它是 3D 装配和布线状态的真相源。

@ -348,6 +348,6 @@ allow_floating_fallback = false
为避免第一版范围漂移,下面三项采用明确默认值,后续阶段再扩展: 为避免第一版范围漂移,下面三项采用明确默认值,后续阶段再扩展:
1. 第一版读取 `wire_style_id` 仅用于诊断和后续扩展,不强制映射 FreeCAD 导线颜色和线宽 1. 第一版读取 `wire_style_id` 后会按 `wire_properties` 解析导线显示样式:能解析时映射 FreeCAD 导线颜色、线宽和线型,并把 `Resolved/Missing` 状态写入导线对象和批量诊断;缺少数据库或查不到样式时使用默认显示样式,不阻止布线
2. 第一版只在 FreeCAD 中保存和显示自动布线长度,不导出正式长度报表。 2. 第一版只在 FreeCAD 中保存和显示自动布线长度,不导出正式长度报表。
3. 第一版不提供 `AutoSuggested` 转锁定确认导线的完整工作流;用户需要固定路径时,先使用已有手动布线能力重新创建正式手动导线。 3. 第一版不提供 `AutoSuggested` 转锁定确认导线的完整工作流;用户需要固定路径时,先使用已有手动布线能力重新创建正式手动导线。

@ -0,0 +1,64 @@
# 面贴合装配辅助设计
## 背景
CAD 用户在 FreeCAD 中摆放导轨、线槽和设备时需要让两个接触面刚好贴合避免穿模或悬空。FreeCAD 原生 `变换` 可以移动旋转对象,但不会自动判断面接触;`Assembly` 工作台可以做装配约束,但对当前 QET 演示流程偏重。
## 目标
`QET模板 -> 3D手动布线` 面板增加一个轻量装配辅助按钮:`贴合到选中面`。
用户先选择目标承载面,再选择要移动对象的接触面,点击按钮后:
- 沿第一个目标面的法向移动第二个对象,使第二个选择面落到目标面的同一平面上。
- 尽量让第二个选择面的法向与第一个选择面的反向对齐。
- 保持第二个对象原来的切向位置,不把对象横向拉到目标面的拾取点。
- 操作完成后恢复当前 QET 工程为活动文档。
- 不写数据库,不改 2D/3D 绑定表,不影响导线任务。
## 适用场景
- 导轨背面贴合机柜安装板。
- 线槽背面或底面贴合机柜安装板。
- 设备背面或卡扣接触面贴合导轨安装面。
## 交互
推荐流程:
1. 在 3D 视图中选择机柜、导轨或线槽上的目标安装面。
2. 点击 `设为贴合目标面`
3. 选择要移动对象上的接触面。
4. 点击 `贴合到选中面`
5. 对同一个目标面连续摆放多个设备时,只重复第 3、4 步。
兼容流程:
1. 同时选择两个面,第一个是目标面,第二个是要移动对象的接触面。
2. 点击 `贴合到选中面`
如果用户只选中了一个对象而没有选中具体面,系统会尝试使用该对象面积最大的平面作为贴合面。这是为了降低机柜板、导轨、线槽这类规则模型的选面难度;复杂设备仍建议精确选择真实安装面。
如果方向不理想,用户可以先用 FreeCAD 旋转视图、隐藏遮挡物或透明化对象来选面。不要为了选面而旋转模型本体。
第一版只接受两个面。多选多个设备、多个面时,系统不能唯一判断哪个对象应该移动、哪个面是目标、是否要同时满足多个约束,因此会直接提示错误。多面贴合属于完整 Assembly 约束求解范围,不放进这个轻量按钮。
第一版不做多约束求解,不自动识别“哪个面是背面”,也不保存永久装配约束。它只执行一次几何位姿调整。
## 错误处理
- 少于两个面:提示用户先选目标面,再选移动对象接触面。
- 多于两个面:提示只能选择两个面。
- 第二个选择对象没有可移动 `Placement`:提示对象不能移动。
- 无法读取面中心或法向:提示请选择有效模型面。
- 已设置目标面后又选中多个移动面:提示只选择一个接触面。
## 测试
- 选择两个面后,移动对象应只沿目标面法向平移,消除法向间距。
- 多选三个或更多面时应报错。
- 已设置目标面后,只选择一个接触面即可贴合。
- 选择内部子零件面时,应移动 QET 设备或载体根对象,而不是只移动内部 Shape。
- 贴合后应重新选中被移动的根对象,保证后续 FreeCAD `变换` 从新坐标开始。
- 只选对象时,可用最大平面作为辅助贴合面。
- 导入类操作或贴合操作后,`App.ActiveDocument` 仍应是当前 QET 工程。

@ -0,0 +1,334 @@
# 批量端子排与小型断路器装配设计
## 目标
本功能用于 QET 与 FreeCAD 协同工程中的快速 3D 装配。正式工程里QET 已经传入真实设备、真实端子和 3D 模型FreeCAD 不再把 `批量端子排`、`批量断路器` 理解为重新生成一批假设备,而是把 QET 已导入的真实实例沿导轨批量排布。
第一版目标:
- 选择一根 DIN 导轨后,批量排布 QET 已导入的端子排实例,例如 `UD`、`ID`。
- 选择一根 DIN 导轨后,批量排布 QET 已导入的小型断路器或同类设备,例如 `QF1`、`QF2`。
- 保留 QET 身份字段,尤其是 `QetTerminalUuid`、`QetInstanceId`、`QetElementUuid`。
- 不破坏现有工程端子、导线任务和后续布线匹配。
- 旧的本地占位生成逻辑只作为没有 QET 数据时的演示兜底。
## 数据职责
QET 负责:
- 2D 原理图中的设备、符号、端子、端子排和导线任务。
- 设备型号、端子号、端子排名称,例如 `UD`、`ID`。
- 设备与 3D 模型资产绑定。
- 端子的真实 `terminal_uuid`
当前交换 JSON 中,正式端子优先来自 `devices[].terminals[]`。顶层 `terminals[]` 可以为空,不能因此判断 QET 没有传端子。导线任务的 `start_terminal_uuid / end_terminal_uuid` 也使用同一套真实端子 UUID。
FreeCAD 负责:
- 真实 3D 设备实例的空间位姿。
- 导轨、线槽、柜面等装配宿主。
- 工程端子的 3D 坐标和出线方向。
- 设备与导轨的批量排布状态。
- 3D 布线路径和保存回写。
第一版仍遵守 2D/3D 协同约束3D 端子绑定唯一依据是 `terminal_uuid`3D 位姿以 `scene.FCStd` 为准,不从数据库反推 3D 位姿。
## 端子导入顺序
FreeCAD 导入工程端子时按下面顺序读取:
```text
1. 顶层 terminals[]
2. devices[].terminals[]
3. wires[] 中的起点/终点端子,仅作为缺失端子的兜底补齐
```
如果 `devices[].terminals[]` 已经包含某个导线端点,`wires[]` 不会重复生成同一个端子。正式工程中生成的工程端子必须保留 QET 传入的 `terminal_uuid`,包括 `element_uuid:terminal_uuid` 这种复合字符串,不允许转换为 `local:*`
## 端子排批量排布
正式流程:
1. 用户在 FreeCAD 中选中一根已识别或已标记的导轨。
2. 点击 `3D手动布线` 面板中的 `批量端子排`
3. 输入 QET 端子排名称或前缀,例如 `UD`、`ID`。
4. 输入端子片间距和起始偏移。
5. 系统扫描当前 `scene.FCStd` 中 QET 已导入的端子片设备。
6. 匹配端子排名称,例如 `UD:1`、`UD-2`、`ID_006`。
7. 按 QET 顺序字段或名称中的自然序号排序。
8. 沿导轨轴向排布这些真实端子片。
9. 写入轻量装配属性,不改变端子的 QET 绑定。
端子排匹配优先读取这些属性:
- `QetTerminalStripName`
- `QetTerminalBlockName`
- `QetTerminalGroupName`
- `QetStripName`
- `QetParentTerminalBlockName`
如果没有上述属性,则从对象 `Label` / `Name` 解析 `UD:1`、`ID-2` 这类名称。
端子排排序优先读取这些属性:
- `QetTerminalStripIndex`
- `QetTerminalIndex`
- `QetTerminalSequence`
- `QetTerminalOrder`
- `QetTerminalNo`
- `QetTerminalDisplay`
如果没有上述属性,则从对象名称中提取最后一个数字做自然排序。
## 小型断路器批量排布
正式流程:
1. 用户在 FreeCAD 中选中一根导轨。
2. 点击 `3D手动布线` 面板中的 `批量断路器`
3. 输入 QET 设备前缀,例如 `QF`
4. 输入设备间距和起始偏移。
5. 系统扫描当前 `scene.FCStd` 中 QET 已导入的真实设备实例。
6. 排除端子排端子片和旧的本地批量生成对象。
7. 按设备 `Label`、`Name`、`QetInstanceId` 等字段匹配前缀。
8. 按自然顺序排布,例如 `QF1`、`QF2`、`QF10`。
9. 保留设备下的工程端子和 QET 绑定关系。
断路器筛选只处理真实设备对象,不处理设备下的工程端子对象或 `QET Terminals` 分组。工程端子和端子分组也会携带 `QetInstanceId / QetElementUuid`,不能只按这些字段判断为设备,否则 `QF1:1` 这类端子会被误当成断路器一起排布。
断路器端子号来自 QET 传入的真实端子数据。参数窗口中的“兜底端子号”只在当前工程没有匹配 QET 设备、需要演示生成占位对象时使用。
## 旧兜底逻辑
为了保留开发调试和无 QET 数据演示能力,旧接口仍保留:
- `create_terminal_block(...)`
- `create_breakers(...)`
但正式按钮调用顺序是:
```text
先 layout_existing_terminal_block / layout_existing_devices
如果 updated_devices > 0说明已排布 QET 真实对象
如果没有匹配对象,才回退 create_terminal_block / create_breakers
```
兜底生成对象可能产生 `local:*` 端子,只能用于 3D 演示和开发测试,不作为正式 QET 布线匹配的主流程。
## 装配属性
排布真实 QET 对象时,系统只写入轻量属性:
- `QetBatchAssemblyKind`
- `QetBatchAssemblyName`
- `QetBatchAssemblyMode = layout_existing`
- `QetBatchAssemblyOrder`
- `QetBatchAssemblyOffsetMm`
- `QetMountKind = rail`
- `QetMountHostName`
- `QetMountHostKind`
这些属性保存在 FreeCAD 文档里,用于后续刷新、诊断和显示,不扩展第一版数据库绑定表。
## 导轨定位规则
第一版使用导轨对象的 `QetCarrierAxis` 作为排列轴,默认 `x`。如果导轨带旋转,排列轴经过导轨 `Placement.Rotation` 转换。
放置公式:
```text
第 N 个对象位置 = 导轨 Placement.Base + 导轨轴向单位向量 * (起始偏移 + N * 间距)
```
当前实现重点保证批量排布稳定、身份不丢失。复杂 Assembly Joint、端子片端挡、隔板、跨接片、短接片规则暂不纳入第一版。
## 装配视频复盘
本节作为后续 3D 装配优化的对比基准。装配相关需求、问题复盘和验收差异优先沉淀到本文档,再拆分为具体实现计划。
### 用户装配视频提炼
用户视频前半段体现的目标流程:
1. 先按真实设备和实物安装关系确认 3D 模型是否匹配。
2. 对设备补充可复用的装配脚点、连接点或接线点。
3. 设备脚点制作完成后,保存为可复用 `.FCStd` 模块。
4. 后续工程中再次插入该设备时,自动带出脚点、端子和装配语义。
5. 装配时可使用 FreeCAD 原生 `切换透明度`、`显示/隐藏所选`,便于选中柜板、导轨、线槽和设备背面。
6. 按步骤导入设备并完成贴合,避免只靠人工拖拽。
当前 FreeCAD 二开需要重点解决的问题:
- 面不容易选中,尤其是柜内导轨、线槽、设备背面被遮挡时。
- 旋转模型后再贴合,容易出现一部分贴合、一部分穿模或悬空。
- 贴合时如果只移动可视子对象,父对象 `Placement` 没同步,后续使用 `变换` 会回到旧位置。
- 多选多个设备面参与贴合不合理,约束语义不清,会导致算法不知道哪个面是移动面。
- 线槽、导轨贴合后仍需要能二次修改长度,并保持与柜板的贴合关系。
- FreeCAD 任务面板和原生 `变换` 任务框会冲突,普通用户需要一键关闭当前面板并进入原生变换。
### 甲方视频参考能力
甲方视频中可参考的装配体验:
- 柜体、导轨、线槽、安装板可透明显示,便于从柜内选择目标面;透明化优先复用 FreeCAD 原生右键菜单能力。
- 对象树、属性面板和三维操纵器联动,用户能明确看到当前选中的对象和坐标。
- 装配过程使用面、边、点作为参考,而不是单纯输入绝对坐标。
- 设备沿导轨或安装板成组排列,位置规则清晰,适合端子排、断路器、继电器等电气元件。
- 贴合后仍能继续微调距离、方向和局部偏移。
- 电气装配关注柜板、导轨、线槽、设备安装面,不需要第一阶段实现完整机械 CAD 装配约束。
## 后续装配优化方向
后续装配能力优先向 SolidWorks Electrical / EPLAN 的电气柜装配体验靠拢,但第一阶段只做电气常用能力,不做完整机械装配工作台。
### 1. 面贴合可靠性
目标:
- 目标面和移动面只允许一对一贴合。
- 如果用户已点击 `设为贴合目标面`,后续只能再选一个移动面。
- 如果用户一次选择两个面,按选择顺序解释为:第一个目标面,第二个移动面。
- 如果选择超过两个面,直接提示重新选择,不执行贴合。
- 贴合时同时更新父级可移动对象的 `Placement`,避免可视位置和对象坐标脱节。
贴合计算原则:
```text
移动面法向 -> 目标面反向法向
移动面参考点 -> 目标面参考点所在平面
最终位姿写入可移动父对象 Placement
```
### 2. 旋转模型后的贴合
目标:
- 用户为了选面临时旋转设备后,贴合仍能根据真实面法向计算旋转和位移。
- 不再只做单轴平移。
- 贴合完成后设备安装面应整体与目标面共面,不允许局部穿模。
- 用户可设置 `贴合间距`0 mm 表示完全贴合,正值表示沿目标面法向预留距离。
- 已贴合对象保存 `QetMountHostNormalJson``QetMountOffsetMm`,后续选择对象后可点击 `应用贴合间距` 做二次调节。
- 如果模型法向与现场直觉相反,选择已贴合对象后点击 `反转贴合方向`,再应用贴合间距。
验收:
- 电流互感器、小型断路器、端子片旋转后,仍可贴到导轨或柜板。
- 贴合后使用 FreeCAD 原生 `变换`,对象从当前贴合位置继续移动,不跳回旧位置。
- 在 `3D手动布线` 面板中选择对象后点击 `关闭面板并变换`,系统先关闭当前任务面板,再调用 FreeCAD 原生 `Std_TransformManip`
### 3. 导轨、线槽、柜板宿主语义
装配宿主分为:
- `cabinet`:柜板、安装板、门板等。
- `rail`DIN 导轨。
- `wire_duct`:线槽。
- `device`:已经装配好的设备,可作为局部参考。
宿主对象应保存:
- `QetCarrierKind`
- `QetCarrierAxis`
- `QetCarrierBaseLength`
- `QetMountMode`
- `QetMountHostName`
- `QetMountHostKind`
- `QetMountContactSubElement`
- `QetMountHostSubElement`
- `QetMountLocalBaseJson`
- `QetMountHostBaseJson`
这些属性用于保存重开、刷新宿主装配和后续自动布线。
### 4. 长度二次调节
导轨和线槽长度调整规则:
- 导入时可设置初始长度。
- 贴合到柜板后仍可修改长度。
- 修改长度时保持宿主贴合面不变。
- 长度变化应优先沿 `QetCarrierAxis` 扩展。
- 如果对象是导入的 FCStd/STEP 组合体,优先修改带 `QetCarrierBaseLength` 的父级载体对象,不应误选内部子零件。
### 5. 设备模板化与复用
设备模板应包含:
- 真实几何模型。
- 安装接触面或装配脚点。
- 工程端子 LCS。
- 端子出线方向。
- 可选局部出线路径。
保存为 `.FCStd`QET 再次导入同型号设备时,应复用这些模板语义。正式导线匹配仍以 QET 传入的 `terminal_uuid` 为准,不使用 `local:*` 作为正式端子身份。
### 6. 电气装配优先级
优先实现:
1. 导轨贴柜板。
2. 线槽贴柜板。
3. 端子排沿导轨排列。
4. 小型断路器沿导轨排列。
5. 电流互感器、继电器等设备贴导轨或柜板。
6. 贴合后的长度调节和刷新宿主装配。
暂不优先实现:
- 完整机械装配 Joint。
- 螺钉、孔、螺纹的精确机械配合。
- 复杂运动学约束。
- 完整 SW Mechanical 级别的 Mate 系统。
## UI
入口位于:
```text
QET模板 -> 3D手动布线
```
按钮:
- `批量端子排`
- `批量断路器`
- `设为贴合目标面`
- `贴合到选中面`
- `应用贴合间距`
- `反转贴合方向`
- `刷新宿主装配`
- `贴合间距`
参数窗口说明:
- `QET端子排名称/前缀`:正式工程用于匹配 QET 端子排,例如 `UD`、`ID`。
- `QET断路器前缀`:正式工程用于匹配 QET 已导入设备,例如 `QF`
- `端子间距 / 断路器间距`:沿导轨方向的排布间距。
- `起始偏移`:从导轨基点开始的偏移。
- `兜底数量 / 兜底端子号`:只有找不到匹配 QET 对象时才用于生成演示对象。
执行成功后状态栏会区分:
- `已排布 QET 端子排`
- `已排布 QET 断路器`
- `未找到匹配的 QET ...,已兜底生成`
## 验收
1. 从 QET 点击 `3D视图` 打开 FreeCAD。
2. 树目录中已经存在 QET 导入的端子片或设备实例。
3. 选中导轨,点击 `批量端子排`
4. 输入 `UD``ID`,确认后真实端子片沿导轨排布。
5. 排布后端子对象仍保留真实 `QetTerminalUuid`,包括 `element_uuid:terminal_uuid` 这种 QET -> FreeCAD 交换身份,不会变成 `local:*`
6. 选中导轨,点击 `批量断路器`
7. 输入 `QF`,确认后真实断路器沿导轨排布。
8. 保存后重新打开 `scene.FCStd`,设备位置保持。
9. 后续 `3D手动布线``3D布线连接` 能继续通过 `terminal_uuid` 匹配导线任务。
## 非目标
- 不做完整 SolidWorks Electrical / EPLAN 设备库。
- 不在 FreeCAD 中重新创建 QET 已经传入的正式设备。
- 不伪造 QET `terminal_uuid`
- 不删除旧兜底生成函数,但普通工程主流程不依赖它。
- 不实现完整端子排电气跨接片、跳线、端挡和标记条规则。

@ -0,0 +1,848 @@
# 三期 3D 功能任务拆解与开发顺序
更新时间2026-06-08
## 1. 文档目标
本文档根据当前任务表拆解三期 3D 建模和三维布线相关功能,明确:
- 每个任务包含哪些具体功能。
- FreeCAD 原生是否已经具备对应能力。
- 当前项目是否已经完成。
- 第一版是否必须做。
- 后续应该如何开发,以及开发顺序。
本文档只按当前正式路线评估:
```text
QET / 明图CAD
-> 2d_to_3d.json
-> FreeCADExchange
-> scene.FCStd
-> 3d_to_2d.json
```
不再把旧 ThreeD 模块作为正式完成依据。
## 2. 状态定义
| 状态 | 含义 |
| --- | --- |
| 已完成第一版 | 当前代码已经能支撑最小可用流程,但仍可能需要优化体验和边界情况。 |
| 部分完成 | 已有基础代码或 FreeCAD 原生能力,但还没有达到任务表描述的完整交付标准。 |
| 未完成 | 当前正式 FreeCAD 路线中还没有形成可用能力。 |
| FreeCAD 原生可用 | FreeCAD 已经提供通用 CAD 能力,第一版可以直接使用,不需要重复开发。 |
## 3. 三期 3D 建模功能交付
### 3.1 3D 数据模型与映射规范开发
任务描述:
> 基于 QET 设备、符号、端子、项目数据库,建立设备-3D资产-场景实例-端子连接点-2D图元映射关系定义 STEP/IGES/FCStd 资产、sidecar 元数据、设备参数、安装规则、连接点语义。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 设备实例与 3D 实例映射 | 已完成第一版 | 必须 | 使用 `project_uuid + element_uuid + instance_id`。 | QET 设备打开到 FreeCAD 后能稳定生成或复用同一个 3D 设备实例。 |
| 2D 端子与 3D 工程端子映射 | 已完成第一版 | 必须 | 第一版唯一核心依据是 `terminal_uuid`。 | 2D 端子在 FreeCAD 中能找到对应工程端子。 |
| 设备与 3D 资产映射 | 已完成第一版 | 必须 | QET 侧解析设备绑定资产FreeCAD 侧读取 `resolved_model_path`。 | QET 绑定的 FCStd/STEP 资产能被 FreeCAD 正确导入。 |
| 场景实例保存 | 部分完成 | 必须 | 第一版不把 3D 位姿放数据库,以 `scene.FCStd` 为准。 | 设备移动后保存 `scene.FCStd`,重新打开位置不丢。 |
| STEP/IGES/FCStd 资产定义 | 部分完成 | 必须 | STEP/IGES 作为几何输入FCStd 作为正式电气 3D 资产。 | FCStd 能保存模型几何、模板端子、工程端子和布线对象。 |
| sidecar 元数据 | 未作为主链路 | 非必须 | 当前正式路线优先 FCStd LCS不建议第一版依赖 sidecar。 | 如后续启用sidecar 只能作为 FCStd 之外的兼容补充。 |
| 设备参数 | 部分完成 | 非第一版必须 | 属于参数化设备库能力。 | 能用参数生成不同规格设备模型。 |
| 安装规则 | 部分完成 | 后续必须 | 如安装到导轨、安装板、柜体。 | 设备知道自己安装在哪个宿主上,移动宿主时能跟随或校验。 |
| 连接点语义 | 已改为端子语义 | 必须 | 正式叫“端子”,分模板端子和工程端子。 | 工程端子能被选中接线,带端子 UUID、位置、方向和可接线属性。 |
### 3.2 FreeCAD 参数化设备建模能力开发
任务描述:
> 基于 FreeCAD Part/PartDesign 建立电气元件参数化建模模板支持断路器、继电器、端子排、导轨、线槽、柜体等常用结构生成支持模型复用、尺寸参数配置、STEP/IGES 导出。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| FreeCAD 手工建模 | FreeCAD 原生可用 | 非必须二开 | 直接使用 FreeCAD Part/PartDesign。 | CAD 人员能手工建模。 |
| STEP/IGES 模型导入 | FreeCAD 原生可用,项目已接入第一版 | 必须 | 用于把厂家模型导入后保存为 FCStd。 | STEP/STP/IGES 能导入并继续添加模板端子。 |
| 给模型添加模板端子 | 已完成第一版 | 必须 | 当前端子和布线的基础。 | 用户能在模型上创建模板端子并保存为 FCStd。 |
| 断路器/继电器/端子排参数化生成 | 未完整完成 | 非当前必须 | 属于后续设备库效率能力。 | 输入极数、宽度、高度、端子数等参数后自动生成模型。 |
| 导轨/线槽/柜体参数化生成 | 部分完成 | 后续建议 | 当前已有部分基础资产和布线载体能力。 | 能按长度、宽度、高度生成导轨、线槽、柜体模型。 |
| 模型复用 | 已完成第一版 | 必须 | 复用 FCStd 模板。 | 同一个 FCStd 可在不同工程中作为设备资产使用。 |
| 尺寸参数配置 | 部分完成 | 后续建议 | 可以用 FreeCAD Spreadsheet/Expression 或 Python 生成器。 | 不改代码即可生成不同规格模型。 |
| STEP/IGES 导出 | FreeCAD 原生可用 | 非当前必须 | 主要用于对外交付几何。 | 能导出标准 STEP/IGES 文件。 |
### 3.3 3D 资产绑定与导入管理开发
任务描述:
> 在 QET 设备库中支持绑定 FreeCAD 生成模型或外部 STEP/IGES/STL 资产;提供资产路径解析、版本记录、缺失诊断、重新加载、模型元数据读取能力。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| QET 设备绑定 3D 资产 | 已完成第一版,主要在 QET 侧 | 必须 | FreeCAD 侧消费导出的模型路径。 | QET 设备能绑定 FCStd/STEP 资产。 |
| 资产路径解析 | 已完成第一版 | 必须 | `2d_to_3d.json` 提供 `resolved_model_path`。 | FreeCAD 能找到本地真实模型文件。 |
| FCStd 导入 | 已完成第一版 | 必须 | 当前正式电气 3D 资产格式。 | 导入后能读取模型、模板端子。 |
| STEP/IGES/STL 导入 | 部分完成 | 必须 STEP/IGESSTL 非重点 | STEP/IGES 只作为几何输入STL 不适合作为电气语义资产。 | 文件能进入 FreeCAD必要时转存 FCStd。 |
| 版本记录 | 部分完成 | 后续建议 | 至少记录文件 hash、更新时间、模板版本。 | 模型变更后能提示需要重新加载或重新生成端子。 |
| 缺失诊断 | 部分完成 | 必须 | 找不到模型文件时给出明确提示。 | 用户能知道哪个设备缺少 3D 模型文件。 |
| 重新加载 | 部分完成 | 后续建议 | 模型文件变更后刷新场景实例。 | 重新绑定或替换资产后FreeCAD 能更新对应设备。 |
| 模型元数据读取 | 已完成第一版 | 必须 | 当前重点读取模板端子 LCS。 | 能读取端子槽位、端子类型、坐标、方向、局部出线路径。 |
### 3.4 复杂设备结构装配开发
任务描述:
> 构建机柜、安装板、DIN 导轨、线槽、设备实例的 3D 场景装配能力;支持设备拖放、吸附、对齐、旋转、偏移、安装宿主绑定和装配约束保存。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 机柜导入 | 部分完成 | 必须 | 机柜可作为场景背景和布线范围。 | QET 选择机柜后 FreeCAD 能打开对应场景。 |
| 安装板、导轨、线槽导入 | 部分完成 | 必须 | 导轨和线槽后续作为安装/布线参考。 | 能在场景中识别并显示这些载体。 |
| 设备实例装配 | 部分完成 | 必须 | 第一版允许手动摆放。 | 设备实例能放入机柜场景并保存位置。 |
| 拖放、旋转、偏移 | FreeCAD 原生可用,项目部分接入 | 必须 | 第一版可依赖 FreeCAD 原生变换。 | 用户能移动和旋转设备,保存后不丢。 |
| 吸附、对齐 | 部分完成 | 后续建议 | 需要自定义电气柜装配命令。 | 设备能自动贴合导轨、安装板或线槽边界。 |
| 安装宿主绑定 | 未完整完成 | 后续必须 | 如设备绑定到某根 DIN 导轨。 | 宿主移动时设备关系可追踪,校验时知道设备安装位置是否合法。 |
| 装配约束保存 | 部分完成 | 后续必须 | 当前主要保存 FreeCAD 位姿,不是完整约束系统。 | 重新打开后不只是位置在,还能知道设备为什么在这里。 |
### 3.5 3D 视图导航功能开发
任务描述:
> 实现 3D 视图缩放、平移、旋转、前/顶/左/右等轴测视角切换、选择高亮、实例聚焦、相机状态保存。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 缩放、平移、旋转 | FreeCAD 原生可用 | 必须但不需要二开 | 使用 FreeCAD 自带 3D 视图。 | 用户能正常查看场景。 |
| 轴测视角切换 | FreeCAD 原生可用 | 必须但不需要二开 | 使用 FreeCAD 视图命令。 | 用户能切换前/顶/左/右/等轴测视角。 |
| 选择高亮 | FreeCAD 原生可用 | 必须但不需要二开 | 端子和导线对象需要可选择。 | 选择对象时能明显看到目标。 |
| 实例聚焦 | 部分完成 | 后续建议 | 可做“定位到设备/端子/导线”命令。 | 从任务或树节点能快速定位目标。 |
| 相机状态保存 | 未完整完成 | 非第一版必须 | 属于使用体验增强。 | 重新打开工程后恢复上次视角。 |
### 3.6 2D 到 3D 单向联动开发
任务描述:
> 根据 QET 原理图/布置图中的设备、端子、柜体、导轨、线槽信息,单向生成或更新 3D 场景实例。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| QET 导出设备 | 已完成第一版 | 必须 | 导出 `devices[]`。 | FreeCAD 能得到设备实例列表。 |
| QET 导出端子 | 已完成第一版但需稳定 | 必须 | 导出 `devices[].terminals[]`。 | FreeCAD 能知道端子属于哪个设备实例。 |
| QET 导出资产路径 | 已完成第一版 | 必须 | 导出 `device_models[]`。 | FreeCAD 能找到设备 3D 模型。 |
| FreeCAD 生成/更新设备 | 已完成第一版 | 必须 | 按 `instance_id` 复用或创建设备组。 | 多次打开不会重复生成混乱设备。 |
| FreeCAD 生成/更新工程端子 | 已完成第一版 | 必须 | 依赖 FCStd 模板端子。 | 工程端子落在设备正确端子位置。 |
| 柜体、导轨、线槽联动 | 部分完成 | 后续必须 | 当前不应阻塞端子和手动布线。 | QET 中柜体/载体信息能稳定进入 FreeCAD 场景。 |
### 3.7 3D 布线基础能力开发
任务描述:
> 基于 3D 连接点和端子映射,支持线路路径采集、手动布线路径编辑、路径叠加显示、线槽/导轨空间参考,为后续自动布线预留接口。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 工程端子可被选中接线 | 已完成第一版 | 必须 | 手动布线只连接工程端子。 | 不能误选模板端子或普通几何作为接线端。 |
| 线路路径采集 | 已完成第一版 | 必须 | 起点、终点、手动路径点。 | 能记录一条导线的完整路径点。 |
| 手动布线路径编辑 | 已完成第一版 | 必须 | 添加路径点、更新导线。 | 用户能调整导线走向。 |
| 路径叠加显示 | 已完成第一版 | 必须 | 在 FreeCAD 场景中生成可见导线。 | 导线几何可见,能区分起终点。 |
| 线槽/导轨空间参考 | 部分完成 | 后续必须 | 自动布线需要,手动布线先可弱化。 | 线槽/导轨能作为布线候选路径或参考对象。 |
| 自动布线接口 | 已完成第一版 | 后续必须 | 为 qdj 自动布线提供端子、导线任务、路径网络基础。 | 自动布线可以复用工程端子和路径载体。 |
## 4. 三期三维布线功能交付
### 4.1 布线数据模型设计
任务描述:
> 定义端子、设备、线缆的数据结构与接口规范。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 设备数据结构 | 已完成第一版 | 必须 | `QETDevice_xxx` 设备组保存实例信息。 | 可按 `instance_id` 找到设备。 |
| 工程端子数据结构 | 已完成第一版 | 必须 | LCS 对象保存端子语义。 | 可按 `terminal_uuid` 找到工程端子。 |
| 导线任务数据结构 | 已完成第一版 | 必须 | 从 QET `wires[]` 导入。 | 任务能描述起点端子、终点端子、线号等。 |
| 已布导线数据结构 | 已完成第一版 | 必须 | FreeCAD 对象保存起终点、路径点、线长、状态。 | 保存后重开不丢,能参与回写。 |
| 线缆/多芯线数据结构 | 未完整完成 | 后续建议 | 比单根导线更复杂。 | 支持线缆、芯线、屏蔽层等关系。 |
### 4.2 基础数据解析开发
任务描述:
> 开发 3D 场景中设备、端子数据的读取与解析模块。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 读取设备实例 | 已完成第一版 | 必须 | 从 FreeCAD 文档中扫描 QET 设备组。 | 能列出当前场景设备。 |
| 读取工程端子 | 已完成第一版 | 必须 | 扫描设备下的工程端子组。 | 能按设备和端子 UUID 建索引。 |
| 读取模板端子 | 已完成第一版 | 必须 | 从 FCStd 模板 LCS 读取槽位。 | 工程端子能参考模板端子位置生成。 |
| 读取导线任务 | 已完成第一版 | 必须 | 从 `2d_to_3d.json``wires[]` 导入。 | 能得到待布线起点和终点。 |
| 端子数据稳定匹配 | 部分完成 | 必须 | 依赖 QET 提供稳定 `terminal_uuid`,可增加 `slot_name_hint`。 | 同一设备多个端子不会错位或串线。 |
### 4.3 智能连接识别算法开发
任务描述:
> 实现端子与线缆、设备接口的自动匹配识别逻辑。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 按 `terminal_uuid` 找端子 | 已完成第一版 | 必须 | 最稳定的识别方式。 | 给定端子 UUID 能找到唯一工程端子。 |
| 按槽位提示匹配模板端子 | 已完成第一版 | 必须 | 使用 `slot_name_hint``terminal_label` 辅助。 | P1/P2/A1/A2 等端子能落到正确模板槽位。 |
| 手动选择两个端子识别连接 | 已完成第一版 | 必须 | 手动布线场景使用。 | 用户选两个工程端子即可生成导线。 |
| 自动识别线缆与设备接口 | 部分完成 | 后续建议 | 属于自动布线和智能匹配。 | 系统能从导线任务自动找到起终点端子。 |
| 复杂接线关系纠错 | 未完成 | 后续建议 | 需要电气规则和 QET 数据完整支持。 | 能提示端子不匹配、线缆类型不匹配等。 |
### 4.4 更新 BOM 并生成取线表
任务描述:
> 自动更新线长、线表和 BOM 数据,生成生产用取线表。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 线长计算 | 部分完成 | 建议第一版保留字段 | 可根据导线路径点计算长度。 | 每根导线有可回写的长度。 |
| 线表生成 | 未完整完成 | 后续必须 | 需要 QET 侧线号、线型、颜色、截面积等数据。 | 能导出起点、终点、线号、长度、规格。 |
| BOM 更新 | 未完成 | 后续必须 | 需要接入 QET BOM 或物料系统。 | 导线、端子附件、线槽等物料可进入 BOM。 |
| 取线表 | 未完成 | 后续必须 | 面向生产下线。 | 可按柜体、线号、长度批量输出。 |
### 4.5 电气布线规则梳理
任务描述:
> 明确布线的电气规范,如线距、转角、分层规则等。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 基础线距/避障规则 | 部分完成 | 必须基础版 | 自动布线已有部分间距参数。 | 导线不会明显穿过设备或柜体。 |
| 转角规则 | 部分完成 | 建议 | 当前可用正交路径表达。 | 路径转角符合甲方要求。 |
| 分层规则 | 未完整完成 | 后续必须 | 如强弱电、不同电压等级分层。 | 不同线缆类别按规则走不同区域。 |
| 线槽容量规则 | 部分完成 | 后续必须 | 当前已有容量/复用思路,但未完整产品化。 | 超容量时提示或改道。 |
| 甲方规范配置 | 未完成 | 必须依赖甲方 | 规则需要甲方确认。 | 规则可配置、可验收。 |
### 4.6 路径规划算法开发
任务描述:
> 基于规则实现自动生成无碰撞、合规的布线路径。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 端子到端子路径生成 | 已完成第一版 | 后续自动布线必须 | 自动布线使用工程端子作为起终点。 | 能从起点端子生成到终点端子的 3D 路径。 |
| 正交路径生成 | 已完成第一版 | 必须基础版 | 符合柜内布线常见表达。 | 导线由水平/垂直/深度方向线段组成。 |
| 线槽/路径网络布线 | 已完成第一版 | 后续必须 | 依赖 RoutingNetwork。 | 导线能优先走线槽或用户定义路径。 |
| 避障 | 部分完成 | 后续必须 | 已有包围盒避障和碰撞检查。 | 不穿设备、不穿柜体、不超出布线区域。 |
| 合规评分与择优 | 部分完成 | 后续必须 | 当前仍需优化。 | 多条候选路径中选择更符合规则的一条。 |
### 4.7 算法性能优化与测试
任务描述:
> 优化路径生成效率,处理复杂场景下的布线稳定性问题。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 单根导线性能 | 部分完成 | 必须 | 当前先保证单根或少量导线可用。 | 单根导线生成不卡顿。 |
| 批量导线性能 | 未完整完成 | 后续必须 | 大量导线需要缓存和分批处理。 | 大工程批量布线耗时可接受。 |
| 复杂场景稳定性 | 未完整完成 | 后续必须 | 需要机柜、设备、线槽组合测试。 | 多设备、多线槽、多导线时不崩溃、不乱连。 |
| 自动测试用例 | 未完整完成 | 后续必须 | 需要固定样例工程。 | 每次修改后能跑回归测试。 |
### 4.8 手动调整功能开发
任务描述:
> 实现布线路径的拖拽、修改、撤销/重做等交互功能。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 设置起点端子 | 已完成第一版 | 必须 | 从选择对象中识别工程端子。 | 起点不是工程端子时给出提示。 |
| 设置终点并生成 | 已完成第一版 | 必须 | 连接两个工程端子。 | 生成导线对象并保存起终点 UUID。 |
| 添加手动路径点 | 已完成第一版 | 必须 | 控制导线走向。 | 导线按用户路径点生成。 |
| 修改已布导线 | 部分完成 | 必须 | 对已有导线重新生成。 | 调整后不重复生成错误导线。 |
| 拖拽路径点 | 部分完成 | 后续建议 | 更偏 UI 体验。 | 通过鼠标拖拽调整导线路径。 |
| 撤销/重做 | 部分依赖 FreeCAD 原生 | 后续建议 | 可先依赖 FreeCAD 文档操作栈。 | 用户误操作可以恢复。 |
### 4.9 实时错误检查逻辑开发
任务描述:
> 开发布线冲突、连接错误、电气规范违规的实时检测。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| 非端子选择检查 | 已完成第一版 | 必须 | 手动布线时必须限制对象类型。 | 选错对象时不给生成导线。 |
| 起终点相同检查 | 已完成第一版 | 必须 | 避免自连接。 | 同一端子不能生成导线。 |
| 缺失端子检查 | 部分完成 | 必须 | 自动布线和任务导入都需要。 | 找不到端子时给出明确原因。 |
| 碰撞检查 | 部分完成 | 后续必须 | 当前已有碰撞/间隙诊断雏形。 | 导线与设备冲突时能提示。 |
| 电气规则违规检查 | 未完整完成 | 后续必须 | 依赖完整规则库。 | 强弱电混走、线径不匹配等可检测。 |
| 真正实时检查 | 部分完成 | 后续建议 | 当前更接近命令执行后检查。 | 用户调整路径时实时刷新错误提示。 |
### 4.10 整体联调与验收
任务描述:
> 全流程功能联调,修复 bug 并完成验收测试。
| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 |
| --- | --- | --- | --- | --- |
| QET 到 FreeCAD 打开链路 | 已完成第一版 | 必须 | QET 3D 视图打开 FreeCAD。 | 能打开对应工程和场景文件。 |
| 设备导入链路 | 已完成第一版 | 必须 | FreeCAD 根据 QET 数据导入设备。 | 设备模型能正常显示。 |
| 端子生成链路 | 已完成第一版但需继续稳定 | 必须 | 模板端子生成工程端子。 | 工程端子位置准确,不错位。 |
| 手动布线链路 | 已完成第一版 | 必须 | 两个工程端子生成导线。 | 保存重开后导线不丢。 |
| 自动布线链路 | 部分完成 | 后续必须 | qdj 负责继续完善。 | 批量线缆能按规则生成路径。 |
| BOM/取线表链路 | 未完成 | 后续必须 | 需要 QET 侧数据和导线长度回写。 | 可生成生产可用线表。 |
| 验收测试 | 未完成 | 必须 | 需要固定工程和操作用例。 | 每项任务有可演示、可复现的验收步骤。 |
## 5. 当前 zwl 应优先负责的范围
当前 zwl 的重点不是整张任务表,而是下面这条 FreeCAD 端子和手动布线链路:
```text
FCStd 模板端子
-> 工程端子生成
-> 工程端子显示/选择
-> 手动连接两个工程端子
-> 生成 3D 导线
-> 保存到 scene.FCStd
-> 生成 3d_to_2d.json 回写
```
### 5.1 zwl 当前必须做
| 功能 | 说明 | 完成标准 |
| --- | --- | --- |
| 模板端子规范 | 定义设备 FCStd 内哪里可以接线。 | STEP/FCStd 模型能保存模板端子、槽位名和出线方向。 |
| 模型元数据读取 | FreeCAD 读取 FCStd 内模板端子。 | 导入设备后能读取模板端子位置和方向。 |
| 工程端子生成 | 从模板端子生成项目内可接线端子。 | 工程端子落在设备正确位置。 |
| 端子全局坐标计算 | 设备移动/旋转后仍能得到正确端子位置。 | 导线起终点跟随设备变化。 |
| 手动布线 | 选两个工程端子生成导线。 | 导线几何正确,起终点 UUID 正确。 |
| 手动路径点 | 支持用户控制导线路径。 | 路径点保存后重开不丢。 |
| 导线保存 | 导线对象保存到 `scene.FCStd`。 | 关闭重开工程,导线仍存在。 |
| 3D 回写 | 生成 `3d_to_2d.json`。 | 回写设备实例、端子绑定、导线基础数据。 |
| 基础错误检查 | 检查选错对象、缺失端子、起终点相同。 | 错误操作有明确提示,不生成错误导线。 |
### 5.2 zwl 当前不应作为主线做
| 功能 | 原因 |
| --- | --- |
| 完整参数化设备库 | 当前可先用已有 STEP/FCStd 资产加端子。 |
| 断路器/继电器/端子排通用参数模板 | 属于提高资产制作效率的后续能力。 |
| QET 设备库资产绑定界面 | 主要在 QET 侧。 |
| 完整复杂装配约束 | 当前只要求端子和导线稳定。 |
| 完整自定义 3D 视图导航 | FreeCAD 原生能力够第一版使用。 |
| 自动布线核心算法 | qdj 负责,依赖 zwl 的工程端子和手动布线基础。 |
| BOM/取线表 | 后续基于导线长度和端子回写再做。 |
| 完整电气规则库 | 需要甲方规则和整体设计。 |
## 6. 推荐开发顺序
### 阶段 1端子资产基础
目标:让一个普通 STEP 模型变成可接线的 FCStd 电气资产。
开发顺序:
1. 完善模板端子创建和校验。
2. 明确模板端子属性:槽位名、端子类型、是否可接线、出线方向。
3. 支持把模板端子的局部出线路径保存进 FCStd。
4. 保存为可复用 FCStd 设备模板。
验收:
- 打开一个 STEP 电流互感器模型。
- 添加 P1/P2 等模板端子。
- 保存为 FCStd。
- 重新打开后模板端子仍然存在,属性不丢。
### 阶段 2工程端子生成
目标QET 打开 FreeCAD 后,设备实例上有可用于接线的工程端子。
开发顺序:
1. 导入 QET 设备实例。
2. 读取设备 FCStd 内模板端子。
3. 根据 QET `terminal_uuid` 生成工程端子。
4. 如果 QET 没有端子 UUID支持生成 `local:*` 本地工程端子用于 3D 验证。
5. 设备移动/旋转后,工程端子全局坐标正确。
验收:
- 一个设备实例能生成工程端子。
- 两个同型号设备实例的工程端子分别落在各自设备上。
- 移动设备后,端子位置跟随变化。
### 阶段 3手动布线
目标:用户能连接两个工程端子。
开发顺序:
1. 选择工程端子作为起点。
2. 选择工程端子作为终点。
3. 根据端子全局坐标生成导线。
4. 支持添加手动路径点。
5. 保存导线属性:起点端子 UUID、终点端子 UUID、路径点、线长、状态。
6. 提供基础错误提示。
验收:
- 选两个端子能生成一根导线。
- 不能连接模板端子或普通几何。
- 起终点不能相同。
- 导线显示正确,不乱连到已有导线。
### 阶段 4保存和回写
目标3D 布线结果可以保存和交还 QET。
开发顺序:
1. 导线、端子、设备保存在 `scene.FCStd`
2. 重新打开工程时恢复端子和导线。
3. 生成 `3d_to_2d.json`
4. 回写设备实例、端子绑定、导线基础信息。
5. 跳过本地端子或无法可靠回写的端子,并给出说明。
验收:
- 保存关闭再打开,导线不丢。
- `3d_to_2d.json` 中能看到实例、端子和导线信息。
- QET 能消费回写结果或至少不报错。
### 阶段 5基础诊断和联调
目标:让 CAD 人员使用时不会因为错误操作把工程弄乱。
开发顺序:
1. 检查缺失模型。
2. 检查设备没有模板端子。
3. 检查端子 UUID 缺失。
4. 检查工程端子错位。
5. 检查导线起终点无效。
6. 输出清晰提示。
验收:
- 出错时能告诉用户是哪台设备、哪个端子、什么原因。
- 不产生半成品错误对象。
### 阶段 6装配和布线增强
目标:提升机柜内真实装配和走线质量。
开发顺序:
1. 设备吸附到导轨/安装板。
2. 保存安装宿主关系。
3. 线槽/导轨作为布线载体。
4. 导线优先沿线槽或用户路径走线。
5. 增加碰撞和越界检查。
验收:
- 设备能稳定放在导轨或安装板上。
- 导线能沿线槽走,不明显穿设备。
### 阶段 7自动布线、BOM 和取线表
目标:在手动布线稳定后,扩展自动化生产能力。
开发顺序:
1. qdj 基于工程端子和布线载体开发自动布线。
2. 引入甲方电气布线规则。
3. 批量生成导线。
4. 计算线长。
5. 回写线表、BOM、取线表。
6. 做大工程性能优化和验收测试。
验收:
- 一批导线能自动生成。
- 有冲突能提示。
- 能输出生产可用取线表。
## 7. 第一版最小交付清单
第一版不要求完成整张任务表,只要先完成下面这些:
1. FCStd 模板端子制作。
2. FCStd 模板端子读取。
3. 工程端子生成。
4. 工程端子选择。
5. 两个工程端子生成手动导线。
6. 手动路径点保存。
7. 导线保存到 `scene.FCStd`
8. 重新打开不丢端子和导线。
9. 生成 `3d_to_2d.json`
10. 基础错误提示。
完成这 10 项,就可以对外说:
> FreeCAD 端子显示、端子手动连线、保存回写第一版完成。
## 8. 甲方资料补充分析
资料来源:
- `D:\video\甲方视频\3D布线功能需求开发文档.docx`
- `D:\video\甲方视频\3D布线KYN28-12操作教程(终版).mp4`
- `D:\video\甲方视频\甲方布线操作.mp4`
- `D:\video\甲方视频\保护装置模型视频.mp4`
- `D:\video\甲方视频\面板装配、路径绘制、导轨配合038F3440.MP4`
- `D:\downloadWX\xwechat_files\wxid_pv577xuccot722_5d4a\msg\file\2026-06\20260601_10365403921E50.MP4`
甲方当前参考软件是 SOLIDWORKS Electrical 3D。设计时也应参考 EPLAN Pro Panel 的电气柜 3D 安装布局和布线思想。
### 8.1 甲方需求归纳
Word 需求文档中核心需求可以归纳为 6 类:
| 序号 | 甲方需求 | 对应我们系统的含义 | 当前优先级 |
| --- | --- | --- | --- |
| 1 | 三维模型读取与关系配合 | 导入柜体、安装板、面板、设备,并能做装配配合。 | 高 |
| 2 | 配合面定义与快速装配 | 设备能快速安装到导轨、安装板、面板等基准上,支持端子排、小型断路器批量插入。 | 中高 |
| 3 | 三维零件建模与电气脚点定义 | STEP/FCStd 设备需要补电气端子,端子带脚号、方向、可接线属性。 | 高 |
| 4 | 草图路径自定义与布线规则 | 用户可在 3D 空间画线段/曲线作为布线路径,导线可沿这些路径或区域走线。 | 高 |
| 5 | 取线表数据生成与导出 | 从布线结果生成线长、线缆规格、颜色、线耳、源/目标设备脚号等生产数据。 | 后续高 |
| 6 | 错误自检与线束高亮提示 | 检查缺失、未连接、路径冲突,选中线束时高亮并提示源/目标信息。 | 中高 |
### 8.2 从视频看到的实际操作意图
| 视频 | 观察到的重点 | 对我们开发的启发 |
| --- | --- | --- |
| `面板装配、路径绘制、导轨配合038F3440.MP4` | 安装板/面板是柜体结构的一部分,通过面、边、孔或基准与柜体配合;导轨、端子排、设备再装到面板上。 | FreeCAD 侧需要把安装板/面板作为“结构载体对象”,支持配合面、安装面、孔位参考和路径对象。 |
| `甲方布线操作.mp4` | 先插入设备、打开柜体或装配体,再做设备端子、路径绘制、布线效果展示。 | 我们应先确保设备、工程端子、路径对象、导线对象这四类对象的语义稳定。 |
| `3D布线KYN28-12操作教程(终版).mp4` | 有保存在线缆工程目录、装配完成状态、草图路径、端子排线槽配合、最终布线效果。 | 路径草图和线槽/导轨配合不是装饰几何,应成为布线网络或布线参考。 |
| `保护装置模型视频.mp4` | 保护装置模型带前面板、后部结构和端子区域。 | 设备模板端子不能只按包围盒猜点,必须由模板端子或连接点模式明确指定。 |
| `20260601_10365403921E50.MP4` | 显示机柜内端子排、导轨、线槽、局部装配和设备位置参考。 | 蓝色 CAD 参考线或草图线应区别于最终布线导线,作为安装/路径参考。 |
### 8.3 SW/EPLAN 对标结论
SOLIDWORKS Electrical 3D 的核心思想:
- 2D 电气原理图和 3D 柜体布局联动。
- 3D 中管理设备布局,并布置 wires、cables、harnesses。
- 通过 routing paths / ducts 引导线缆路径。
- routing 完成后更新线长,并可生成包含长度的报表。
- routing cables 时需要 origin / destinationrouting wires 时起终点来自电气图。
EPLAN Pro Panel 的核心思想:
- 在布局空间中放置柜体、安装板、安装导轨、电缆槽和设备。
- 机械元件可以有安装面,设备放在安装面或导轨上。
- 3D 部件放置上的连接点可以图形化显示,并显示连接点方向。
- 布线需要布线路径网络、布线范围、连接点方向和待布线连接。
- 已布线连接会计算长度,可用于生产和报表。
对我们项目的结论:
```text
安装板/面板/导轨/线槽
不只是普通模型
-> 应该成为 3D 装配和布线的结构载体
设备端子
不只是几何点
-> 应该成为带 terminal_uuid、槽位名、方向、脚号的工程端子
蓝色/彩色草图路径
不应该当作最终导线
-> 应该作为布线路径网络、布线范围或安装参考
最终导线
必须连接两个工程端子
-> 保存起点、终点、路径、线长、规格、状态
```
### 8.4 对当前 FreeCAD 开发的影响
| 功能 | 是否 FreeCAD 原生已有 | 我们要做什么 |
| --- | --- | --- |
| 导入柜体、安装板、导轨、线槽 | FreeCAD 原生能导入几何 | 给这些对象补业务类型,如 Cabinet、MountingPlate、DinRail、WireDuct、RoutingPath。 |
| 装配配合 | FreeCAD 有基础移动/约束能力,但不是电气柜业务规则 | 做轻量配合语义:安装面、宿主、局部坐标、吸附结果保存。 |
| 设备放置 | FreeCAD 能移动设备 | 增加“设备属于哪个安装面/导轨”的语义。 |
| 设备端子 | FreeCAD 没有电气端子语义 | 用 FCStd LCS 模板端子和工程端子实现。 |
| 连接点方向 | FreeCAD 有坐标系方向 | 规定 LCS 本地 +Z 为出线方向,并在布线时使用。 |
| 草图路径 | FreeCAD 能画草图/线段 | 把草图/边/线转换为 RoutingPath供布线算法使用。 |
| 布线范围 | FreeCAD 没有电气布线范围语义 | 需要新增 RoutingArea 或 CabinetRoutingZone 概念。 |
| 线长/取线表 | FreeCAD 没有 QET 生产数据 | FreeCAD 计算路径长度QET 或后处理生成生产取线表。 |
| 错误自检 | FreeCAD 没有电气规则检查 | 我们需要检查缺失端子、路径断开、碰撞、越界、线槽容量等。 |
### 8.5 需要 QET 侧配合的内容
以下内容如果涉及改 QET 代码,需要找 QET 对应开发者配合:
| QET 侧能力 | 为什么需要 | FreeCAD 侧依赖 |
| --- | --- | --- |
| 稳定导出设备 `element_uuid``instance_id` | FreeCAD 用它找到设备实例。 | 设备导入和回写。 |
| 稳定导出端子 `terminal_uuid` | FreeCAD 工程端子绑定 2D 端子的唯一依据。 | 工程端子生成、导线起终点绑定。 |
| 导出端子显示名或槽位提示 | 防止同一设备多个端子顺序错位。 | `slot_name_hint` / `terminal_label` 匹配模板端子。 |
| 导出导线任务 `wires[]` | 自动布线和待布线列表需要。 | 起点端子、终点端子、线号、线型。 |
| 导出线缆规格、颜色、截面积 | 取线表和导线显示需要。 | 线长、线色、线径、线耳。 |
| QET 设备库绑定 FCStd 资产 | FreeCAD 需要知道每个设备用哪个 3D 模型。 | 资产导入。 |
| QET 消费 `3d_to_2d.json` | FreeCAD 回写结果要进入 QET。 | 实例绑定、端子绑定、线长/布线结果。 |
| 取线表格式定义 | 最终要给终端取线机读取。 | FreeCAD 只提供线长和路径基础数据。 |
### 8.6 新增建议开发顺序
结合甲方资料,推荐把后续顺序调整为:
1. 端子模板和工程端子稳定。
2. 手动布线和保存回写稳定。
3. 草图/边/线转换为 `RoutingPath`
4. 安装板/面板/导轨/线槽标记为结构载体。
5. 设备和结构载体建立轻量配合关系。
6. 自动布线使用 `RoutingPath`、线槽、布线范围。
7. 错误自检和高亮。
8. 线长、线缆属性、取线表。
其中第 1、2 项是 zwl 当前主线;第 6 项已经在另一个会话中推进;第 8 项需要 QET 和生产数据格式共同确定。
### 8.7 第一版建议不要扩大到的范围
为了避免 FreeCAD 侧失控,第一版暂不建议做:
- 完整替代 SW 的机械装配 Mate 系统。
- 完整参数化设备库。
- 完整自动端子排生成和编号规则。
- 完整取线机格式导出。
- 完整 EPLAN 级规则库。
第一版只要把下面链路跑通:
```text
FCStd 电气资产
-> 模板端子
-> 工程端子
-> 结构载体/布线路径参考
-> 手动或自动布线
-> 线长和错误诊断
-> scene.FCStd + 3d_to_2d.json
```
### 8.8 甲方取线表样例分析
取线表样例:
- `D:\downloadWX\xwechat_files\wxid_pv577xuccot722_5d4a\msg\file\2026-04\PT2柜取线表.xlsx`
工作簿结构:
| 工作表 | 行列情况 | 说明 |
| --- | --- | --- |
| `线束组件编码` | 1 行 1 列 | 当前样例中只有标题。 |
| `取线表` | 528 行 52 列 | 真实取线表数据。 |
主要字段可以分成 5 类:
| 字段类别 | 代表字段 | 来源判断 | 说明 |
| --- | --- | --- | --- |
| 连接两端信息 | `号码管字符1`、`号码管方向1`、`线鼻子型号1`、`剥皮长度1`、`号码管字符2`、`号码管方向2`、`线鼻子型号2`、`剥皮长度2` | QET 为主FreeCAD 可补位置/区域 | 这些是电气连接和生产加工字段,不能只靠 3D 几何推断。 |
| 导线规格信息 | `导线型号`、`导线颜色`、`导线截面积mm2` | QET 为主 | 来自 2D 图纸、导线样式、线缆规则或物料数据。 |
| 线长信息 | `导线长度(mm)`、`加工总数` | FreeCAD 计算QET 汇总 | FreeCAD 按 3D 路径计算实际长度QET 或后处理计算生产数量和汇总。 |
| 生产数量信息 | `生产总数`、`单批数量`、`套数`、`加工总数` | QET/生产系统为主 | 依赖项目数量、批次、套数,不应由 FreeCAD 单独决定。 |
| 区域和线束编号 | `区域`、`始端区域`、`末端区域`、`分线束编号`、`总线束编号`、`小区域` | QET + FreeCAD | QET 知道柜体/图纸区域FreeCAD 可根据 3D 设备所在柜内区域辅助计算。 |
从样例看,取线表最终需要的不只是“线长”,而是一条完整生产记录:
```text
起点设备/端子/号码管/线鼻子
+ 终点设备/端子/号码管/线鼻子
+ 导线型号/颜色/截面积
+ 3D 路径计算线长
+ 所属区域/线束编号/生产数量
```
因此推荐职责边界:
| 职责 | FreeCAD 侧 | QET 侧 |
| --- | --- | --- |
| 端子空间位置 | 负责 | 消费结果或仅保存绑定 |
| 实际 3D 走线路径 | 负责 | 可读取回写 |
| 线长计算 | 负责 | 汇总、修正、导出 |
| 线号/号码管字符 | 不负责生成,最多回传关联端子 | 负责 |
| 导线型号/颜色/截面积 | 不负责主数据 | 负责 |
| 线鼻子/剥皮长度 | 不负责主数据 | 负责 |
| 区域/柜体/小区域 | 可根据 3D 空间辅助判断 | 负责主数据和最终分类 |
| 取线表格式导出 | 可提供基础 JSON | 负责最终 Excel/取线机格式 |
### 8.9 当前 JSON 交换样例分析
当前工程交换目录示例:
```text
D:\test\MT\电气工程4.0\电气工程414\电气工程414\.qet_freecad
2d_to_3d.json
3d_to_2d.json
```
当前 `2d_to_3d.json` 统计:
| 内容 | 数量 | 说明 |
| --- | --- | --- |
| `devices[]` | 86 | QET 导出的 2D 设备实例和端子上下文。 |
| `device_models[]` | 85 | QET 解析出的 3D 资产路径。 |
| `wires[]` | 75 | QET 导出的导线任务。 |
| `stale_devices[]` | 27 | 已失效或不在当前快照中的设备。 |
| `cabinet` | 1 | 当前图纸绑定的机柜上下文。 |
当前 `wires[]` 已有字段示例:
```json
{
"start_element_uuid": "...",
"start_terminal_uuid": "...",
"start_terminal_display": "12",
"end_element_uuid": "...",
"end_terminal_uuid": "...",
"end_terminal_display": "21",
"wire_id": "direction:...",
"wire_mark": "N4131",
"wire_mark_is_manual": false,
"wire_style_id": 3
}
```
这些字段已经足够支持“从 QET 导线任务找到 FreeCAD 工程端子并生成导线”。
但对取线表还不够,缺少:
| 取线表需要 | 当前 JSON 是否已有 | 建议责任 |
| --- | --- | --- |
| 导线型号 | 未直接看到,可能需通过 `wire_style_id` 回查 | QET 补充或回查 |
| 导线颜色 | 未直接看到,可能需通过 `wire_style_id` 回查 | QET 补充或回查 |
| 导线截面积 | 未直接看到,可能需通过 `wire_style_id` 回查 | QET 补充或回查 |
| 线鼻子型号 | 未看到 | QET 提供 |
| 剥皮长度 | 未看到 | QET 提供 |
| 号码管字符 | 部分可由 `wire_mark`、端子显示、设备信息组合 | QET 负责最终生成 |
| 3D 实际线长 | 当前 `2d_to_3d.json` 不应有,应该 FreeCAD 回写 | FreeCAD 计算 |
| 分线束编号/总线束编号 | 未看到 | QET/生产规则提供 |
| 起终点区域 | 当前有柜体上下文,但不完整 | QET 主导FreeCAD 可辅助空间判断 |
当前 `3d_to_2d.json` 统计:
| 内容 | 数量 | 说明 |
| --- | --- | --- |
| `instances[]` | 85 | FreeCAD 回写设备实例绑定。 |
| `terminals[]` | 138 | FreeCAD 回写端子绑定。 |
当前 `3d_to_2d.json` 尚未包含:
- 已布导线列表。
- 每根导线的 3D 路径点。
- 每根导线的实际长度。
- 碰撞/错误状态。
- 线缆/导线规格回传字段。
因此如果目标是最终生成甲方取线表,需要新增 FreeCAD 回写结构,建议命名为:
```json
{
"routed_wires": [
{
"wire_id": "string",
"wire_mark": "string",
"start_terminal_uuid": "string",
"end_terminal_uuid": "string",
"start_instance_id": "string",
"end_instance_id": "string",
"length_mm": 1234.5,
"route_status": "Routed",
"route_mode": "Manual",
"route_points": [],
"collision_count": 0,
"diagnostics": []
}
]
}
```
注意:`routed_wires[]` 只负责把 3D 布线结果回写给 QET。最终取线表中的导线型号、颜色、截面积、线鼻子、剥皮长度、号码管字符、区域、生产数量仍建议由 QET 根据主数据和生产规则生成。
### 8.10 后续需要 QET 开发者确认的问题
如果要从当前 3D 布线走到甲方取线表,需要 QET 侧确认:
1. `wire_style_id` 能否稳定回查导线型号、颜色、截面积。
2. 线鼻子型号、剥皮长度、导线半脱长度来自哪里。
3. 号码管字符当前生成规则是否已经在 QET 中存在。
4. 始端区域、末端区域、小区域、分线束编号、总线束编号的规则由谁生成。
5. QET 是否准备消费 FreeCAD 回写的 `routed_wires[]`
6. QET 最终是否负责导出甲方 Excel 取线表,还是 FreeCAD 直接导出。
推荐边界:
```text
FreeCAD
负责 3D 设备、端子、路径、线长、碰撞状态。
QET
负责 2D 电气连接、线号、线型、颜色、截面积、线鼻子、剥皮长度、区域、取线表格式。
```
## 9. 任务描述合理性修订标注
本章不覆盖原始任务表而是标注其中不够合理或容易误导开发范围的描述方便后续和项目负责人、QET 开发者、自动布线开发者对齐。
标注规则:
| 标记 | 含义 |
| --- | --- |
| 【建议修改】 | 原描述容易扩大范围或职责不清,建议修改。 |
| 【建议拆分】 | 一个任务里混入多类能力,建议拆成多个阶段。 |
| 【建议降级】 | 第一版不应承诺完整能力,只做基础版或依赖 FreeCAD 原生能力。 |
| 【职责需拆分】 | 该能力不能只由 FreeCAD 完成,需要 QET 或生产数据配合。 |
### 9.1 三期 3D 建模功能任务描述
| 原任务 | 合理性判断 | 建议改法 |
| --- | --- | --- |
| `3D 数据模型与映射规范开发`:基于 QET 设备、符号、端子、项目数据库,建立设备-3D资产-场景实例-端子连接点-2D图元映射关系定义 STEP/IGES/FCStd 资产、sidecar 元数据、设备参数、安装规则、连接点语义。 | 【建议拆分】这句话把第一版最小映射、资产格式、sidecar、参数、安装规则、连接点语义都混在一起范围过大。并且当前正式路线不建议用 `connectionPoint` 作为核心术语,也不建议第一版依赖 sidecar。 | 改为:`建立 QET 设备/端子与 FreeCAD 设备实例/工程端子的最小映射;定义 FCStd 电气资产、模板端子、工程端子和 3D 回写协议。STEP/IGES 仅作为几何输入sidecar、设备参数、安装规则作为后续扩展。` |
| `FreeCAD 参数化设备建模能力开发`:支持断路器、继电器、端子排、导轨、线槽、柜体等常用结构生成。 | 【建议降级】这相当于做一个完整参数化设备库,工作量很大,不应和端子/布线第一版绑死。 | 改为:`第一版支持 STEP/FCStd 资产加模板端子和少量基础载体模板;断路器、继电器、端子排、导轨、线槽、柜体的完整参数化生成作为设备库后续任务。` |
| `3D 资产绑定与导入管理开发`:支持绑定 FreeCAD 生成模型或外部 STEP/IGES/STL 资产。 | 【建议修改】STL 只有网格几何,不适合作为电气语义资产;正式电气资产应优先 FCStd。 | 改为:`优先支持 FCStd 电气资产STEP/IGES 作为几何输入STL/OBJ 只作为显示类或临时参考资产,不作为正式可接线设备资产。` |
| `3D 资产绑定与导入管理开发`:提供版本记录、缺失诊断、重新加载、模型元数据读取能力。 | 【职责需拆分】版本记录和资产绑定主要在 QET/设备库侧FreeCAD 侧负责读取模型语义和诊断导入状态。 | 改为:`QET 负责资产绑定、版本记录、路径解析FreeCAD 负责导入诊断、FCStd 模板端子读取、模型变更后的场景更新提示。` |
| `复杂设备结构装配开发`:支持设备拖放、吸附、对齐、旋转、偏移、安装宿主绑定和装配约束保存。 | 【建议修改】“装配约束”容易被理解为 SW Mate 级机械约束,第一版不现实。 | 改为:`第一版依赖 FreeCAD 原生移动/旋转,补充轻量电气装配语义:安装面、安装宿主、吸附结果、局部坐标。完整机械 Mate 系统不作为第一版目标。` |
| `3D 视图导航功能开发`:实现缩放、平移、旋转、视角切换、选择高亮。 | 【建议降级】这些是 FreeCAD 原生能力,不应作为大量二开任务。 | 改为:`复用 FreeCAD 原生视图能力;二开只做对象定位、端子/导线高亮、设备预览、错误对象聚焦。` |
| `2D 到 3D 单向联动开发`:根据原理图/布置图中的设备、端子、柜体、导轨、线槽信息,单向生成或更新 3D 场景实例。 | 【职责需拆分】设备、端子、导线任务可以由 QET 当前数据提供;导轨、线槽、安装板等结构载体未必来自 2D 原理图,需要明确数据源。 | 改为:`QET 第一版导出设备、端子、导线任务、3D 资产路径和柜体上下文FreeCAD 生成或更新设备、工程端子和导线任务。导轨、线槽、安装板可先由 FreeCAD 场景或机柜 FCStd 提供。` |
| `3D 布线基础能力开发`:基于 3D 连接点和端子映射。 | 【建议修改】当前正式术语应统一为“端子”,不要继续把 `连接点 connectionPoint` 作为核心。 | 改为:`基于 3D 工程端子和 2D terminal_uuid 映射,支持手动布线、路径点编辑、路径显示和自动布线接口。` |
### 9.2 三维布线功能任务描述
| 原任务 | 合理性判断 | 建议改法 |
| --- | --- | --- |
| `三期-3 维布线功能交付`:完成 3D 自动布线全链路功能开发,实现智能连接、规则化布线、手动调整与实时错误校验。 | 【建议拆分】自动布线全链路依赖端子、装配、布线路径、QET 导线任务、电气规则和取线表,不能作为一个单点任务承诺。 | 改为:`先完成工程端子、手动布线、路径保存和线长回写;再基于稳定端子和布线路径网络开发自动布线、规则检查和生产数据输出。` |
| `布线数据模型设计`:定义端子、设备、线缆的数据结构与接口规范。 | 基本合理,但应明确 FreeCAD/QET 边界。 | 改为:`QET 定义电气连接、线号、线型、规格FreeCAD 定义工程端子、布线路径、线长、碰撞状态和回写结构。` |
| `基础数据解析开发`:开发 3D 场景中设备、端子数据的读取与解析模块。 | 合理,但“端子数据没取对”这类问题通常不只在 FreeCAD可能是 QET 导出的端子和 FCStd 模板槽位不匹配。 | 增加说明:`解析模块需要同时校验 QET terminal_uuid、terminal_label/slot_name_hint 与 FCStd 模板端子槽位。` |
| `智能连接识别算法开发`:实现端子与线缆、设备接口的自动匹配识别逻辑。 | 【建议降级】第一版不应做复杂智能推断,必须先以 `terminal_uuid` 精确绑定为主。 | 改为:`第一版按 terminal_uuid 精确匹配slot_name_hint/terminal_label 只作模板槽位匹配提示;复杂智能识别作为后续。` |
| `更新BOM并生成取线表`:自动更新线长、线表和 BOM 数据,生成生产用取线表。 | 【职责需拆分】FreeCAD 只能可靠生成 3D 路径和线长;取线表中的线鼻子、剥皮长度、导线型号、颜色、区域、生产数量主要来自 QET/生产规则。 | 改为:`FreeCAD 回写 routed_wires[]包含线长、路径、起终点端子和状态QET 根据主数据生成 BOM、线表和取线表。` |
| `电气布线规则梳理`:明确布线的电气规范,如线距、转角、分层规则等,甲方提供。 | 合理,但它是算法开发的输入,不应等到后期才补。 | 调整为前置任务:`在自动布线前由甲方确认最小规则集:线距、转角、线槽优先级、强弱电分层、线槽容量、禁止区域。` |
| `路径规划算法开发`:基于规则实现自动生成无碰撞、合规的布线路径。 | 【建议修改】“无碰撞、合规”是理想目标,不应绝对承诺。实际应生成候选路径并给出诊断。 | 改为:`基于布线路径网络生成优选路径;尽量避障并标记碰撞/间隙/越界诊断,不能保证所有复杂场景自动无碰撞。` |
| `手动调整功能开发`:实现布线路径的拖拽、修改、撤销/重做等交互功能。 | 【建议降级】完整拖拽路径点和撤销重做 UI 工作量大,第一版可以先做路径点添加/删除/重新生成,并复用 FreeCAD 原生撤销。 | 改为:`第一版支持设置起点、终点、添加路径点、删除路径点、重新生成导线;拖拽路径点作为后续体验增强。` |
| `实时错误检查逻辑开发`:开发布线冲突、连接错误、电气规范违规的实时检测。 | 【建议降级】真正实时检测成本高,第一版可以做命令执行前后检查和批量诊断。 | 改为:`第一版做布线前置检查、生成后诊断和错误高亮;实时跟随鼠标/拖拽刷新作为后续。` |
| `整体联调与验收`:全流程功能联调,修复 bug 并完成验收测试。 | 合理,但需要明确验收样例。 | 增加说明:`验收必须基于甲方 KYN28/PT2 柜样例、真实 2d_to_3d.json、真实 FCStd 设备资产和取线表样例。` |
### 9.3 建议调整后的第一版任务口径
如果要让任务表更符合当前项目实际,建议第一版总目标改成:
```text
完成 QET 与 FreeCAD 的 3D 电气设计最小闭环:
1. QET 提供设备、端子、导线任务和 3D 资产路径。
2. FreeCAD 导入 FCStd 电气资产和柜体场景。
3. FreeCAD 从模板端子生成工程端子。
4. 用户可手动连接两个工程端子并编辑路径。
5. FreeCAD 保存 scene.FCStd并回写设备实例、端子绑定、已布导线、路径长度和诊断状态。
6. QET 根据回写结果和电气主数据生成取线表。
```
第一版不应承诺:
- 完整 SW Mate 级装配系统。
- 完整参数化设备库。
- 完整自动端子排生成。
- 完整 EPLAN 级自动布线规则库。
- FreeCAD 单独生成最终生产取线表。
这些可以作为第二阶段或第三阶段能力继续扩展。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -21,6 +21,20 @@ set(FreeCADExchange_Scripts
ExchangeWriteBack.py ExchangeWriteBack.py
ManualWiring.py ManualWiring.py
ManualWiringPanel.py ManualWiringPanel.py
BatchAssembly.py
)
set(FreeCADExchange_CabinetAssetDir
${CMAKE_CURRENT_SOURCE_DIR}/../../../data/examples/qet_cabinet_assets
)
set(FreeCADExchange_CabinetAssets
${FreeCADExchange_CabinetAssetDir}/README.md
${FreeCADExchange_CabinetAssetDir}/qet_cabinet_assets_report.json
${FreeCADExchange_CabinetAssetDir}/qet_din_rail.FCStd
${FreeCADExchange_CabinetAssetDir}/qet_din_rail.step
${FreeCADExchange_CabinetAssetDir}/qet_wire_duct.FCStd
${FreeCADExchange_CabinetAssetDir}/qet_wire_duct.step
) )
add_custom_target(FreeCADExchangeScripts ALL add_custom_target(FreeCADExchangeScripts ALL
@ -39,3 +53,10 @@ install(
DESTINATION DESTINATION
Mod/FreeCADExchange Mod/FreeCADExchange
) )
install(
FILES
${FreeCADExchange_CabinetAssets}
DESTINATION
data/examples/qet_cabinet_assets
)

@ -861,6 +861,19 @@ def _is_exchange_sidecar_group(obj):
return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES} return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}
def _existing_model_objects(doc, group):
return [
obj
for obj in _existing_group_objects(doc, group)
if not _is_exchange_sidecar_group(obj)
]
def _remove_model_objects(doc, objects):
for obj in list(objects or []):
_remove_object_tree(doc, obj)
def _keep_only_direct_model_children(device_group, direct_model_objects): def _keep_only_direct_model_children(device_group, direct_model_objects):
allowed_ids = {id(obj) for obj in direct_model_objects if obj is not None} allowed_ids = {id(obj) for obj in direct_model_objects if obj is not None}
kept_children = [] kept_children = []
@ -1010,25 +1023,28 @@ def _import_model_into_group(
before_names = _existing_object_names(doc) before_names = _existing_object_names(doc)
try: try:
ImportGui.insert( try:
name=model_path, ImportGui.insert(
docName=doc.Name, name=model_path,
merge=bool(merge), docName=doc.Name,
useLinkGroup=bool(use_link_group), merge=bool(merge),
) useLinkGroup=bool(use_link_group),
except Exception: )
for obj in _new_objects_since(doc, before_names): except Exception:
_remove_object_tree(doc, obj) for obj in _new_objects_since(doc, before_names):
raise _remove_object_tree(doc, obj)
raise
imported_objects = _new_objects_since(doc, before_names)
top_level_objects = _top_level_imported_objects(imported_objects) imported_objects = _new_objects_since(doc, before_names)
for obj in top_level_objects: top_level_objects = _top_level_imported_objects(imported_objects)
if obj not in getattr(device_group, "Group", []): for obj in top_level_objects:
device_group.addObject(obj) if obj not in getattr(device_group, "Group", []):
TemplateSemantics.clear_stored_template_slot_hints(device_group) device_group.addObject(obj)
TerminalObjects.hide_template_terminal_hints(device_group) TemplateSemantics.clear_stored_template_slot_hints(device_group)
return top_level_objects TerminalObjects.hide_template_terminal_hints(device_group)
return top_level_objects
finally:
_activate_document(doc)
def _open_fcstd_source_document(model_path, source_doc_cache=None): def _open_fcstd_source_document(model_path, source_doc_cache=None):
@ -1231,9 +1247,12 @@ def _import_cabinet_model(doc, root_group, cabinet, report, source_doc_cache=Non
return return
project_uuid = getattr(root_group, "QetProjectUuid", "").strip() project_uuid = getattr(root_group, "QetProjectUuid", "").strip()
existing_group = _find_cabinet_group(doc, _cabinet_instance_id(cabinet))
previous_path = ""
if existing_group is not None:
previous_path = getattr(existing_group, "QetCabinetResolvedScenePath", "").strip()
cabinet_group = _ensure_cabinet_model_group(doc, root_group, cabinet, project_uuid) cabinet_group = _ensure_cabinet_model_group(doc, root_group, cabinet, project_uuid)
existing_model_objects = _existing_group_objects(doc, cabinet_group) existing_model_objects = _existing_model_objects(doc, cabinet_group)
previous_path = getattr(cabinet_group, "QetCabinetResolvedScenePath", "").strip()
same_source = _normalized_path_key(previous_path) == _normalized_path_key(resolved_scene_path) same_source = _normalized_path_key(previous_path) == _normalized_path_key(resolved_scene_path)
if existing_model_objects and same_source: if existing_model_objects and same_source:
report.setdefault("cabinet_reused", 0) report.setdefault("cabinet_reused", 0)
@ -1246,7 +1265,6 @@ def _import_cabinet_model(doc, root_group, cabinet, report, source_doc_cache=Non
return return
had_existing_model = bool(existing_model_objects) had_existing_model = bool(existing_model_objects)
_clear_group_contents(doc, cabinet_group)
_ensure_string_property( _ensure_string_property(
cabinet_group, cabinet_group,
"QetCabinetResolvedScenePath", "QetCabinetResolvedScenePath",
@ -1268,6 +1286,7 @@ def _import_cabinet_model(doc, root_group, cabinet, report, source_doc_cache=Non
use_link_group=True, use_link_group=True,
source_doc_cache=source_doc_cache, source_doc_cache=source_doc_cache,
) )
_remove_model_objects(doc, existing_model_objects)
report["cabinet_imported"] += 1 report["cabinet_imported"] += 1
if had_existing_model: if had_existing_model:
report.setdefault("cabinet_reimported", 0) report.setdefault("cabinet_reimported", 0)
@ -1277,6 +1296,14 @@ def _import_cabinet_model(doc, root_group, cabinet, report, source_doc_cache=Non
report["cabinet_added"] += 1 report["cabinet_added"] += 1
_append_debug_log("DeviceImport cabinet import succeeded") _append_debug_log("DeviceImport cabinet import succeeded")
except Exception as exc: except Exception as exc:
if had_existing_model:
_ensure_string_property(
cabinet_group,
"QetCabinetResolvedScenePath",
"QET Exchange",
"Resolved local cabinet scene path from QET exchange",
previous_path,
)
report["cabinet_skipped_import_error"] += 1 report["cabinet_skipped_import_error"] += 1
report["warnings"].append( report["warnings"].append(
"机柜 3D 导入失败:{0}".format(exc) "机柜 3D 导入失败:{0}".format(exc)
@ -1362,7 +1389,9 @@ def import_devices_from_payload(payload, scene_path=""):
existing_model_objects = _existing_model_objects( existing_model_objects = _existing_model_objects(
doc, existing_device_group doc, existing_device_group
) )
model_info = models_by_element.get(instance_id or element_uuid, {}) 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", "")) resolved_model_path = _native_path(model_info.get("resolved_model_path", ""))
_append_debug_log( _append_debug_log(
"DeviceImport device instance_id={0}, display_tag={1}, resolved_model_path={2}".format( "DeviceImport device instance_id={0}, display_tag={1}, resolved_model_path={2}".format(
@ -1511,7 +1540,8 @@ def import_devices_from_payload(payload, scene_path=""):
) )
continue continue
_clear_group_contents(doc, device_group) if created_now or not existing_model_objects:
_clear_group_contents(doc, device_group)
try: try:
_append_debug_log( _append_debug_log(
@ -1530,7 +1560,17 @@ def import_devices_from_payload(payload, scene_path=""):
instance_id instance_id
) )
) )
if existing_model_objects:
_remove_model_objects(doc, existing_model_objects)
except Exception as exc: except Exception as exc:
if existing_model_objects:
_ensure_string_property(
device_group,
"QetResolvedModelPath",
"QET Exchange",
"Resolved local model path from QET exchange",
previous_path,
)
report["skipped_import_error"] += 1 report["skipped_import_error"] += 1
report["warnings"].append( report["warnings"].append(
"{0} 导入失败:{1}".format( "{0} 导入失败:{1}".format(
@ -1587,6 +1627,7 @@ def import_devices_from_payload(payload, scene_path=""):
finally: finally:
_close_cached_source_documents(source_doc_cache, target_doc=doc) _close_cached_source_documents(source_doc_cache, target_doc=doc)
TerminalObjects.sort_group_children(root_group)
doc.recompute() doc.recompute()
_append_debug_log("DeviceImport ViewFit skipped during exchange import") _append_debug_log("DeviceImport ViewFit skipped during exchange import")

@ -1,6 +1,7 @@
import json import json
import traceback import traceback
import os import os
import sqlite3
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@ -645,6 +646,15 @@ def _optional_string(item, field_name, entry_label):
return value.strip() if isinstance(value, str) else "" return value.strip() if isinstance(value, str) else ""
def _optional_text(item, field_name):
value = item.get(field_name, "")
if value is None:
return ""
if isinstance(value, str):
return value.strip()
return str(value).strip()
def _normalize_conductor_uuids(item, entry_label): def _normalize_conductor_uuids(item, entry_label):
values = item.get("conductor_uuids", []) values = item.get("conductor_uuids", [])
if values is None: if values is None:
@ -695,8 +705,10 @@ def _normalize_wires(payload):
"wire_id": wire_id, "wire_id": wire_id,
"net_uuid": _optional_string(item, "net_uuid", entry_label), "net_uuid": _optional_string(item, "net_uuid", entry_label),
"group_uuid": _optional_string(item, "group_uuid", entry_label), "group_uuid": _optional_string(item, "group_uuid", entry_label),
"wire_label": _optional_string(item, "wire_label", entry_label),
"wire_mark": _optional_string(item, "wire_mark", entry_label), "wire_mark": _optional_string(item, "wire_mark", entry_label),
"wire_mark_is_manual": wire_mark_is_manual, "wire_mark_is_manual": wire_mark_is_manual,
"wire_style_id": _optional_text(item, "wire_style_id"),
"start_element_uuid": _optional_string(item, "start_element_uuid", entry_label), "start_element_uuid": _optional_string(item, "start_element_uuid", entry_label),
"start_instance_id": _optional_string(item, "start_instance_id", entry_label), "start_instance_id": _optional_string(item, "start_instance_id", entry_label),
"start_terminal_uuid": _optional_string(item, "start_terminal_uuid", entry_label), "start_terminal_uuid": _optional_string(item, "start_terminal_uuid", entry_label),
@ -813,6 +825,68 @@ def _normalize_cabinet(payload):
return normalized return normalized
def _has_wire_properties_table(database_path):
try:
connection = sqlite3.connect(str(database_path))
except Exception:
return False
try:
row = connection.execute(
"""
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = 'wire_properties'
LIMIT 1
"""
).fetchone()
return row is not None
except Exception:
return False
finally:
try:
connection.close()
except Exception:
pass
def _wire_style_database_path(payload, json_path):
for key in ("wire_style_database_path", "project_database_path", "database_path"):
value = payload.get(key, "")
if isinstance(value, str) and value.strip():
return value.strip()
try:
directory = Path(json_path).resolve().parent
except Exception:
return ""
candidates = []
search_dirs = []
for base in (directory, directory.parent, directory.parent.parent):
if base and base not in search_dirs:
search_dirs.append(base)
data_dir = base / "datafiles"
if data_dir not in search_dirs:
search_dirs.append(data_dir)
for base in (directory.parent, directory.parent.parent):
try:
for data_dir in base.glob("*/datafiles"):
if data_dir not in search_dirs:
search_dirs.append(data_dir)
except Exception:
pass
for search_dir in search_dirs:
for pattern in ("project-local.db", "project-local.sqlite", "*.sqlite", "*.sqlite3", "*.db"):
try:
candidates.extend(search_dir.glob(pattern))
except Exception:
pass
for candidate in sorted(set(candidates), key=lambda item: item.name.lower()):
# 不要求 QET 改协议FreeCAD 只在候选库确实含 wire_properties 时自动使用。
if _has_wire_properties_table(candidate):
return str(candidate)
return ""
def load_exchange_payload(json_path): def load_exchange_payload(json_path):
try: try:
raw_text = Path(json_path).read_text(encoding="utf-8") raw_text = Path(json_path).read_text(encoding="utf-8")
@ -854,6 +928,9 @@ def load_exchange_payload(json_path):
"device_models": _normalize_device_models(payload), "device_models": _normalize_device_models(payload),
"wires": _normalize_wires(payload), "wires": _normalize_wires(payload),
} }
wire_style_database_path = _wire_style_database_path(payload, json_path)
if wire_style_database_path:
normalized["wire_style_database_path"] = wire_style_database_path
return normalized return normalized
@ -883,6 +960,7 @@ def _build_summary(payload, json_path):
"missing_terminal_instances": missing_terminal_instances, "missing_terminal_instances": missing_terminal_instances,
"cabinet": cabinet, "cabinet": cabinet,
"scene_path": os.environ.get(ENV_SCENE_PATH, "").strip(), "scene_path": os.environ.get(ENV_SCENE_PATH, "").strip(),
"wire_style_database_path": payload.get("wire_style_database_path", ""),
} }

@ -16,9 +16,8 @@ COMMANDS = [
"QET_Template_CreateEngineeringTerminals", "QET_Template_CreateEngineeringTerminals",
"QET_Exchange_CreateManualWire", "QET_Exchange_CreateManualWire",
"QET_Exchange_OpenManualWiringPanel", "QET_Exchange_OpenManualWiringPanel",
"QET_Exchange_AutoRouteSelected", "QET_Exchange_RouteEplanConnections",
"QET_Exchange_AutoRouteAll", "QET_Exchange_OpenRoutingConnectionPanel",
"QET_Exchange_OpenAutoRoutingPanel",
"QET_Exchange_HideStaleObjects", "QET_Exchange_HideStaleObjects",
"QET_Exchange_ShowStaleObjects", "QET_Exchange_ShowStaleObjects",
"QET_Exchange_SummarizeStaleObjects", "QET_Exchange_SummarizeStaleObjects",
@ -103,7 +102,7 @@ def _register_exchange_commands(
auto_routing.register_commands() auto_routing.register_commands()
except Exception: except Exception:
append_init_log( append_init_log(
"InitGui failed to register auto-routing commands:\n{0}".format( "InitGui failed to register routing connection commands:\n{0}".format(
traceback_module.format_exc() traceback_module.format_exc()
) )
) )
@ -113,7 +112,7 @@ def _register_exchange_commands(
auto_routing_panel.register_commands() auto_routing_panel.register_commands()
except Exception: except Exception:
append_init_log( append_init_log(
"InitGui failed to register auto-routing panel command:\n{0}".format( "InitGui failed to register routing connection panel command:\n{0}".format(
traceback_module.format_exc() traceback_module.format_exc()
) )
) )

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1,5 +1,7 @@
# FreeCADExchange FCStd template authoring helpers. # FreeCADExchange FCStd template authoring helpers.
import json
import FreeCAD as App import FreeCAD as App
try: try:
@ -110,6 +112,151 @@ def is_template_terminal(obj):
return TerminalObjects.is_terminal_hint_object(obj) return TerminalObjects.is_terminal_hint_object(obj)
def _route_point_payload(point):
if isinstance(point, App.Vector):
return [float(point.x), float(point.y), float(point.z)]
if isinstance(point, dict):
try:
return [
float(point.get("x", 0.0) or 0.0),
float(point.get("y", 0.0) or 0.0),
float(point.get("z", 0.0) or 0.0),
]
except Exception:
return None
if isinstance(point, (list, tuple)) and len(point) >= 3:
try:
return [float(point[0] or 0.0), float(point[1] or 0.0), float(point[2] or 0.0)]
except Exception:
return None
return None
def _distance(left, right):
try:
dx = float(left.x) - float(right.x)
dy = float(left.y) - float(right.y)
dz = float(left.z) - float(right.z)
return (dx * dx + dy * dy + dz * dz) ** 0.5
except Exception:
return 0.0
def _edge_points(edge):
vertexes = list(getattr(edge, "Vertexes", []) or [])
if len(vertexes) >= 2:
start = getattr(vertexes[0], "Point", None)
end = getattr(vertexes[-1], "Point", None)
if start is not None and end is not None:
return [start, end]
try:
return [edge.valueAt(edge.FirstParameter), edge.valueAt(edge.LastParameter)]
except Exception:
return []
def _selection_item_points(selection_item):
points = []
points.extend(list(getattr(selection_item, "PickedPoints", []) or []))
for sub_object in list(getattr(selection_item, "SubObjects", []) or []):
shape_type = (getattr(sub_object, "ShapeType", "") or "").lower()
if shape_type == "edge":
points.extend(_edge_points(sub_object))
continue
if shape_type == "vertex":
point = getattr(sub_object, "Point", None)
if point is not None:
points.append(point)
obj = getattr(selection_item, "Object", None)
shape = getattr(obj, "Shape", None)
if shape is not None and not is_template_terminal(obj):
for edge in list(getattr(shape, "Edges", []) or []):
points.extend(_edge_points(edge))
normalized = []
for point in points:
if point is None:
continue
if not normalized or _distance(normalized[-1], point) > 1e-6:
normalized.append(point)
return normalized
def _document_point_to_terminal_local(terminal, point):
placement = getattr(terminal, "Placement", None)
if placement is None:
return point
try:
inverse = placement.inverse()
transformed = inverse.multVec(point)
if transformed is not None:
return transformed
except Exception:
pass
base = getattr(placement, "Base", None)
if base is None:
return point
return App.Vector(
float(point.x) - float(base.x),
float(point.y) - float(base.y),
float(point.z) - float(base.z),
)
def set_template_terminal_local_route_points(obj, points):
if not is_template_terminal(obj):
raise TemplateAuthoringError("A valid template terminal is required.")
payload = []
for point in points or []:
item = _route_point_payload(point)
if item is not None:
payload.append(item)
if len(payload) < 2:
raise TemplateAuthoringError("At least two local route points are required.")
TerminalObjects.ensure_string_property(
obj,
"QetTerminalLocalRoutePointsJson",
TEMPLATE_PROPERTY_GROUP,
"Terminal-local route points used before joining the cabinet routing network",
json.dumps(payload, ensure_ascii=False),
)
try:
obj.Document.recompute()
except Exception:
pass
return obj
def set_selected_template_terminal_local_route_points(selection_ex=None):
if selection_ex is None:
if Gui is None:
raise TemplateAuthoringError("FreeCAD GUI selection is not available.")
try:
selection_ex = Gui.Selection.getSelectionEx()
except Exception:
selection_ex = []
terminal = None
route_points = []
for item in list(selection_ex or []):
obj = getattr(item, "Object", None)
if terminal is None and is_template_terminal(obj):
terminal = obj
continue
route_points.extend(_selection_item_points(item))
if terminal is None:
raise TemplateAuthoringError("Select one template terminal before selecting its local route path.")
if len(route_points) < 2:
raise TemplateAuthoringError("Select a sketch, edge, or route path with at least two points.")
local_points = [
_document_point_to_terminal_local(terminal, point)
for point in route_points
]
return set_template_terminal_local_route_points(terminal, local_points)
def _has_property(obj, prop_name): def _has_property(obj, prop_name):
return prop_name in getattr(obj, "PropertiesList", []) return prop_name in getattr(obj, "PropertiesList", [])
@ -276,6 +423,30 @@ class CommandValidateTemplateTerminals:
App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning))
class CommandSetTemplateTerminalLocalRoute:
def GetResources(self):
return {
"MenuText": "设置端子局部路径",
"ToolTip": "把选中的草图或线段保存为模板端子的局部出线路径",
}
def IsActive(self):
return App.ActiveDocument is not None and Gui is not None
def Activated(self):
try:
terminal = set_selected_template_terminal_local_route_points()
App.Console.PrintMessage(
"[FreeCADExchange] Updated local route points for template terminal {0}.\n".format(
getattr(terminal, "QetTemplateSlotName", "") or getattr(terminal, "Name", "")
)
)
except Exception as exc:
App.Console.PrintError(
"[FreeCADExchange] setting template terminal local route failed: {0}\n".format(exc)
)
class CommandSaveTemplateAsFCStd: class CommandSaveTemplateAsFCStd:
def GetResources(self): def GetResources(self):
return { return {
@ -325,6 +496,7 @@ def register_commands():
return return
Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal())
Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals())
Gui.addCommand("QET_Template_SetTerminalLocalRoute", CommandSetTemplateTerminalLocalRoute())
Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd()) Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd())
_COMMANDS_REGISTERED = True _COMMANDS_REGISTERED = True

@ -207,7 +207,7 @@ def ensure_engineering_terminals_for_device(doc, device_group):
slot_name=slot_name, slot_name=slot_name,
) )
try: try:
terminal_obj.ViewObject.Visibility = True TerminalObjects.hide_engineering_terminal(terminal_obj)
terminal_obj.ViewObject.ShapeColor = (0.0, 0.75, 1.0) terminal_obj.ViewObject.ShapeColor = (0.0, 0.75, 1.0)
except Exception: except Exception:
pass pass

@ -123,6 +123,34 @@ def _vector_to_payload(value):
return [float(vector.x), float(vector.y), float(vector.z)] return [float(vector.x), float(vector.y), float(vector.z)]
def _route_points_to_payload(value):
if not isinstance(value, (list, tuple)):
return None
points = []
for item in value:
point = _vector_to_payload(item)
if point is not None:
points.append(point)
return points if points else None
def _route_points_from_object(source_object):
if source_object is None:
return None
for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"):
raw_text = getattr(source_object, property_name, "")
if not isinstance(raw_text, str) or not raw_text.strip():
continue
try:
parsed = json.loads(raw_text)
except (TypeError, ValueError):
continue
points = _route_points_to_payload(parsed)
if points:
return points
return None
def _rotation_to_payload(value): def _rotation_to_payload(value):
if not isinstance(value, dict): if not isinstance(value, dict):
return None return None
@ -161,6 +189,9 @@ def _slot_to_payload(slot):
rotation = _rotation_to_payload(slot.get("rotation")) rotation = _rotation_to_payload(slot.get("rotation"))
if rotation is not None: if rotation is not None:
payload["rotation"] = rotation payload["rotation"] = rotation
local_route_points = _route_points_to_payload(slot.get("local_route_points"))
if local_route_points is not None:
payload["local_route_points"] = local_route_points
return payload return payload
@ -263,6 +294,18 @@ def _slot_from_payload(item, source, index, source_object=None):
rotation = placement_rotation rotation = placement_rotation
if rotation is None: if rotation is None:
rotation = _rotation_from_object(source_object) rotation = _rotation_from_object(source_object)
local_route_points = None
for route_points_key in (
"local_route_points",
"terminal_local_route_points",
"exit_path",
"route_points",
):
local_route_points = _route_points_to_payload(item.get(route_points_key))
if local_route_points is not None:
break
if local_route_points is None:
local_route_points = _route_points_from_object(source_object)
slot = { slot = {
"name": name, "name": name,
@ -273,6 +316,8 @@ def _slot_from_payload(item, source, index, source_object=None):
} }
if rotation is not None: if rotation is not None:
slot["rotation"] = rotation slot["rotation"] = rotation
if local_route_points is not None:
slot["local_route_points"] = local_route_points
return slot return slot

@ -1,6 +1,7 @@
# FreeCADExchange terminal import helpers. # FreeCADExchange terminal import helpers.
from collections import OrderedDict from collections import OrderedDict
import json
import FreeCAD as App import FreeCAD as App
@ -151,6 +152,56 @@ def _payload_terminal_entries(payload):
return terminal_entries return terminal_entries
def _device_embedded_terminal_entries(payload, existing_keys):
devices = payload.get("devices", []) or []
if not isinstance(devices, list):
return []
seen = set(existing_keys or set())
entries = []
for device in devices:
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_terminals = device.get("terminals", []) or []
if not isinstance(device_terminals, list):
continue
for terminal in device_terminals:
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):
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")
or ""
)
entries.append(
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"instance_id": instance_id,
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
)
return entries
def _wire_endpoint_terminal_entries(payload, existing_keys): def _wire_endpoint_terminal_entries(payload, existing_keys):
wires = payload.get("wires", []) or [] wires = payload.get("wires", []) or []
if not isinstance(wires, list): if not isinstance(wires, list):
@ -203,12 +254,8 @@ def _terminal_belongs_to_payload_devices(entry, device_lookup):
return False return False
def _ensure_visible(obj): def _hide_engineering_terminal(obj):
try: TerminalObjects.hide_engineering_terminal(obj)
if getattr(obj, "ViewObject", None) is not None:
obj.ViewObject.Visibility = True
except Exception:
pass
def _set_terminal_geometry_source(obj, source): def _set_terminal_geometry_source(obj, source):
@ -226,6 +273,21 @@ def _set_terminal_geometry_source(obj, source):
) )
def _set_terminal_local_route_points(obj, slot):
points = slot.get("local_route_points") if isinstance(slot, dict) else None
raw_text = ""
if isinstance(points, list) and points:
raw_text = json.dumps(points, ensure_ascii=False)
if raw_text or "QetTerminalLocalRoutePointsJson" in getattr(obj, "PropertiesList", []):
TerminalObjects.ensure_string_property(
obj,
"QetTerminalLocalRoutePointsJson",
"QET Routing",
"Terminal-local route points used before joining the cabinet routing network",
raw_text,
)
def _hide_object(obj): def _hide_object(obj):
try: try:
if getattr(obj, "ViewObject", None) is not None: if getattr(obj, "ViewObject", None) is not None:
@ -398,7 +460,8 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro
slot_name=slot.get("name", ""), slot_name=slot.get("name", ""),
) )
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_ensure_visible(terminal_obj) _set_terminal_local_route_points(terminal_obj, slot)
_hide_engineering_terminal(terminal_obj)
return terminal_obj return terminal_obj
@ -425,6 +488,13 @@ def import_terminals_from_payload(payload, scene_path=""):
terminal_uuid = (item.get("terminal_uuid") or "").strip() terminal_uuid = (item.get("terminal_uuid") or "").strip()
if element_uuid and terminal_uuid: if element_uuid and terminal_uuid:
terminal_entry_keys.add((element_uuid, terminal_uuid)) terminal_entry_keys.add((element_uuid, terminal_uuid))
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"])
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) synthesized_entries = _wire_endpoint_terminal_entries(payload, terminal_entry_keys)
terminal_entries.extend(synthesized_entries) terminal_entries.extend(synthesized_entries)
@ -441,6 +511,7 @@ def import_terminals_from_payload(payload, scene_path=""):
"reused_template_hints": 0, "reused_template_hints": 0,
"matched_by_slot_hint": 0, "matched_by_slot_hint": 0,
"generated_fallback_slots": 0, "generated_fallback_slots": 0,
"device_embedded_terminals": len(embedded_entries),
"synthesized_wire_endpoint_terminals": len(synthesized_entries), "synthesized_wire_endpoint_terminals": len(synthesized_entries),
"skipped_missing_slot": 0, "skipped_missing_slot": 0,
"skipped_missing_device": 0, "skipped_missing_device": 0,
@ -560,11 +631,12 @@ def import_terminals_from_payload(payload, scene_path=""):
slot_name=slot.get("name", ""), slot_name=slot.get("name", ""),
) )
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
try: try:
terminal_obj.Placement = _slot_placement(slot) terminal_obj.Placement = _slot_placement(slot)
except Exception: except Exception:
pass pass
_ensure_visible(terminal_obj) _hide_engineering_terminal(terminal_obj)
report["updated_terminals"] += 1 report["updated_terminals"] += 1
else: else:
terminal_obj = _create_terminal_object( terminal_obj = _create_terminal_object(
@ -589,11 +661,12 @@ def import_terminals_from_payload(payload, scene_path=""):
slot_name=slot.get("name", ""), slot_name=slot.get("name", ""),
) )
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
try: try:
terminal_obj.Placement = _slot_placement(slot) terminal_obj.Placement = _slot_placement(slot)
except Exception: except Exception:
pass pass
_ensure_visible(terminal_obj) _hide_engineering_terminal(terminal_obj)
report["updated_terminals"] += 1 report["updated_terminals"] += 1
if terminal_obj not in getattr(terminal_group, "Group", []): if terminal_obj not in getattr(terminal_group, "Group", []):
@ -606,6 +679,9 @@ def import_terminals_from_payload(payload, scene_path=""):
_hide_object(source_obj) _hide_object(source_obj)
report["reused_template_hints"] += 1 report["reused_template_hints"] += 1
TerminalObjects.sort_group_children(terminal_group)
TerminalObjects.sort_group_children(root_group)
doc.recompute() doc.recompute()
try: try:
DeviceImport._append_debug_log( DeviceImport._append_debug_log(

@ -3,6 +3,7 @@
import json import json
import math import math
import os import os
import re
from pathlib import Path from pathlib import Path
import FreeCAD as App import FreeCAD as App
@ -45,6 +46,45 @@ def is_local_terminal_uuid(value):
return (value or "").strip().lower().startswith("local:") return (value or "").strip().lower().startswith("local:")
def natural_sort_key(value):
text = str(value or "").strip().casefold()
parts = re.split(r"(\d+)", text)
key = []
for part in parts:
if not part:
continue
if part.isdigit():
key.append((0, int(part)))
else:
key.append((1, part))
return tuple(key)
def object_display_sort_key(obj):
label = (getattr(obj, "Label", "") or "").strip()
name = (getattr(obj, "Name", "") or "").strip()
return (natural_sort_key(label or name), natural_sort_key(name))
def sort_group_children(group):
children = list(getattr(group, "Group", []) or [])
if len(children) < 2:
return children
sorted_children = sorted(
enumerate(children),
key=lambda item: (object_display_sort_key(item[1]), item[0]),
)
ordered = [child for _index, child in sorted_children]
try:
group.Group = ordered
except Exception:
try:
group.Group[:] = ordered
except Exception:
return children
return ordered
def ensure_string_property(obj, prop_name, group_name, description, value): def ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []): if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description) obj.addProperty("App::PropertyString", prop_name, group_name, description)
@ -274,6 +314,17 @@ def is_template_terminal_object(obj):
return is_terminal_hint_object(obj) and not is_terminal_object(obj) return is_terminal_hint_object(obj) and not is_terminal_object(obj)
def hide_engineering_terminal(obj):
try:
view_object = getattr(obj, "ViewObject", None)
if view_object is not None and hasattr(view_object, "Visibility"):
view_object.Visibility = False
return True
except Exception:
pass
return False
def hide_template_terminal_hints(container): def hide_template_terminal_hints(container):
hidden = 0 hidden = 0
if container is None: if container is None:

@ -304,6 +304,31 @@ def _json_array_property(obj, prop_name):
return [] return []
def _json_property(obj, prop_name, fallback=None):
text = getattr(obj, prop_name, "")
if not text:
return fallback
try:
return json.loads(text)
except Exception:
return fallback
def _float_property(obj, prop_name, fallback=0.0):
try:
return float(getattr(obj, prop_name, fallback) or fallback)
except Exception:
return float(fallback)
def _route_metadata_payload(wire_obj):
return {
"wire_style_id": getattr(wire_obj, "QetWireStyleId", "").strip(),
"length_mm": _float_property(wire_obj, "QetRouteLengthMm", 0.0),
"route_diagnostics": _json_property(wire_obj, "QetRouteDiagnosticsJson", {}),
}
def wire_shape_points(wire_obj): def wire_shape_points(wire_obj):
if wire_obj is None: if wire_obj is None:
return [] return []
@ -362,7 +387,9 @@ def wire_payload_from_object(wire_obj):
"points": [], "points": [],
"manual_waypoints": [], "manual_waypoints": [],
"route_nodes": [], "route_nodes": [],
"route_track": {},
"terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0), "terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0),
**_route_metadata_payload(wire_obj),
} }
points = [_point_from_vector(point) for point in wire_shape_points(wire_obj)] points = [_point_from_vector(point) for point in wire_shape_points(wire_obj)]
return { return {
@ -382,7 +409,9 @@ def wire_payload_from_object(wire_obj):
"points": points, "points": points,
"manual_waypoints": _json_array_property(wire_obj, "QetManualWaypointsJson"), "manual_waypoints": _json_array_property(wire_obj, "QetManualWaypointsJson"),
"route_nodes": _json_array_property(wire_obj, "QetRouteNodesJson"), "route_nodes": _json_array_property(wire_obj, "QetRouteNodesJson"),
"route_track": _json_property(wire_obj, "QetRouteTrackJson", {}),
"terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0), "terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0),
**_route_metadata_payload(wire_obj),
} }
@ -393,7 +422,7 @@ def is_routed_wire_object(obj):
return ( return (
"QetStartTerminalUuid" in properties "QetStartTerminalUuid" in properties
and "QetEndTerminalUuid" in properties and "QetEndTerminalUuid" in properties
and (getattr(obj, "RouteType", "") or "").strip() in {"Manual", "GuidedManual", "AutoSuggested"} and (getattr(obj, "RouteType", "") or "").strip() in {"Manual", "GuidedManual", "RoutedConnection"}
) )

@ -8,8 +8,8 @@ import FreeCAD as App
REPO_ROOT = r"D:\LightWork3D" REPO_ROOT = r"D:\LightWork3D"
MODULE_DIR = os.path.join(REPO_ROOT, "src", "Mod", "FreeCADExchange") MODULE_DIR = os.path.join(REPO_ROOT, "src", "Mod", "FreeCADExchange")
OUT_DIR = os.path.join(REPO_ROOT, "tests", "out") OUT_DIR = os.path.join(REPO_ROOT, "tests", "out")
OUT_FCSTD = os.path.join(OUT_DIR, "auto_routing_smoke.FCStd") OUT_FCSTD = os.path.join(OUT_DIR, "routing_connection_smoke.FCStd")
OUT_JSON = os.path.join(OUT_DIR, "auto_routing_smoke_result.json") OUT_JSON = os.path.join(OUT_DIR, "routing_connection_smoke_result.json")
if MODULE_DIR not in sys.path: if MODULE_DIR not in sys.path:
sys.path.insert(0, MODULE_DIR) sys.path.insert(0, MODULE_DIR)
@ -48,7 +48,7 @@ def _point_payload(point):
def main(): def main():
os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(OUT_DIR, exist_ok=True)
doc = App.newDocument("AutoRoutingSmoke") doc = App.newDocument("RoutingConnectionSmoke")
App.setActiveDocument(doc.Name) App.setActiveDocument(doc.Name)
TerminalObjects.ensure_root_group(doc, "project-smoke") TerminalObjects.ensure_root_group(doc, "project-smoke")
WiringObjects.initialize_wiring_scene(doc, "project-smoke") WiringObjects.initialize_wiring_scene(doc, "project-smoke")
@ -76,7 +76,7 @@ def main():
obstacle.Placement = App.Placement(App.Vector(60, -20, -10), App.Rotation()) obstacle.Placement = App.Placement(App.Vector(60, -20, -10), App.Rotation())
doc.recompute() doc.recompute()
result = AutoRouting.route_between_terminals(doc, start, end) result = AutoRouting.route_eplan_connection_between_terminals(doc, start, end)
payload = { payload = {
"algorithm": result["algorithm"], "algorithm": result["algorithm"],
"route_status": result["route_status"], "route_status": result["route_status"],
@ -92,8 +92,8 @@ def main():
routed_group = reopened.getObject("QETWiring_04_Routed") routed_group = reopened.getObject("QETWiring_04_Routed")
reopened_wires = list(getattr(routed_group, "Group", []) or []) if routed_group else [] reopened_wires = list(getattr(routed_group, "Group", []) or []) if routed_group else []
payload["reopened_routed_wire_count"] = len(reopened_wires) payload["reopened_routed_wire_count"] = len(reopened_wires)
payload["reopened_has_auto_route"] = any( payload["reopened_has_routed_connection"] = any(
(getattr(wire, "RouteType", "") or "").strip() == "AutoSuggested" (getattr(wire, "RouteType", "") or "").strip() == "RoutedConnection"
for wire in reopened_wires for wire in reopened_wires
) )

File diff suppressed because it is too large Load Diff

@ -0,0 +1,427 @@
import importlib
import sys
import tempfile
import types
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange"
if str(MODULE_DIR) not in sys.path:
sys.path.insert(0, str(MODULE_DIR))
def _install_fake_freecad():
class Vector:
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = float(x)
self.y = float(y)
self.z = float(z)
class Rotation:
def multVec(self, vector):
return vector
class Placement:
def __init__(self, base=None, rotation=None):
self.Base = base or Vector()
self.Rotation = rotation or Rotation()
fake_freecad = types.ModuleType("FreeCAD")
fake_freecad.Vector = Vector
fake_freecad.Rotation = Rotation
fake_freecad.Placement = Placement
fake_freecad.ActiveDocument = None
fake_freecad.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
sys.modules["FreeCAD"] = fake_freecad
fake_importgui = types.ModuleType("ImportGui")
def insert(name, docName=None, merge=False, useLinkGroup=True):
doc = fake_freecad.ActiveDocument
obj = doc.addObject("Part::Feature", "ImportedBatchModel")
obj.ImportedPath = name
return obj
fake_importgui.insert = insert
sys.modules["ImportGui"] = fake_importgui
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
sys.modules["FreeCADGui"] = fake_freecadgui
class FakeViewObject:
def __init__(self):
self.Visibility = True
class FakeObject:
def __init__(self, name, type_id):
self.Name = name
self.Label = name
self.TypeId = type_id
self.PropertiesList = []
self.Group = []
self.InList = []
self.ViewObject = FakeViewObject()
self.Placement = sys.modules["FreeCAD"].Placement()
self.Shape = None
def isDerivedFrom(self, type_name):
if self.TypeId == type_name:
return True
if type_name == "App::DocumentObjectGroup":
return self.TypeId == "App::DocumentObjectGroup"
if type_name == "App::LocalCoordinateSystem":
return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"}
return False
def addProperty(self, _prop_type, prop_name, _group_name, _description):
if prop_name not in self.PropertiesList:
self.PropertiesList.append(prop_name)
def addObject(self, child):
if child not in self.Group:
self.Group.append(child)
if self not in child.InList:
child.InList.append(self)
class FakeDocument:
def __init__(self):
self.Objects = []
self.Name = "QETScene"
self.recompute_count = 0
def addObject(self, type_name, name):
obj = FakeObject(name, type_name)
self.Objects.append(obj)
return obj
def getObject(self, name):
for obj in self.Objects:
if obj.Name == name:
return obj
return None
def recompute(self):
self.recompute_count += 1
def _reload_modules(*extra_names):
for name in ["RoutingNetwork", "WiringObjects", "TemplateSemantics", "TerminalObjects", "BatchAssembly"] + list(extra_names):
sys.modules.pop(name, None)
terminal_objects = importlib.import_module("TerminalObjects")
batch_assembly = importlib.import_module("BatchAssembly")
return terminal_objects, batch_assembly
class BatchAssemblyTest(unittest.TestCase):
def _qet_device(self, doc, terminal_objects, label, instance_id=None, element_uuid=None):
token = terminal_objects.safe_token(label)
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_" + token)
device.Label = label
terminal_objects.ensure_string_property(device, "QetGroupKind", "QET Exchange", "", "Device")
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"",
element_uuid or label,
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"",
instance_id or label,
)
return device
def _terminal(self, doc, terminal_objects, device, terminal_uuid, label):
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id=device.QetInstanceId,
)
terminal = terminal_objects.create_lcs_object(
doc,
"QETTerminal_" + terminal_objects.safe_token(terminal_uuid),
label=label,
)
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
device.QetElementUuid,
terminal_uuid,
device.QetInstanceId,
label=label,
)
return terminal
def test_layout_existing_terminal_block_places_qet_terminal_slices_without_local_rebind(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
ud2 = self._qet_device(doc, terminal_objects, "UD:2", instance_id="ud-2", element_uuid="element-ud-2")
ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1")
self._terminal(doc, terminal_objects, ud2, "element-ud-2:terminal-template-1", "UD:2")
self._terminal(doc, terminal_objects, ud1, "element-ud-1:terminal-template-1", "UD:1")
report = batch_assembly.layout_existing_terminal_block(
doc,
rail,
block_name="UD",
pitch_mm=5.2,
start_offset_mm=10.0,
)
self.assertEqual("qet_existing", report["source"])
self.assertEqual(2, report["updated_devices"])
self.assertEqual(0, report["created_devices"])
self.assertEqual(["UD:1", "UD:2"], [device.Label for device in report["devices"]])
self.assertEqual([110.0, 115.2], [device.Placement.Base.x for device in report["devices"]])
self.assertEqual(
["element-ud-1:terminal-template-1", "element-ud-2:terminal-template-1"],
[terminal.QetTerminalUuid for terminal in report["terminals"]],
)
self.assertFalse(any(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"]))
self.assertEqual("layout_existing", ud1.QetBatchAssemblyMode)
self.assertEqual("rail", ud1.QetMountHostKind)
def test_layout_existing_terminal_block_moves_group_children_for_document_groups(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1")
body = doc.addObject("Part::Feature", "TerminalSlice_GreenBody")
body.Placement = app.Placement(app.Vector(10, 20, 30), app.Rotation())
ud1.addObject(body)
self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1")
report = batch_assembly.layout_existing_terminal_block(
doc,
rail,
block_name="UD",
pitch_mm=5.2,
start_offset_mm=10.0,
)
self.assertEqual(1, report["updated_devices"])
self.assertNotEqual(10.0, body.Placement.Base.x)
self.assertAlmostEqual(110.0, ud1.Placement.Base.x)
def test_available_terminal_strip_names_comes_from_existing_qet_devices(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1")
id2 = self._qet_device(doc, terminal_objects, "ID:2", instance_id="id-2", element_uuid="element-id-2")
self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1")
self._terminal(doc, terminal_objects, id2, "terminal-id-2", "ID:2")
self.assertEqual(["ID", "UD"], batch_assembly.available_terminal_strip_names(doc))
def test_layout_existing_devices_filters_qet_breakers_and_ignores_terminal_slices(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
qf2 = self._qet_device(doc, terminal_objects, "QF2", instance_id="qf-2", element_uuid="element-qf-2")
qf1 = self._qet_device(doc, terminal_objects, "QF1", instance_id="qf-1", element_uuid="element-qf-1")
qf2_terminal = self._terminal(doc, terminal_objects, qf2, "terminal-qf-2-1", "QF2:1")
qf1_terminal = self._terminal(doc, terminal_objects, qf1, "terminal-qf-1-1", "QF1:1")
ta1 = self._qet_device(doc, terminal_objects, "TA1", instance_id="ta-1", element_uuid="element-ta-1")
ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1")
self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1")
qf0 = self._qet_device(doc, terminal_objects, "QF0", instance_id="qf-0", element_uuid="element-qf-0")
terminal_objects.ensure_string_property(
qf0,
"QetBatchAssemblyKind",
"QET Batch Assembly",
"",
"breaker_batch",
)
report = batch_assembly.layout_existing_devices(
doc,
rail,
prefix="QF",
pitch_mm=18.0,
start_offset_mm=5.0,
kind="breaker_batch",
)
self.assertEqual("qet_existing", report["source"])
self.assertEqual(["QF1", "QF2"], [device.Label for device in report["devices"]])
self.assertNotIn(ta1, report["devices"])
self.assertNotIn(ud1, report["devices"])
self.assertNotIn(qf0, report["devices"])
self.assertNotIn(qf1_terminal, report["devices"])
self.assertNotIn(qf2_terminal, report["devices"])
self.assertEqual([5.0, 23.0], [device.Placement.Base.x for device in report["devices"]])
self.assertEqual("layout_existing", qf1.QetBatchAssemblyMode)
def test_create_terminal_block_places_slices_and_local_terminals_along_selected_rail(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
report = batch_assembly.create_terminal_block(
doc,
rail,
block_name="XT1",
count=3,
pitch_mm=5.2,
start_offset_mm=10.0,
)
self.assertEqual(3, report["created_devices"])
self.assertEqual(3, report["created_terminals"])
self.assertEqual("XT1", report["group"].Label)
placements = [item.Placement.Base.x for item in report["devices"]]
self.assertEqual([110.0, 115.2, 120.4], placements)
terminal_labels = [terminal.Label for terminal in report["terminals"]]
self.assertEqual(["XT1:1", "XT1:2", "XT1:3"], terminal_labels)
self.assertTrue(all(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"]))
self.assertTrue(all(not terminal.ViewObject.Visibility for terminal in report["terminals"]))
def test_create_breakers_generates_numbered_devices_and_terminal_labels(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
report = batch_assembly.create_breakers(
doc,
rail,
base_name="QF",
count=2,
pitch_mm=18.0,
start_offset_mm=0.0,
terminal_numbers=("1", "2", "3", "4", "5", "6"),
)
self.assertEqual(["QF1", "QF2"], [device.Label for device in report["devices"]])
self.assertEqual(12, report["created_terminals"])
self.assertTrue(all(device.Name.startswith("QETDevice_") for device in report["devices"]))
self.assertEqual(["QF1", "QF2"], [device.QetInstanceId for device in report["devices"]])
self.assertEqual(["QF1", "QF2"], [device.QetElementUuid for device in report["devices"]])
labels = [terminal.Label for terminal in report["terminals"]]
self.assertEqual(["QF1:1", "QF1:2", "QF1:3", "QF1:4", "QF1:5", "QF1:6"], labels[:6])
self.assertEqual(["QF2:1", "QF2:2", "QF2:3", "QF2:4", "QF2:5", "QF2:6"], labels[6:])
self.assertEqual([0.0, 18.0], [device.Placement.Base.x for device in report["devices"]])
def test_created_breaker_local_slots_can_be_promoted_to_qet_terminal_uuid(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules("AutoRouting")
auto_routing = importlib.import_module("AutoRouting")
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
batch_assembly.create_breakers(
doc,
rail,
base_name="QF",
count=1,
terminal_numbers=("1", "2"),
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_uuid": "wire-1",
"start_instance_id": "QF1",
"start_terminal_uuid": "terminal-qf1-1",
"start_terminal_display": "1",
"end_instance_id": "QF1",
"end_terminal_uuid": "terminal-qf1-2",
"end_terminal_display": "2",
}
],
}
report = auto_routing.bind_wire_task_terminals_from_payload(doc, payload)
self.assertEqual(2, report["bound"])
indexed = auto_routing.index_terminals(doc)
self.assertIn("terminal-qf1-1", indexed)
self.assertIn("terminal-qf1-2", indexed)
self.assertFalse(any(key.startswith("local:QF1") for key in indexed))
def test_create_breakers_can_import_model_template_instead_of_placeholder_box(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "breaker.step"
model_path.write_text("fake step", encoding="utf-8")
report = batch_assembly.create_breakers(
doc,
rail,
base_name="QF",
count=1,
model_path=str(model_path),
)
imported_children = [child for child in report["devices"][0].Group if getattr(child, "ImportedPath", "")]
self.assertEqual(1, len(imported_children))
self.assertEqual(str(model_path), imported_children[0].QetBatchSourceModelPath)
self.assertEqual("QF1 模型", imported_children[0].Label)
if __name__ == "__main__":
unittest.main()

@ -1,5 +1,6 @@
import importlib import importlib
import json import json
import sqlite3
import sys import sys
import tempfile import tempfile
import types import types
@ -173,6 +174,34 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
self.assertEqual("device-inst-1", normalized["terminals"][0]["instance_id"]) self.assertEqual("device-inst-1", normalized["terminals"][0]["instance_id"])
self.assertEqual("device-inst-1", normalized["device_models"][0]["instance_id"]) self.assertEqual("device-inst-1", normalized["device_models"][0]["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",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
"wires": [
{
"wire_id": "wire-1",
"wire_label": "N4111",
"wire_style_id": 1,
"start_terminal_uuid": "terminal-a",
"end_terminal_uuid": "terminal-b",
}
],
}
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("N4111", normalized["wires"][0]["wire_label"])
self.assertEqual("1", normalized["wires"][0]["wire_style_id"])
def test_load_exchange_payload_rejects_legacy_root_terminals(self): def test_load_exchange_payload_rejects_legacy_root_terminals(self):
_install_fake_modules() _install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None) sys.modules.pop("ExchangeBootstrap", None)
@ -192,6 +221,101 @@ class ExchangeBootstrapWiringTest(unittest.TestCase):
with self.assertRaises(bootstrap.ExchangeValidationError): with self.assertRaises(bootstrap.ExchangeValidationError):
bootstrap.load_exchange_payload(str(path)) bootstrap.load_exchange_payload(str(path))
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": "1.2",
"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"
path.write_text(json.dumps(payload), encoding="utf-8")
connection = sqlite3.connect(str(db_path))
try:
connection.execute(
"""
CREATE TABLE wire_properties (
id INTEGER PRIMARY KEY,
project_uuid TEXT NOT NULL,
line_color TEXT
)
"""
)
connection.commit()
finally:
connection.close()
normalized = bootstrap.load_exchange_payload(str(path))
self.assertEqual(str(db_path), normalized["wire_style_database_path"])
def test_load_exchange_payload_detects_project_datafiles_wire_properties_database(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"schema_version": "1.2",
"project_uuid": "project-1",
"devices": [],
"device_models": [],
"wires": [{"wire_id": "wire-1", "wire_style_id": "1"}],
}
with tempfile.TemporaryDirectory() as temp_dir:
project_dir = Path(temp_dir) / "project-a"
exchange_dir = project_dir / ".qet_freecad"
data_dir = project_dir / "datafiles"
exchange_dir.mkdir(parents=True)
data_dir.mkdir(parents=True)
path = exchange_dir / "2d_to_3d.json"
db_path = data_dir / "project-local.db"
path.write_text(json.dumps(payload), encoding="utf-8")
connection = sqlite3.connect(str(db_path))
try:
connection.execute(
"""
CREATE TABLE wire_properties (
id INTEGER PRIMARY KEY,
project_uuid TEXT NOT NULL,
line_color TEXT
)
"""
)
connection.commit()
finally:
connection.close()
normalized = bootstrap.load_exchange_payload(str(path))
self.assertEqual(str(db_path), normalized["wire_style_database_path"])
def test_exchange_summary_includes_wire_style_database_path(self):
_install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None)
bootstrap = importlib.import_module("ExchangeBootstrap")
payload = {
"project_uuid": "project-1",
"devices": [],
"terminals": [],
"device_models": [],
"wires": [],
"wire_style_database_path": "D:/project/project-local.sqlite",
}
summary = bootstrap._build_summary(payload, "D:/project/2d_to_3d.json")
self.assertEqual(
"D:/project/project-local.sqlite",
summary["wire_style_database_path"],
)
def test_summary_message_includes_updated_device_label_change_details(self): def test_summary_message_includes_updated_device_label_change_details(self):
_install_fake_modules() _install_fake_modules()
sys.modules.pop("ExchangeBootstrap", None) sys.modules.pop("ExchangeBootstrap", None)

@ -203,6 +203,9 @@ class FakeDocument:
if target in getattr(obj, "InList", []): if target in getattr(obj, "InList", []):
obj.InList.remove(target) obj.InList.remove(target)
def recompute(self):
return None
def copyObject(self, source_obj, recursive): def copyObject(self, source_obj, recursive):
copies = {} copies = {}
@ -358,6 +361,108 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertIn(group_a, root_group.Group) self.assertIn(group_a, root_group.Group)
self.assertIn(group_b, root_group.Group) self.assertIn(group_b, root_group.Group)
def test_failed_cabinet_reimport_keeps_existing_model(self):
with tempfile.TemporaryDirectory() as temp_dir:
cabinet_path = Path(temp_dir) / "cabinet.step"
cabinet_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root_group = device_import._ensure_root_group(doc, None, "project-1")
cabinet = {
"location_id": 1,
"resolved_scene_path": str(cabinet_path),
"display_text": "Main Cabinet",
}
cabinet_group = device_import._ensure_cabinet_model_group(
doc,
root_group,
cabinet,
"project-1",
)
old_cabinet_path = str(Path(temp_dir) / "old-cabinet.step")
cabinet_group.QetCabinetResolvedScenePath = old_cabinet_path
old_body = doc.addObject("Part::Feature", "OldCabinetBody")
cabinet_group.addObject(old_body)
def failing_import(*_args, **_kwargs):
raise RuntimeError("simulated import failure")
device_import._import_model_into_group = failing_import
report = {
"cabinet_imported": 0,
"cabinet_added": 0,
"cabinet_reimported": 0,
"cabinet_reused": 0,
"cabinet_skipped_missing_model": 0,
"cabinet_skipped_missing_file": 0,
"cabinet_skipped_unsupported_format": 0,
"cabinet_skipped_import_error": 0,
"warnings": [],
}
device_import._import_cabinet_model(doc, root_group, cabinet, report)
self.assertEqual(1, report["cabinet_skipped_import_error"])
self.assertIs(doc.getObject("OldCabinetBody"), old_body)
self.assertIn(old_body, cabinet_group.Group)
self.assertEqual(old_cabinet_path, cabinet_group.QetCabinetResolvedScenePath)
def test_failed_device_reimport_keeps_existing_model(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "breaker.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root_group = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root_group,
"device-1",
"instance-1",
str(model_path),
"QF1",
0,
)
old_model_path = str(Path(temp_dir) / "old-breaker.step")
device_group.QetResolvedModelPath = old_model_path
old_body = doc.addObject("Part::Feature", "OldDeviceBody")
device_group.addObject(old_body)
def failing_import(*_args, **_kwargs):
raise RuntimeError("simulated import failure")
device_import._import_model_into_group = failing_import
sys.modules["DevicePreview"].find_main_exchange_document = lambda _name: doc
report = device_import.import_devices_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-1",
"instance_id": "instance-1",
"display_tag": "QF1",
}
],
"device_models": [
{
"element_uuid": "device-1",
"resolved_model_path": str(model_path),
}
],
}
)
self.assertEqual(1, report["skipped_import_error"])
self.assertIs(doc.getObject("OldDeviceBody"), old_body)
self.assertIn(old_body, device_group.Group)
self.assertEqual(old_model_path, device_group.QetResolvedModelPath)
def test_fcstd_import_preserves_template_slots_without_live_template_lcs(self): def test_fcstd_import_preserves_template_slots_without_live_template_lcs(self):
source = FakeDocument("Source", r"D:\models\breaker.FCStd") source = FakeDocument("Source", r"D:\models\breaker.FCStd")
_install_fake_freecad(source) _install_fake_freecad(source)
@ -374,6 +479,8 @@ class FcstdDeviceImportTest(unittest.TestCase):
terminal.Role = "Terminal" terminal.Role = "Terminal"
terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "")
terminal.QetTemplateSlotName = "D1" terminal.QetTemplateSlotName = "D1"
terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Template", "")
terminal.QetTerminalLocalRoutePointsJson = "[[0,0,0],[6,0,0],[6,18,0]]"
x_axis = source.addObject("App::Line", "Terminal_D1_XAxis") x_axis = source.addObject("App::Line", "Terminal_D1_XAxis")
terminal.OriginFeatures = [x_axis] terminal.OriginFeatures = [x_axis]
@ -402,6 +509,10 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertEqual("D1", slots[0]["name"]) self.assertEqual("D1", slots[0]["name"])
self.assertEqual(11.0, slots[0]["base"].x) self.assertEqual(11.0, slots[0]["base"].x)
self.assertEqual(90.0, slots[0]["rotation"]["angle"]) self.assertEqual(90.0, slots[0]["rotation"]["angle"])
self.assertEqual(
[[0.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 18.0, 0.0]],
slots[0]["local_route_points"],
)
self.assertIsNone(slots[0]["source_object"]) self.assertIsNone(slots[0]["source_object"])
def test_fcstd_import_keeps_link_dependencies_out_of_device_group(self): def test_fcstd_import_keeps_link_dependencies_out_of_device_group(self):
@ -489,6 +600,32 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertEqual("", device_group.QetTemplateSlotsJson) self.assertEqual("", device_group.QetTemplateSlotsJson)
self.assertEqual([], template_semantics.collect_terminal_hints(device_group)) self.assertEqual([], template_semantics.collect_terminal_hints(device_group))
def test_non_fcstd_import_restores_target_document_after_insert_changes_active_document(self):
source = FakeDocument("Source", r"D:\models\breaker.FCStd")
_install_fake_freecad(source)
app = sys.modules["FreeCAD"]
doc = FakeDocument("QETScene")
app.ActiveDocument = doc
device_group = doc.addObject("App::Part", "QETDevice_breaker")
device_import, _ = _reload_modules()
def insert_step_body(name, docName, merge, useLinkGroup):
doc.addObject("Part::Feature", "StepBody")
app.ActiveDocument = None
device_import.ImportGui.insert = insert_step_body
device_import._import_model_into_group(
doc,
device_group,
r"D:\models\breaker.step",
)
self.assertIs(doc, app.ActiveDocument)
self.assertIn("QETScene", app.set_active_document_calls)
def test_fcstd_import_detaches_removed_template_lcs_from_parent_group(self): def test_fcstd_import_detaches_removed_template_lcs_from_parent_group(self):
source = FakeDocument("Source", r"D:\models\breaker.FCStd") source = FakeDocument("Source", r"D:\models\breaker.FCStd")
_install_fake_freecad(source) _install_fake_freecad(source)

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
import importlib import importlib
import json
import sys import sys
import types import types
import unittest import unittest
@ -88,6 +89,30 @@ class FakeObject:
self.Group.append(child) self.Group.append(child)
class FakeVertex:
def __init__(self, point):
self.Point = point
class FakeEdge:
ShapeType = "Edge"
def __init__(self, start, end):
self.Vertexes = [FakeVertex(start), FakeVertex(end)]
class FakeShape:
def __init__(self, edges=None):
self.Edges = edges or []
class FakeSelectionItem:
def __init__(self, obj=None, sub_objects=None, picked_points=None):
self.Object = obj
self.SubObjects = sub_objects or []
self.PickedPoints = picked_points or []
class FakeDocument: class FakeDocument:
def __init__(self): def __init__(self):
self.Name = "TemplateDoc" self.Name = "TemplateDoc"
@ -132,6 +157,10 @@ class TemplateAuthoringTest(unittest.TestCase):
"校验模板端子", "校验模板端子",
template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"], template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"],
) )
self.assertEqual(
"设置端子局部路径",
template_authoring.CommandSetTemplateTerminalLocalRoute().GetResources()["MenuText"],
)
self.assertEqual( self.assertEqual(
"保存模板为 FCStd", "保存模板为 FCStd",
template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"], template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"],
@ -169,6 +198,54 @@ class TemplateAuthoringTest(unittest.TestCase):
self.assertEqual(30.0, terminal.Placement.Base.z) self.assertEqual(30.0, terminal.Placement.Base.z)
self.assertTrue(doc.recomputed) self.assertTrue(doc.recomputed)
def test_set_template_terminal_local_route_points_writes_json_property(self):
_install_fake_freecad()
template_authoring = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal = template_authoring.create_template_terminal(doc, "P1", app.Vector(1, 2, 3))
template_authoring.set_template_terminal_local_route_points(
terminal,
[
app.Vector(0, 0, 0),
[8, 0, 0],
{"x": 8, "y": 20, "z": 0},
],
)
self.assertIn("QetTerminalLocalRoutePointsJson", terminal.PropertiesList)
self.assertEqual(
[[0.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 20.0, 0.0]],
json.loads(terminal.QetTerminalLocalRoutePointsJson),
)
def test_set_selected_template_terminal_local_route_points_uses_terminal_local_coordinates(self):
_install_fake_freecad()
template_authoring = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal = template_authoring.create_template_terminal(doc, "P1", app.Vector(10, 0, 0))
path = doc.addObject("Part::Feature", "LocalExitPath")
path.Shape = FakeShape(
edges=[
FakeEdge(app.Vector(10, 0, 0), app.Vector(20, 0, 0)),
FakeEdge(app.Vector(20, 0, 0), app.Vector(20, 15, 0)),
]
)
template_authoring.set_selected_template_terminal_local_route_points(
[
FakeSelectionItem(obj=terminal),
FakeSelectionItem(obj=path),
]
)
self.assertEqual(
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 15.0, 0.0]],
json.loads(terminal.QetTerminalLocalRoutePointsJson),
)
def test_validate_template_terminals_reports_missing_slot_name(self): def test_validate_template_terminals_reports_missing_slot_name(self):
_install_fake_freecad() _install_fake_freecad()
template_authoring = _reload_modules() template_authoring = _reload_modules()

@ -211,6 +211,7 @@ class TemplateInstantiationTest(unittest.TestCase):
self.assertEqual(1, len(terminals)) self.assertEqual(1, len(terminals))
self.assertEqual("terminal-p1", terminals[0].QetTerminalUuid) self.assertEqual("terminal-p1", terminals[0].QetTerminalUuid)
self.assertEqual("qet", terminals[0].QetTerminalBindingMode) self.assertEqual("qet", terminals[0].QetTerminalBindingMode)
self.assertFalse(terminals[0].ViewObject.Visibility)
self.assertFalse(p1.ViewObject.Visibility) self.assertFalse(p1.ViewObject.Visibility)
def test_device_without_template_slots_reports_no_created_terminals(self): def test_device_without_template_slots_reports_no_created_terminals(self):

@ -153,6 +153,40 @@ class TemplateSemanticsRotationTest(unittest.TestCase):
self.assertEqual(0.0, slots[0]["rotation"]["axis"].y) self.assertEqual(0.0, slots[0]["rotation"]["axis"].y)
self.assertEqual(1.0, slots[0]["rotation"]["axis"].z) self.assertEqual(1.0, slots[0]["rotation"]["axis"].z)
def test_sidecar_keeps_terminal_local_route_points(self):
_install_fake_freecad()
template_semantics, _ = _reload_exchange_modules()
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "Relay.step"
model_path.write_text("", encoding="utf-8")
sidecar_path = Path(temp_dir) / "Relay.qet_template.json"
sidecar_path.write_text(
json.dumps(
{
"terminal_slots": [
{
"name": "A1",
"position": [10, 20, 30],
"local_route_points": [
[0, 0, 0],
{"x": 12, "y": 0, "z": 0},
[12, 25, 0],
],
}
]
}
),
encoding="utf-8",
)
slots = template_semantics.load_sidecar_terminal_slots(str(model_path))
self.assertEqual(
[[0.0, 0.0, 0.0], [12.0, 0.0, 0.0], [12.0, 25.0, 0.0]],
slots[0]["local_route_points"],
)
class TerminalSlotResolutionPolicyTest(unittest.TestCase): class TerminalSlotResolutionPolicyTest(unittest.TestCase):
def test_resolve_terminal_slots_returns_bbox_fallback_when_model_has_no_template_slots(self): def test_resolve_terminal_slots_returns_bbox_fallback_when_model_has_no_template_slots(self):

@ -1,4 +1,5 @@
import importlib import importlib
import json
import sys import sys
import types import types
import unittest import unittest
@ -191,6 +192,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual(1, len(terminals)) self.assertEqual(1, len(terminals))
self.assertEqual("terminal-a", terminals[0].QetTerminalUuid) self.assertEqual("terminal-a", terminals[0].QetTerminalUuid)
self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource) self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource)
self.assertFalse(terminals[0].ViewObject.Visibility)
def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self): def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self):
_install_fake_freecad() _install_fake_freecad()
@ -407,6 +409,87 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual("terminal-b", end_terminals[0].QetTerminalUuid) self.assertEqual("terminal-b", end_terminals[0].QetTerminalUuid)
self.assertEqual("device-b", end_terminals[0].QetElementUuid) self.assertEqual("device-b", end_terminals[0].QetElementUuid)
def test_import_reads_qet_terminals_embedded_in_devices(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",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"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",
}
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
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["synthesized_wire_endpoint_terminals"])
self.assertEqual(1, len(terminals))
self.assertEqual("device-a:terminal-p1", terminals[0].QetTerminalUuid)
self.assertEqual("device-a", terminals[0].QetElementUuid)
self.assertEqual("instance-a", terminals[0].QetInstanceId)
self.assertEqual("P1", terminals[0].Label)
self.assertFalse(terminals[0].QetTerminalUuid.startswith("local:"))
def test_import_prefers_terminal_element_uuid_over_conflicting_instance_id(self): def test_import_prefers_terminal_element_uuid_over_conflicting_instance_id(self):
_install_fake_freecad() _install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules() terminal_import, terminal_objects, device_import = _reload_modules()
@ -583,6 +666,91 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual(2, len(terminals)) self.assertEqual(2, len(terminals))
self.assertEqual(20.0, terminals["terminal-p2"].Placement.Base.x) self.assertEqual(20.0, terminals["terminal-p2"].Placement.Base.x)
self.assertEqual(10.0, terminals["terminal-p1"].Placement.Base.x) self.assertEqual(10.0, terminals["terminal-p1"].Placement.Base.x)
self.assertFalse(terminals["terminal-p2"].ViewObject.Visibility)
self.assertFalse(terminals["terminal-p1"].ViewObject.Visibility)
def test_import_copies_template_slot_local_route_points_to_terminal(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_objects.ensure_string_property(
device,
"QetTemplateSlotsJson",
"QET Exchange",
"Stored template slots",
json.dumps(
{
"terminal_slots": [
{
"name": "P1",
"label": "P1",
"base": [10, 0, 0],
"local_route_points": [[0, 0, 0], [8, 0, 0], [8, 20, 0]],
}
]
}
),
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
}
],
"terminals": [
{
"terminal_uuid": "terminal-p1",
"element_uuid": "device-a",
"instance_id": "instance-a",
"terminal_display": "P1",
},
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(1, len(terminals))
self.assertIn("QetTerminalLocalRoutePointsJson", terminals[0].PropertiesList)
self.assertEqual(
[[0.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 20.0, 0.0]],
json.loads(terminals[0].QetTerminalLocalRoutePointsJson),
)
def test_import_rebinds_existing_local_terminal_on_matching_template_slot(self): def test_import_rebinds_existing_local_terminal_on_matching_template_slot(self):
_install_fake_freecad() _install_fake_freecad()
@ -678,6 +846,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual("terminal-real-p1", local_terminal.QetTerminalUuid) self.assertEqual("terminal-real-p1", local_terminal.QetTerminalUuid)
self.assertEqual("instance-a", local_terminal.QetInstanceId) self.assertEqual("instance-a", local_terminal.QetInstanceId)
self.assertEqual(15.0, local_terminal.Placement.Base.x) self.assertEqual(15.0, local_terminal.Placement.Base.x)
self.assertFalse(local_terminal.ViewObject.Visibility)
if __name__ == "__main__": if __name__ == "__main__":

@ -42,6 +42,7 @@ def _install_fake_freecad():
class FakeObject: class FakeObject:
def __init__(self, name, type_id="App::DocumentObjectGroup"): def __init__(self, name, type_id="App::DocumentObjectGroup"):
self.Name = name self.Name = name
self.Label = name
self.TypeId = type_id self.TypeId = type_id
self.Group = [] self.Group = []
self.InList = [] self.InList = []
@ -131,5 +132,24 @@ class TemplateTerminalVisibilityTest(unittest.TestCase):
self.assertTrue(engineering_terminal.ViewObject.Visibility) self.assertTrue(engineering_terminal.ViewObject.Visibility)
class GroupSortingTest(unittest.TestCase):
def test_sort_group_children_uses_case_insensitive_natural_label_order(self):
_install_fake_freecad()
terminal_objects = _reload_module()
root = FakeObject("QETExchangeDevices")
for label in ["ID:10", "TAa", "id:2", "TAb", "C - 电容柜001", "ID:7"]:
child = FakeObject("QETDevice_" + label)
child.Label = label
root.addObject(child)
sorted_children = terminal_objects.sort_group_children(root)
self.assertEqual(
["C - 电容柜001", "id:2", "ID:7", "ID:10", "TAa", "TAb"],
[child.Label for child in sorted_children],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -124,11 +124,11 @@ class WiringImportTest(unittest.TestCase):
"group_uuid": "group-1", "group_uuid": "group-1",
"start_element_uuid": "device-a", "start_element_uuid": "device-a",
"start_instance_id": "instance-a", "start_instance_id": "instance-a",
"start_terminal_uuid": "terminal-a", "start_terminal_uuid": "device-a:terminal-a",
"start_terminal_display": "A1", "start_terminal_display": "A1",
"end_element_uuid": "device-b", "end_element_uuid": "device-b",
"end_instance_id": "instance-b", "end_instance_id": "instance-b",
"end_terminal_uuid": "terminal-b", "end_terminal_uuid": "device-b:terminal-b",
"end_terminal_display": "B1", "end_terminal_display": "B1",
} }
], ],
@ -164,9 +164,9 @@ class WiringImportTest(unittest.TestCase):
"wire_mark": "W001", "wire_mark": "W001",
"wire_mark_is_manual": True, "wire_mark_is_manual": True,
"start_element_uuid": "device-a", "start_element_uuid": "device-a",
"start_terminal_uuid": "terminal-a", "start_terminal_uuid": "device-a:terminal-a",
"end_element_uuid": "device-b", "end_element_uuid": "device-b",
"end_terminal_uuid": "terminal-b", "end_terminal_uuid": "device-b:terminal-b",
"start_terminal_display": "A1", "start_terminal_display": "A1",
"end_terminal_display": "B1", "end_terminal_display": "B1",
"conductor_uuids": ["conductor-1"], "conductor_uuids": ["conductor-1"],
@ -187,8 +187,8 @@ class WiringImportTest(unittest.TestCase):
self.assertEqual("group-1", task.QetGroupUuid) self.assertEqual("group-1", task.QetGroupUuid)
self.assertEqual("W001", task.QetWireMark) self.assertEqual("W001", task.QetWireMark)
self.assertTrue(task.QetWireMarkIsManual) self.assertTrue(task.QetWireMarkIsManual)
self.assertEqual("terminal-a", task.QetStartTerminalUuid) self.assertEqual("device-a:terminal-a", task.QetStartTerminalUuid)
self.assertEqual("terminal-b", task.QetEndTerminalUuid) self.assertEqual("device-b:terminal-b", task.QetEndTerminalUuid)
self.assertEqual("device-a", task.QetStartElementUuid) self.assertEqual("device-a", task.QetStartElementUuid)
self.assertEqual("device-b", task.QetEndElementUuid) self.assertEqual("device-b", task.QetEndElementUuid)
self.assertEqual("A1", task.QetStartTerminalDisplay) self.assertEqual("A1", task.QetStartTerminalDisplay)

@ -1,3 +1,4 @@
import json
import os import os
import sys import sys
import tempfile import tempfile
@ -341,6 +342,40 @@ class WiringTest(unittest.TestCase):
) )
self.assertEqual("face", payload["route_nodes"][2]["anchor_kind"]) self.assertEqual("face", payload["route_nodes"][2]["anchor_kind"])
def test_wire_payload_includes_auto_route_diagnostics_metadata(self):
_install_fake_freecad()
terminal_objects, wiring_objects, _manual_wiring, _write_back = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
wire = doc.addObject("Part::Feature", "QETWire_auto")
wire.Shape = [app.Vector(0, 0, 0), app.Vector(100, 0, 0)]
terminal_objects.ensure_string_property(wire, "QetProjectUuid", "QET Exchange", "", "project-1")
terminal_objects.ensure_string_property(wire, "QetWireUuid", "QET Exchange", "", "wire-1")
terminal_objects.ensure_string_property(wire, "QetWireLabel", "QET Exchange", "", "N4111")
terminal_objects.ensure_string_property(wire, "QetStartTerminalUuid", "QET Exchange", "", "terminal-start")
terminal_objects.ensure_string_property(wire, "QetEndTerminalUuid", "QET Exchange", "", "terminal-end")
terminal_objects.ensure_string_property(wire, "QetStartInstanceId", "QET Exchange", "", "instance-start")
terminal_objects.ensure_string_property(wire, "QetEndInstanceId", "QET Exchange", "", "instance-end")
terminal_objects.ensure_string_property(wire, "RouteType", "QET Exchange", "", "RoutedConnection")
terminal_objects.ensure_string_property(wire, "RouteStatus", "QET Exchange", "", "CollisionWarning")
terminal_objects.ensure_string_property(wire, "RouteMode", "QET Exchange", "", "EplanRoute")
terminal_objects.ensure_string_property(wire, "QetWireStyleId", "QET Exchange", "", "42")
terminal_objects.ensure_string_property(wire, "QetRouteLengthMm", "QET Exchange", "", "123.5")
terminal_objects.ensure_string_property(
wire,
"QetRouteDiagnosticsJson",
"QET Exchange",
"",
json.dumps({"collision_count": 1, "wire_style_id": "42"}),
)
payload = wiring_objects.wire_payload_from_object(wire)
self.assertEqual("42", payload["wire_style_id"])
self.assertEqual(123.5, payload["length_mm"])
self.assertEqual(1, payload["route_diagnostics"]["collision_count"])
def test_wire_writeback_omits_scene_routed_wire_payload(self): def test_wire_writeback_omits_scene_routed_wire_payload(self):
_install_fake_freecad() _install_fake_freecad()
terminal_objects, wiring_objects, manual_wiring, write_back = _reload_modules() terminal_objects, wiring_objects, manual_wiring, write_back = _reload_modules()

Loading…
Cancel
Save