diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 378b70b..41459a8 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -143,6 +143,16 @@ QetProjectUuid = Points = [Vector, Vector, ...] ``` +由线槽、安装板/柜面、过线孔等源对象自动生成的 carrier 还会记录来源: + +```text +QetRouteSourceName = +QetRouteSourceLabel = +QetRouteSourceKind = "WireDuct" | "RoutingRange" | "WiringCutOut" +``` + +这些属性只用于 FreeCAD 文档内部刷新和清理,不写入数据库,也不要求 QET 提供。 + carrier 统一放在: ```text @@ -324,6 +334,8 @@ src/Mod/FreeCADExchange/InitGui.py 生成线槽 carrier 时,系统除了 `WireDuct` 中心路径,还会在线槽两端生成 `WireDuctOpenEnd` 横向路径;对象名或标签包含 `Wiring Cut-Out`、`wire cutout`、`穿线孔`、`过线孔` 等语义时,会生成 `WiringCutOut` 穿线路径载体。 +自动生成的 carrier 会随源对象生命周期刷新:源对象仍有效时更新几何;安装板尺寸变化时同步增删 `RoutingRange` 网格线;源对象被删除或不再满足线槽/支撑面规则时,下一次生成布线路径网络会删除对应自动 carrier,并撤销该源对象的穿越/支撑面障碍模式。用户手工创建、没有源对象元数据的 carrier 不会被这一步自动删除。 + ### 5.3 布线连接功能 已完成: @@ -395,6 +407,7 @@ tests/python/freecad_exchange_auto_routing_test.py 21. 相邻线槽端点在容差内会被网络自动连通;端子接入会连接到最近的网络线段点,而不是只连接到已有端点。 22. 线槽端部会生成 `WireDuctOpenEnd` 横向路径,穿线孔/过线孔会生成 `WiringCutOut` carrier。 23. 导线会保存 routing track;网络检查会生成 `RoutingPathNetwork` 诊断对象。 +24. 自动生成的线槽、过线孔和支撑面 carrier 会在源对象移动、缩放、删除或失效后刷新/清理。 已完成 FreeCAD smoke: diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index 832da9f..5194266 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -22,6 +22,11 @@ ROUTE_CARRIER_KIND_WIRING_CUT_OUT = "WiringCutOut" ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath" ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange" ROUTE_CARRIER_KIND_TERMINAL_ACCESS = "TerminalAccess" +MANAGED_ROUTE_SOURCE_KINDS = { + ROUTE_CARRIER_KIND_WIRE_DUCT, + ROUTE_CARRIER_KIND_WIRING_CUT_OUT, + ROUTE_CARRIER_KIND_ROUTING_RANGE, +} PROPERTY_GROUP = "QET Routing" DEFAULT_NODE_TOLERANCE = 0.001 DEFAULT_SURFACE_LANE_SPACING = 100.0 @@ -872,10 +877,11 @@ def _detach_from_groups(doc, obj): pass -def clear_route_carriers(doc): - """Delete generated route carriers while keeping terminals and routed wires.""" +def _remove_route_carriers(doc, carriers): removed = 0 - for carrier in list(collect_route_carriers(doc)): + for carrier in list(carriers or []): + if carrier is None or not is_route_carrier(carrier): + continue _detach_from_groups(doc, carrier) try: if doc.getObject(getattr(carrier, "Name", "")) is not None: @@ -883,6 +889,12 @@ def clear_route_carriers(doc): removed += 1 except Exception: pass + return removed + + +def clear_route_carriers(doc): + """Delete generated route carriers while keeping terminals and routed wires.""" + removed = _remove_route_carriers(doc, collect_route_carriers(doc)) try: doc.recompute() except Exception: @@ -1528,6 +1540,40 @@ def _live_source_carriers(doc, source): return carriers +def _source_kind_value(source): + return (getattr(source, "QetRoutingSourceKind", "") or "").strip() + + +def _set_route_carrier_source_metadata(carrier, source, source_kind=""): + if carrier is None or source is None: + return + source_name = (getattr(source, "Name", "") or "").strip() + if not source_name: + return + kind = (source_kind or _source_kind_value(source)).strip() + TerminalObjects.ensure_string_property( + carrier, + "QetRouteSourceName", + PROPERTY_GROUP, + "FreeCAD source object name that generated this route carrier", + source_name, + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteSourceLabel", + PROPERTY_GROUP, + "FreeCAD source object label that generated this route carrier", + getattr(source, "Label", "") or source_name, + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteSourceKind", + PROPERTY_GROUP, + "Routing source kind that generated this route carrier", + kind, + ) + + def _remember_source_carriers(source, carriers): live_names = [ getattr(carrier, "Name", "") @@ -1535,6 +1581,9 @@ def _remember_source_carriers(source, carriers): if carrier is not None and getattr(carrier, "Name", "") ] if live_names: + source_kind = _source_kind_value(source) + for carrier in carriers or []: + _set_route_carrier_source_metadata(carrier, source, source_kind=source_kind) TerminalObjects.ensure_string_property( source, "QetRouteCarrierNamesJson", @@ -1591,6 +1640,7 @@ def _mark_wiring_cut_out_source(source, carrier): "Generated route carrier for this source", getattr(carrier, "Name", ""), ) + _remember_source_carriers(source, [carrier]) except Exception: pass @@ -1613,6 +1663,7 @@ def _mark_terminal_access_source(source, carrier): "Generated route carrier for this source", getattr(carrier, "Name", ""), ) + _remember_source_carriers(source, [carrier]) except Exception: pass @@ -1622,6 +1673,77 @@ def _live_source_carrier(doc, source): return carriers[0] if carriers else None +def _source_is_valid_for_kind(source, source_kind): + if source_kind == ROUTE_CARRIER_KIND_WIRE_DUCT: + return _is_wire_duct_candidate(source) + if source_kind == ROUTE_CARRIER_KIND_ROUTING_RANGE: + return _is_support_surface_candidate(source) + if source_kind == ROUTE_CARRIER_KIND_WIRING_CUT_OUT: + return _is_wiring_cut_out_candidate(source) + return True + + +def _clear_invalid_source_route_metadata(source): + for property_name in ( + "QetRouteCarrierName", + "QetRouteCarrierNamesJson", + "QetRoutingObstacleMode", + ): + if property_name not in getattr(source, "PropertiesList", []) and not getattr(source, property_name, ""): + continue + TerminalObjects.ensure_string_property( + source, + property_name, + PROPERTY_GROUP, + "Cleared invalid routing source metadata", + "", + ) + + +def _document_object_by_name(doc, name): + if doc is None or not name: + return None + try: + return doc.getObject(name) + except Exception: + return None + + +def cleanup_invalid_source_carriers(doc): + """Remove generated carriers whose FreeCAD source object is missing or invalid.""" + if doc is None: + return 0 + + removed = 0 + for carrier in list(collect_route_carriers(doc)): + source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() + source_kind = (getattr(carrier, "QetRouteSourceKind", "") or "").strip() + if source_kind not in MANAGED_ROUTE_SOURCE_KINDS or not source_name: + continue + if _document_object_by_name(doc, source_name) is None: + removed += _remove_route_carriers(doc, [carrier]) + + for source in list(getattr(doc, "Objects", []) or []): + if source is None or is_route_carrier(source): + continue + source_kind = _source_kind_value(source) + if source_kind not in MANAGED_ROUTE_SOURCE_KINDS: + continue + if not _route_source_carrier_names(source): + continue + if _source_is_valid_for_kind(source, source_kind): + continue + removed += _remove_route_carriers(doc, _live_source_carriers(doc, source)) + _clear_invalid_source_route_metadata(source) + + if removed: + try: + doc.recompute() + except Exception: + pass + return removed + + def detect_wire_duct_sources(doc, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): """Return document objects that look like wire ducts based on semantics/name and shape.""" sources = [] @@ -1672,6 +1794,7 @@ def prepare_layout_space_sources_from_document(doc, project_uuid=""): raise RoutingNetworkError("No FreeCAD document is available.") WiringObjects.ensure_wiring_root_group(doc, project_uuid) + cleanup_invalid_source_carriers(doc) wire_duct_sources = detect_wire_duct_sources(doc) support_surface_sources = detect_support_surface_sources(doc) @@ -1713,6 +1836,7 @@ def create_wire_duct_carriers_from_document( min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT, ): """Auto-detect wire duct objects in the document and create WireDuct centerlines.""" + cleanup_invalid_source_carriers(doc) created = [] for index, source in enumerate(detect_wire_duct_sources(doc, min_aspect=min_aspect), start=1): bbox = _bound_box_from_object(source) @@ -1765,6 +1889,7 @@ def create_wire_duct_carriers_from_document( def create_wiring_cut_out_carriers_from_document(doc, project_uuid=""): """Create pass-through route carriers for wiring cut-out objects.""" + cleanup_invalid_source_carriers(doc) created = [] for source in detect_wiring_cut_out_sources(doc): bbox = _bound_box_from_object(source) @@ -1808,6 +1933,7 @@ def create_surface_carriers_from_document( margin=DEFAULT_SURFACE_MARGIN, ): """Auto-detect thin support panels and create low-priority RoutingRange grids.""" + cleanup_invalid_source_carriers(doc) created = [] for source in detect_support_surface_sources(doc): bbox = _bound_box_from_object(source) @@ -2076,6 +2202,7 @@ def create_wire_duct_carriers_from_selection( min_aspect=1.5, ): """Create WireDuct centerline carriers from selected duct-like solids.""" + cleanup_invalid_source_carriers(doc) created = [] for index, source in enumerate(_wire_duct_sources_from_selection(selection_ex), start=1): bbox = _bound_box_from_object(source) diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 2960a64..1be017e 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -965,6 +965,37 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(60.0, max(x_values)) self.assertEqual(60.0, max(z_values)) + def test_auto_detect_support_surface_removes_carriers_and_obstacle_mode_when_source_invalid(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + + created = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 120, 0, 120)) + created_again = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + + self.assertEqual(6, len(created)) + self.assertEqual(0, len(created_again)) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + self.assertEqual("", getattr(panel, "QetRoutingObstacleMode", "")) + self.assertEqual("", getattr(panel, "QetRouteCarrierNamesJson", "")) + def test_eplan_connection_route_can_use_auto_detected_support_surface(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -1093,6 +1124,30 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual([(20.0, 0.0, 10.0), (200.0, 0.0, 10.0)], [(p.x, p.y, p.z) for p in main.Points]) self.assertEqual([20.0, 20.0, 200.0, 200.0], open_end_x_values) + def test_generate_routing_paths_removes_generated_wire_duct_carriers_after_source_deleted(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + + auto_routing_panel.AutoRoutingController().generate_routing_paths() + generated = [ + item + for item in routing_network.collect_route_carriers(doc) + if getattr(item, "QetRouteSourceName", "") == "WireDuctA" + ] + doc.removeObject("WireDuctA") + auto_routing_panel.AutoRoutingController().generate_routing_paths() + + self.assertEqual(3, len(generated)) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + def test_prepare_layout_space_uses_whole_document_not_selected_face_workflow(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules()