Improve QET scene sync and cabinet reuse

dev
zhanghao 4 weeks ago
parent 8a63e8de40
commit 33ae1d8e31

@ -0,0 +1,427 @@
# 2D / 3D 数据传递待办
本文只记录 QET 与 FreeCAD 第一版协同中,和数据传递、持久化、打开流程相关的待办事项。
第一版继续遵守当前约束:
- 2D 电气语义以 QET 为准。
- 3D 空间状态以 FreeCAD 文档为准。
- 3D 位姿、装配关系、端子空间位置、导线几何不写入数据库。
- 数据库只依赖:
- `project_2d3d_symbol_binding`
- `project_2d3d_terminal_binding`
- 设备绑定只依赖:
- `project_uuid`
- `element_uuid`
- `instance_id`
- 端子绑定只依赖:
- `project_uuid`
- `terminal_uuid`
- `instance_id`
- 第一版 3D 端子绑定唯一依据是 `terminal_uuid`
## 1. QET 读取 3d_to_2d.json
### 1.1 当前状态
FreeCAD 侧已经可以生成:
```text
<ProjectRoot>/.qet_freecad/3d_to_2d.json
```
该文件用于把 FreeCAD 创建或维护的 3D 实例 ID 回传给 QET。
当前最关键缺口是:
> QET 侧还需要读取 `3d_to_2d.json`,并把里面的实例绑定写回项目运行库。
### 1.2 要写回的表
设备实例绑定写入:
```text
project_2d3d_symbol_binding(project_uuid, element_uuid, instance_id)
```
端子实例绑定写入:
```text
project_2d3d_terminal_binding(project_uuid, terminal_uuid, instance_id)
```
### 1.3 为什么要做
第一次打开 FreeCAD 时QET 可能只知道:
```text
element_uuid
terminal_uuid
resolved_model_path
```
此时 `instance_id` 可能为空。FreeCAD 创建 3D 设备实例后,会生成并保存 `QetInstanceId`
QET 读取 `3d_to_2d.json` 后,就能记住:
```text
element_uuid -> instance_id
terminal_uuid -> instance_id
```
这样下次 QET 再导出 `2d_to_3d.json` 时,可以带上已有 `instance_id`FreeCAD 就能复用已有 3D 实例,而不是重复创建。
## 2. 3D 工程文件持久化
### 2.1 当前约定
第一版 3D 工程本体是:
```text
<ProjectRoot>/.qet_freecad/scene.FCStd
```
交换目录建议保持为:
```text
<ProjectRoot>/.qet_freecad/
2d_to_3d.json
3d_to_2d.json
scene.FCStd
logs/
```
### 2.2 FreeCAD 文档负责保存
`scene.FCStd` 保存:
- 设备 3D 实例对象
- 设备位姿
- 柜内装配关系
- 端子 LCS 和空间位置
- 手动导线几何
- 自动布线结果
- 走线路径和路由网络
这些内容不进入数据库。
### 2.3 QET 要做什么
QET 下次打开同一个工程或同一个机柜时,应优先打开已有:
```text
<ProjectRoot>/.qet_freecad/scene.FCStd
```
不要重新创建空场景。
如果后续支持一个项目多个机柜,则 QET 还需要保存或推导:
```text
某个机柜 -> 对应哪个 scene.FCStd
```
第一版可以先使用默认工程文件:
```text
.qet_freecad/scene.FCStd
```
## 3. 2D 原理图更新后同步到 3D
### 3.1 当前推荐方式
第一版不做实时同步。
推荐在用户点击 QET 的 `3D视图` 时执行一次同步:
```text
QET 重新导出 2d_to_3d.json
FreeCAD 打开已有 scene.FCStd
FreeCAD 根据最新 JSON 增量更新 3D 文档
```
### 3.2 FreeCAD 更新依据
FreeCAD 应按下面字段识别对象:
```text
设备element_uuid / instance_id
端子terminal_uuid / instance_id
```
处理策略:
- `instance_id` 已存在,并且 FreeCAD 文档中找到对象:复用并更新语义。
- `instance_id` 为空:创建新的 3D 实例。
- 2D 新增设备或端子FreeCAD 新增对应 3D 对象。
- 2D 已删除但 FreeCAD 仍存在的对象:需要定义失效处理策略。
### 3.3 删除或失效对象策略
后续需要明确:
- 直接删除 3D 对象
- 标记为失效
- 隐藏但保留
- 提示用户确认后删除
第一版建议先采用保守策略:
```text
标记失效或提示用户,不自动删除已有 3D 装配和接线。
```
## 4. 3D 保存后 2D 再打开时打开保存的工程
这和“2D 原理图更新后同步到 3D”不是一件事。
### 4.1 打开保存的工程解决的问题
它解决的是:
```text
QET 应该打开哪个 FreeCAD 文件?
```
第一版默认打开:
```text
<ProjectRoot>/.qet_freecad/scene.FCStd
```
### 4.2 2D 更新同步解决的问题
它解决的是:
```text
打开已有 scene.FCStd 后FreeCAD 应该按最新 2D 原理图新增、更新或标记哪些设备、端子、导线任务?
```
### 4.3 两者关系
完整动作通常发生在同一个入口中:
```text
用户点击 3D视图
QET 读取上一轮 3d_to_2d.json
QET 更新绑定表
QET 重新导出 2d_to_3d.json
QET 启动 FreeCAD 并打开已有 scene.FCStd
FreeCAD 根据 2d_to_3d.json 增量更新文档
```
### 4.4 3D视图入口确认流程
当前确认的 `3D视图` 入口顺序为:
```text
用户点击 3D视图
1. QET 检查 .qet_freecad/3d_to_2d.json 是否存在
2. 如果存在,读取并校验 project_uuid
3. 把 instances[] 写入 project_2d3d_symbol_binding
4. 把 terminals[] 写入 project_2d3d_terminal_binding
5. 然后重新导出最新 2d_to_3d.json
6. 启动 FreeCAD打开已有 scene.FCStd
```
这个顺序的关键点是:
```text
先读取 3D 回写并更新绑定表
再导出新的 2D -> 3D 快照
```
否则本次导出的 `2d_to_3d.json` 仍然拿不到上一轮 FreeCAD 生成的 `instance_id`
## 5. 设备 3D 资产传递
### 5.1 当前状态
QET -> FreeCAD 方向已经具备设备资产传递。
`2d_to_3d.json` 中已有:
```text
device_models[]
element_uuid
device_id
parts_3d
resolved_model_path
```
FreeCAD 使用 `resolved_model_path` 导入 3D 模型。
### 5.2 后续要求
后续不是从零实现,而是继续稳定以下规则:
- 优先使用 `device_3d_asset`
- `device_attribute.parts_3d` 只作为兼容或回退字段。
- QET 导出给 FreeCAD 的关键字段是 `resolved_model_path`
- FreeCAD 不反查 QET 数据库来寻找模型路径。
- `.FCStd` 应作为正式可复用设备资产格式。
- STEP / STP / STE 更适合作为制作 FCStd 模板的原始几何输入。
## 6. 导线数据传递
### 6.1 当前状态
QET -> FreeCAD 方向已经具备导线任务传递。
`2d_to_3d.json` 中已有:
```text
wires[]
wire_id
net_uuid
group_uuid
wire_mark
wire_mark_is_manual
start_element_uuid
start_terminal_uuid
end_element_uuid
end_terminal_uuid
start_terminal_display
end_terminal_display
```
FreeCAD 侧已经可以把 `wires[]` 导入为导线任务。
### 6.2 后续可能扩展
如果后续 QET 需要读取 3D 布线状态,可以扩展 `3d_to_2d.json`,例如:
```text
wire_id
route_status
route_type
length
diagnostics
```
### 6.3 第一版不做
第一版不把下面内容写进数据库:
- 3D 路径点
- 导线空间几何
- 线束位姿
- 线槽内具体排布
这些仍保存在:
```text
scene.FCStd
```
## 7. 推荐实施步骤
### 步骤 1补 QET 读取 3d_to_2d.json
在 QET 侧新增或接入一个读取流程:
```text
读取 <ProjectRoot>/.qet_freecad/3d_to_2d.json
校验 project_uuid
读取 instances[]
读取 terminals[]
```
校验失败时不写库,并给出提示或日志。
### 步骤 2写回两张绑定表
`instances[]` 执行 upsert
```text
project_2d3d_symbol_binding:
project_uuid
element_uuid
instance_id
```
`terminals[]` 执行 upsert
```text
project_2d3d_terminal_binding:
project_uuid
terminal_uuid
instance_id
```
第一版不要写入旧 3D 场景表,也不要写入位姿字段。
### 步骤 3把读取回写接到 3D视图入口前
用户点击 `3D视图` 时,建议先执行:
```text
读取上一轮 3d_to_2d.json
更新绑定表
重新导出 2d_to_3d.json
启动 FreeCAD
```
这样新导出的 `2d_to_3d.json` 就能带上最新 `instance_id`
### 步骤 4确认 scene.FCStd 打开策略
QET 启动 FreeCAD 时:
- 如果 `.qet_freecad/scene.FCStd` 已存在,打开它。
- 如果不存在,允许 FreeCAD 创建新的工程文档。
- 后续如果接入机柜维度,则按机柜映射选择对应 FCStd。
### 步骤 5确认 FreeCAD 增量更新策略
FreeCAD 打开 `scene.FCStd` 并读取新的 `2d_to_3d.json` 后:
- 已存在实例:复用。
- 新设备:创建。
- 新端子:创建。
- 失效设备或端子:先标记或提示,不建议第一版自动删除。
### 步骤 6保留设备资产和导线任务现有链路
设备 3D 资产继续走:
```text
device_3d_asset -> resolved_model_path -> FreeCAD
```
导线任务继续走:
```text
QET wires[] -> FreeCAD QETWiring_01_Tasks
```
第一版暂不要求 QET 读取 3D 导线几何。
### 步骤 7后续再扩展 3D 布线状态回传
等最小绑定闭环稳定后,再考虑在 `3d_to_2d.json` 中增加:
```text
routed_wires[]
wire_id
route_status
route_type
length
diagnostics
```
该扩展只作为状态、统计、诊断回传,不作为第一版数据库几何持久化。
## 8. 第一版完成标准
第一版数据传递闭环完成后,应满足:
1. 第一次从 QET 打开 FreeCAD`instance_id` 可以为空。
2. FreeCAD 创建 3D 实例并保存 `scene.FCStd`
3. FreeCAD 生成 `3d_to_2d.json`
4. QET 能读取 `3d_to_2d.json`
5. QET 能写入两张绑定表。
6. 第二次点击 `3D视图`QET 导出的 `2d_to_3d.json` 中已有 `instance_id`
7. FreeCAD 打开已有 `scene.FCStd`,复用已有 3D 实例。
8. 3D 位姿、装配、导线几何仍只保存在 `scene.FCStd`

@ -13,6 +13,8 @@ set(FreeCADExchange_Scripts
TerminalImport.py TerminalImport.py
WiringObjects.py WiringObjects.py
WiringImport.py WiringImport.py
StaleObjectSync.py
StaleObjectActions.py
RoutingNetwork.py RoutingNetwork.py
AutoRouting.py AutoRouting.py
AutoRoutingPanel.py AutoRoutingPanel.py

@ -21,6 +21,7 @@ import TerminalObjects
ROOT_GROUP_NAME = "QETExchangeDevices" ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices" ROOT_GROUP_LABEL = "QET Exchange Devices"
CABINET_MODEL_GROUP_NAME = "QETCabinetModel" CABINET_MODEL_GROUP_NAME = "QETCabinetModel"
CABINET_GROUP_PREFIX = "QETCabinet_"
DEVICE_GROUP_PREFIX = "QETDevice_" DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_" TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_" WIRE_GROUP_PREFIX = "QETWires_"
@ -81,6 +82,13 @@ def _native_path(value):
return os.path.normpath(os.path.expandvars(os.path.expanduser(text))) return os.path.normpath(os.path.expandvars(os.path.expanduser(text)))
def _normalized_path_key(value):
text = _native_path(value)
if not text:
return ""
return os.path.normcase(os.path.normpath(text))
def _existing_object_names(doc): def _existing_object_names(doc):
return {obj.Name for obj in doc.Objects} return {obj.Name for obj in doc.Objects}
@ -318,13 +326,79 @@ def _ensure_root_group(doc, cabinet=None, project_uuid=""):
return root return root
def _ensure_cabinet_model_group(doc, root_group): def _cabinet_instance_id(cabinet):
group = doc.getObject(CABINET_MODEL_GROUP_NAME) if not isinstance(cabinet, dict):
return ""
for field_name in ("cabinet_instance_id", "cabinet_uuid", "location_id"):
value = cabinet.get(field_name)
if value is None:
continue
text = str(value).strip()
if text:
return text
return "default"
def _cabinet_group_label(cabinet):
label = _cabinet_label_text(cabinet)
if label:
return label
return "3D机柜"
def _find_cabinet_group(doc, cabinet_instance_id):
target_id = (cabinet_instance_id or "").strip()
preferred_name = CABINET_GROUP_PREFIX + _safe_token(target_id or "default")
group = doc.getObject(preferred_name)
if group is not None:
return group
legacy_group = doc.getObject(CABINET_MODEL_GROUP_NAME)
if legacy_group is not None:
legacy_instance_id = getattr(legacy_group, "QetCabinetInstanceId", "").strip()
if not target_id or not legacy_instance_id or legacy_instance_id == target_id:
return legacy_group
for candidate in getattr(doc, "Objects", []) or []:
if "QetCabinetInstanceId" not in getattr(candidate, "PropertiesList", []):
continue
if getattr(candidate, "QetCabinetInstanceId", "").strip() == target_id:
return candidate
return None
def _ensure_cabinet_model_group(doc, root_group, cabinet=None, project_uuid=""):
cabinet_instance_id = _cabinet_instance_id(cabinet)
group = _find_cabinet_group(doc, cabinet_instance_id)
if group is None: if group is None:
group = doc.addObject("App::DocumentObjectGroup", CABINET_MODEL_GROUP_NAME) group = doc.addObject(
group.Label = "3D机柜" "App::DocumentObjectGroup",
CABINET_GROUP_PREFIX + _safe_token(cabinet_instance_id or "default"),
)
group.Label = _cabinet_group_label(cabinet)
if group not in getattr(root_group, "Group", []): if group not in getattr(root_group, "Group", []):
root_group.addObject(group) root_group.addObject(group)
_ensure_string_property(
group,
"QetCabinetInstanceId",
"QET Exchange",
"Cabinet instance id from QET exchange",
cabinet_instance_id,
)
_ensure_string_property(
group,
"QetCabinetResolvedScenePath",
"QET Exchange",
"Resolved local cabinet scene path from QET exchange",
cabinet.get("resolved_scene_path", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
return group return group
@ -565,6 +639,14 @@ def _clear_group_contents(doc, group):
_remove_object_tree(doc, child) _remove_object_tree(doc, child)
def _existing_group_objects(doc, group):
result = []
for child in list(getattr(group, "Group", []) or []):
if _object_exists(doc, child):
result.append(child)
return result
def _is_exchange_sidecar_group(obj): def _is_exchange_sidecar_group(obj):
child_name = _object_name(obj) child_name = _object_name(obj)
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX): if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
@ -816,7 +898,22 @@ def _import_cabinet_model(doc, root_group, cabinet, report):
) )
return return
cabinet_group = _ensure_cabinet_model_group(doc, root_group) project_uuid = getattr(root_group, "QetProjectUuid", "").strip()
cabinet_group = _ensure_cabinet_model_group(doc, root_group, cabinet, project_uuid)
existing_model_objects = _existing_group_objects(doc, cabinet_group)
previous_path = getattr(cabinet_group, "QetCabinetResolvedScenePath", "").strip()
same_source = _normalized_path_key(previous_path) == _normalized_path_key(resolved_scene_path)
if existing_model_objects and same_source:
report.setdefault("cabinet_reused", 0)
report["cabinet_reused"] += 1
_append_debug_log(
"DeviceImport cabinet import skipped: reused existing cabinet group for instance_id={0}".format(
getattr(cabinet_group, "QetCabinetInstanceId", "").strip()
)
)
return
had_existing_model = bool(existing_model_objects)
_clear_group_contents(doc, cabinet_group) _clear_group_contents(doc, cabinet_group)
_ensure_string_property( _ensure_string_property(
cabinet_group, cabinet_group,
@ -839,6 +936,12 @@ def _import_cabinet_model(doc, root_group, cabinet, report):
use_link_group=True, use_link_group=True,
) )
report["cabinet_imported"] += 1 report["cabinet_imported"] += 1
if had_existing_model:
report.setdefault("cabinet_reimported", 0)
report["cabinet_reimported"] += 1
else:
report.setdefault("cabinet_added", 0)
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:
report["cabinet_skipped_import_error"] += 1 report["cabinet_skipped_import_error"] += 1
@ -870,6 +973,9 @@ def import_devices_from_payload(payload, scene_path=""):
"skipped_unsupported_format": 0, "skipped_unsupported_format": 0,
"skipped_import_error": 0, "skipped_import_error": 0,
"cabinet_imported": 0, "cabinet_imported": 0,
"cabinet_added": 0,
"cabinet_reimported": 0,
"cabinet_reused": 0,
"cabinet_skipped_missing_model": 0, "cabinet_skipped_missing_model": 0,
"cabinet_skipped_missing_file": 0, "cabinet_skipped_missing_file": 0,
"cabinet_skipped_unsupported_format": 0, "cabinet_skipped_unsupported_format": 0,

@ -23,6 +23,10 @@ try:
import WiringImport import WiringImport
except Exception: except Exception:
WiringImport = None WiringImport = None
try:
import StaleObjectSync
except Exception:
StaleObjectSync = None
try: try:
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
@ -42,6 +46,7 @@ STATE_SUMMARY = "_qet_exchange_summary"
STATE_IMPORT_REPORT = "_qet_exchange_import_report" STATE_IMPORT_REPORT = "_qet_exchange_import_report"
STATE_TERMINAL_IMPORT_REPORT = "_qet_exchange_terminal_import_report" STATE_TERMINAL_IMPORT_REPORT = "_qet_exchange_terminal_import_report"
STATE_WIRING_IMPORT_REPORT = "_qet_exchange_wiring_import_report" STATE_WIRING_IMPORT_REPORT = "_qet_exchange_wiring_import_report"
STATE_STALE_SYNC_REPORT = "_qet_exchange_stale_sync_report"
STATE_WRITEBACK_REPORT = "_qet_exchange_writeback_report" STATE_WRITEBACK_REPORT = "_qet_exchange_writeback_report"
STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled" STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled"
STATE_TREE_FILTER = "_qet_exchange_tree_filter" STATE_TREE_FILTER = "_qet_exchange_tree_filter"
@ -653,9 +658,65 @@ def _import_wiring_tasks(payload):
return None return None
def _summary_message(summary, import_report=None, terminal_report=None, writeback_report=None, wiring_report=None): def _scene_path_from_exchange_context():
scene_path = os.environ.get(ENV_SCENE_PATH, "").strip()
if scene_path:
return scene_path
json_path = os.environ.get(ENV_JSON_PATH, "").strip()
if not json_path:
return ""
exchange_dir = Path(json_path).parent
for file_name in ("QETScene.FCStd", "scene.FCStd"):
candidate = exchange_dir / file_name
if candidate.is_file():
resolved = str(candidate)
os.environ[ENV_SCENE_PATH] = resolved
_append_debug_log(
"QET_FREECAD_SCENE_FILE inferred from exchange directory: {0}".format(
resolved
)
)
return resolved
return ""
def _mark_stale_objects(payload):
if StaleObjectSync is None:
_append_debug_log("stale object sync skipped: StaleObjectSync module unavailable")
return None
try:
return StaleObjectSync.mark_stale_objects_from_payload(
payload,
App.ActiveDocument,
)
except Exception as exc:
_append_debug_log("stale object sync failed: {0}".format(exc))
_append_debug_log(traceback.format_exc())
return None
def _summary_message(summary, import_report=None, terminal_report=None, writeback_report=None, wiring_report=None, stale_report=None):
lines = [ lines = [
"QET exchange file loaded successfully.", "QET exchange file loaded successfully.",
]
if import_report or stale_report:
lines.extend(
[
"",
"同步结果:",
"新增机柜:{0}".format(import_report.get("cabinet_added", 0) if import_report else 0),
"失效机柜:{0}".format(stale_report.get("stale_cabinets", 0) if stale_report else 0),
"新增设备:{0}".format(import_report.get("imported_devices", 0) if import_report else 0),
"失效设备:{0}".format(stale_report.get("stale_devices", 0) if stale_report else 0),
]
)
lines.extend(
[
"", "",
"Project UUID: {0}".format(summary["project_uuid"]), "Project UUID: {0}".format(summary["project_uuid"]),
"Exchange file: {0}".format(summary["json_path"]), "Exchange file: {0}".format(summary["json_path"]),
@ -665,6 +726,7 @@ def _summary_message(summary, import_report=None, terminal_report=None, writebac
"Device models: {0}".format(summary["device_model_count"]), "Device models: {0}".format(summary["device_model_count"]),
"Resolved model paths: {0}".format(summary["device_models_with_parts"]), "Resolved model paths: {0}".format(summary["device_models_with_parts"]),
] ]
)
cabinet = summary.get("cabinet") cabinet = summary.get("cabinet")
if isinstance(cabinet, dict): if isinstance(cabinet, dict):
@ -708,6 +770,9 @@ def _summary_message(summary, import_report=None, terminal_report=None, writebac
"3D device import summary:", "3D device import summary:",
"Target document: {0}".format(import_report["document_name"]), "Target document: {0}".format(import_report["document_name"]),
"Imported cabinet models: {0}".format(import_report["cabinet_imported"]), "Imported cabinet models: {0}".format(import_report["cabinet_imported"]),
"Added cabinets: {0}".format(import_report.get("cabinet_added", 0)),
"Reimported cabinets: {0}".format(import_report.get("cabinet_reimported", 0)),
"Reused cabinets: {0}".format(import_report.get("cabinet_reused", 0)),
"Imported devices: {0}".format(import_report["imported_devices"]), "Imported devices: {0}".format(import_report["imported_devices"]),
"Updated devices: {0}".format(import_report["updated_devices"]), "Updated devices: {0}".format(import_report["updated_devices"]),
] ]
@ -795,6 +860,24 @@ def _summary_message(summary, import_report=None, terminal_report=None, writebac
if len(warnings) > 10: if len(warnings) > 10:
lines.append("- ... ({0} more)".format(len(warnings) - 10)) lines.append("- ... ({0} more)".format(len(warnings) - 10))
if stale_report:
lines.extend(
[
"",
"3D stale object sync:",
"Active cabinets: {0}".format(stale_report.get("active_cabinets", 0)),
"Stale cabinets: {0}".format(stale_report.get("stale_cabinets", 0)),
"Active devices: {0}".format(stale_report.get("active_devices", 0)),
"Stale devices: {0}".format(stale_report.get("stale_devices", 0)),
"Active terminals: {0}".format(stale_report.get("active_terminals", 0)),
"Stale terminals: {0}".format(stale_report.get("stale_terminals", 0)),
"Active wire tasks: {0}".format(stale_report.get("active_wire_tasks", 0)),
"Stale wire tasks: {0}".format(stale_report.get("stale_wire_tasks", 0)),
"Active routed wires: {0}".format(stale_report.get("active_routed_wires", 0)),
"Stale routed wires: {0}".format(stale_report.get("stale_routed_wires", 0)),
]
)
lines.append("") lines.append("")
lines.append("This step validates the exchange payload and imports devices with valid resolved model paths.") lines.append("This step validates the exchange payload and imports devices with valid resolved model paths.")
lines.append("3D terminal import and write-back are enabled.") lines.append("3D terminal import and write-back are enabled.")
@ -832,7 +915,7 @@ def _run_scheduled_device_import(attempt=0):
) )
return return
scene_path = os.environ.get(ENV_SCENE_PATH, "").strip() scene_path = _scene_path_from_exchange_context()
_append_debug_log( _append_debug_log(
"scheduled device import starting with scene_path={0}".format(scene_path) "scheduled device import starting with scene_path={0}".format(scene_path)
) )
@ -917,6 +1000,10 @@ def _run_scheduled_device_import(attempt=0):
if wiring_report is not None: if wiring_report is not None:
setattr(App, STATE_WIRING_IMPORT_REPORT, wiring_report) setattr(App, STATE_WIRING_IMPORT_REPORT, wiring_report)
stale_report = _mark_stale_objects(payload)
if stale_report is not None:
setattr(App, STATE_STALE_SYNC_REPORT, stale_report)
if ExchangeWriteBack is None: if ExchangeWriteBack is None:
_append_debug_log("write-back skipped: ExchangeWriteBack module unavailable") _append_debug_log("write-back skipped: ExchangeWriteBack module unavailable")
writeback_report = None writeback_report = None
@ -942,7 +1029,7 @@ def _run_scheduled_device_import(attempt=0):
_show_info( _show_info(
"QET Exchange", "QET Exchange",
_summary_message(summary, import_report, terminal_report, writeback_report, wiring_report), _summary_message(summary, import_report, terminal_report, writeback_report, wiring_report, stale_report),
) )
_append_debug_log("summary dialog shown") _append_debug_log("summary dialog shown")
@ -988,6 +1075,7 @@ def bootstrap_if_requested():
_append_debug_log(traceback.format_exc()) _append_debug_log(traceback.format_exc())
raise raise
_scene_path_from_exchange_context()
summary = _build_summary(payload, json_path) summary = _build_summary(payload, json_path)
_append_debug_log( _append_debug_log(
"payload loaded: devices={0}, terminals={1}, models={2}, cabinet={3}".format( "payload loaded: devices={0}, terminals={1}, models={2}, cabinet={3}".format(

@ -19,6 +19,9 @@ COMMANDS = [
"QET_Exchange_AutoRouteSelected", "QET_Exchange_AutoRouteSelected",
"QET_Exchange_AutoRouteAll", "QET_Exchange_AutoRouteAll",
"QET_Exchange_OpenAutoRoutingPanel", "QET_Exchange_OpenAutoRoutingPanel",
"QET_Exchange_HideStaleObjects",
"QET_Exchange_ShowStaleObjects",
"QET_Exchange_SummarizeStaleObjects",
] ]
@ -70,6 +73,7 @@ def _register_exchange_commands(
auto_routing_panel = safe_import("AutoRoutingPanel") auto_routing_panel = safe_import("AutoRoutingPanel")
manual_wiring = safe_import("ManualWiring") manual_wiring = safe_import("ManualWiring")
manual_wiring_panel = safe_import("ManualWiringPanel") manual_wiring_panel = safe_import("ManualWiringPanel")
stale_object_actions = safe_import("StaleObjectActions")
template_authoring = safe_import("TemplateAuthoring") template_authoring = safe_import("TemplateAuthoring")
template_authoring_panel = safe_import("TemplateAuthoringPanel") template_authoring_panel = safe_import("TemplateAuthoringPanel")
template_instantiation = safe_import("TemplateInstantiation") template_instantiation = safe_import("TemplateInstantiation")
@ -134,6 +138,16 @@ def _register_exchange_commands(
) )
) )
try:
if stale_object_actions is not None:
stale_object_actions.register_commands()
except Exception:
append_init_log(
"InitGui failed to register stale object commands:\n{0}".format(
traceback_module.format_exc()
)
)
try: try:
if template_authoring is not None: if template_authoring is not None:
template_authoring.register_commands() template_authoring.register_commands()

@ -0,0 +1,294 @@
# FreeCADExchange stale object user actions.
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
try:
from PySide6 import QtWidgets
except ImportError:
try:
from PySide2 import QtWidgets
except ImportError:
try:
from PySide import QtGui as QtWidgets
except ImportError:
QtWidgets = None
import TerminalObjects
SYNC_STATUS_STALE = "Stale"
CABINET_GROUP_PREFIX = "QETCabinet_"
def _active_document(doc=None):
if doc is not None:
return doc
return getattr(App, "ActiveDocument", None)
def _has_property(obj, prop_name):
return prop_name in getattr(obj, "PropertiesList", [])
def _sync_status(obj):
if not _has_property(obj, "QetSyncStatus"):
return ""
return (getattr(obj, "QetSyncStatus", "") or "").strip()
def _is_stale_object(obj):
return _sync_status(obj) == SYNC_STATUS_STALE
def _is_device(obj):
name = getattr(obj, "Name", "")
return name.startswith(TerminalObjects.DEVICE_GROUP_PREFIX) and _has_property(
obj,
"QetElementUuid",
)
def _is_cabinet(obj):
name = getattr(obj, "Name", "")
return (
(name.startswith(CABINET_GROUP_PREFIX) or name == "QETCabinetModel")
and _has_property(obj, "QetCabinetInstanceId")
)
def _is_terminal(obj):
return _has_property(obj, "QetTerminalUuid") or (
(getattr(obj, "Role", "") or "").strip() == TerminalObjects.TERMINAL_ROLE
)
def _is_wire_task(obj):
return (
_has_property(obj, "QetWireUuid")
and (getattr(obj, "RouteType", "") or "").strip() == "Task"
)
def _is_routed_wire(obj):
if not _has_property(obj, "QetWireUuid"):
return False
if (getattr(obj, "RouteType", "") or "").strip() == "Task":
return False
for parent in getattr(obj, "InList", []) or []:
if getattr(parent, "Name", "") == "QETWiring_04_Routed":
return True
return False
def _category(obj):
if _is_cabinet(obj):
return "cabinets"
if _is_device(obj):
return "devices"
if _is_terminal(obj):
return "terminals"
if _is_wire_task(obj):
return "wire_tasks"
if _is_routed_wire(obj):
return "routed_wires"
return "other"
def iter_stale_objects(doc=None):
doc = _active_document(doc)
if doc is None:
return
for obj in list(getattr(doc, "Objects", []) or []):
if _is_stale_object(obj):
yield obj
def _iter_object_tree(obj):
seen = set()
def visit(item):
if item is None or id(item) in seen:
return
seen.add(id(item))
yield item
for child in list(getattr(item, "Group", []) or []):
for nested in visit(child):
yield nested
for result in visit(obj):
yield result
def _set_visible(obj, visible):
view_object = getattr(obj, "ViewObject", None)
if view_object is None or not hasattr(view_object, "Visibility"):
return False
view_object.Visibility = bool(visible)
return True
def summarize_stale_objects(doc=None):
doc = _active_document(doc)
report = {
"document_name": getattr(doc, "Name", "") if doc is not None else "",
"total": 0,
"cabinets": 0,
"devices": 0,
"terminals": 0,
"wire_tasks": 0,
"routed_wires": 0,
"other": 0,
"objects": [],
}
if doc is None:
return report
for obj in iter_stale_objects(doc):
category = _category(obj)
report["total"] += 1
report[category] += 1
report["objects"].append(
{
"name": getattr(obj, "Name", ""),
"label": getattr(obj, "Label", ""),
"category": category,
"reason": getattr(obj, "QetSyncReason", ""),
}
)
return report
def set_stale_objects_visibility(visible, doc=None):
doc = _active_document(doc)
report = summarize_stale_objects(doc)
report["visible"] = bool(visible)
report["affected_objects"] = 0
if doc is None:
return report
affected_ids = set()
for stale_obj in iter_stale_objects(doc):
for obj in _iter_object_tree(stale_obj):
if id(obj) in affected_ids:
continue
if _set_visible(obj, visible):
affected_ids.add(id(obj))
report["affected_objects"] = len(affected_ids)
try:
doc.recompute()
except Exception:
pass
return report
def hide_stale_objects(doc=None):
return set_stale_objects_visibility(False, doc)
def show_stale_objects(doc=None):
return set_stale_objects_visibility(True, doc)
def _format_summary(report):
return (
"失效对象统计:\n"
"总数:{total}\n"
"机柜:{cabinets}\n"
"设备:{devices}\n"
"端子:{terminals}\n"
"导线任务:{wire_tasks}\n"
"已布线:{routed_wires}\n"
"其他:{other}"
).format(**report)
def _notify(title, message):
try:
App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message.replace("\n", "")))
except Exception:
pass
if QtWidgets is not None:
try:
QtWidgets.QMessageBox.information(None, title, message)
except Exception:
pass
class CommandHideStaleObjects:
def GetResources(self):
return {
"MenuText": "隐藏失效对象",
"ToolTip": "隐藏所有 QetSyncStatus=Stale 的 QET 3D 对象",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
report = hide_stale_objects(App.ActiveDocument)
_notify(
"QET 失效对象",
"已隐藏 {0} 个可见对象。\n\n{1}".format(
report["affected_objects"],
_format_summary(report),
),
)
class CommandShowStaleObjects:
def GetResources(self):
return {
"MenuText": "显示失效对象",
"ToolTip": "显示所有 QetSyncStatus=Stale 的 QET 3D 对象",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
report = show_stale_objects(App.ActiveDocument)
_notify(
"QET 失效对象",
"已显示 {0} 个对象。\n\n{1}".format(
report["affected_objects"],
_format_summary(report),
),
)
class CommandSummarizeStaleObjects:
def GetResources(self):
return {
"MenuText": "统计失效对象",
"ToolTip": "统计当前文档中 QetSyncStatus=Stale 的 QET 3D 对象",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
report = summarize_stale_objects(App.ActiveDocument)
_notify("QET 失效对象", _format_summary(report))
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
Gui.addCommand("QET_Exchange_HideStaleObjects", CommandHideStaleObjects())
Gui.addCommand("QET_Exchange_ShowStaleObjects", CommandShowStaleObjects())
Gui.addCommand("QET_Exchange_SummarizeStaleObjects", CommandSummarizeStaleObjects())
_COMMANDS_REGISTERED = True
register_commands()

@ -0,0 +1,291 @@
# FreeCADExchange stale object marking helpers.
import FreeCAD as App
import TerminalObjects
try:
import WiringObjects
except Exception:
WiringObjects = None
SYNC_STATUS_ACTIVE = "Active"
SYNC_STATUS_STALE = "Stale"
SYNC_GROUP = "QET Sync"
CABINET_GROUP_PREFIX = "QETCabinet_"
def _string_value(item, field_name):
if not isinstance(item, dict):
return ""
value = item.get(field_name, "")
if value is None:
return ""
return str(value).strip()
def _set_status(obj, status, reason=""):
TerminalObjects.ensure_string_property(
obj,
"QetSyncStatus",
SYNC_GROUP,
"Latest 2D/3D synchronization status",
status,
)
TerminalObjects.ensure_string_property(
obj,
"QetSyncReason",
SYNC_GROUP,
"Why the object has this synchronization status",
reason,
)
def _payload_identity_sets(payload):
cabinet_instance_ids = set()
device_element_uuids = set()
device_instance_ids = set()
terminal_uuids = set()
wire_uuids = set()
cabinet = payload.get("cabinet")
if isinstance(cabinet, dict):
for field_name in ("cabinet_instance_id", "cabinet_uuid", "location_id"):
value = _string_value(cabinet, field_name)
if value:
cabinet_instance_ids.add(value)
break
for item in payload.get("devices", []) or []:
element_uuid = _string_value(item, "element_uuid")
instance_id = _string_value(item, "instance_id")
if element_uuid:
device_element_uuids.add(element_uuid)
if instance_id:
device_instance_ids.add(instance_id)
for item in payload.get("terminals", []) or []:
terminal_uuid = _string_value(item, "terminal_uuid")
if terminal_uuid:
terminal_uuids.add(terminal_uuid)
for item in payload.get("wires", []) or []:
wire_uuid = (
_string_value(item, "wire_id")
or _string_value(item, "wire_uuid")
or _string_value(item, "id")
)
if wire_uuid:
wire_uuids.add(wire_uuid)
return {
"cabinet_instance_ids": cabinet_instance_ids,
"device_element_uuids": device_element_uuids,
"device_instance_ids": device_instance_ids,
"terminal_uuids": terminal_uuids,
"wire_uuids": wire_uuids,
}
def _iter_cabinet_groups(doc):
root = doc.getObject(TerminalObjects.ROOT_GROUP_NAME)
candidates = []
if root is not None:
candidates.extend(list(getattr(root, "Group", []) or []))
if not candidates:
candidates.extend(list(getattr(doc, "Objects", []) or []))
seen = set()
for obj in candidates:
name = getattr(obj, "Name", "")
if not name.startswith(CABINET_GROUP_PREFIX) and name != "QETCabinetModel":
continue
if "QetCabinetInstanceId" not in getattr(obj, "PropertiesList", []):
continue
if id(obj) in seen:
continue
seen.add(id(obj))
yield obj
def _iter_device_groups(doc):
root = doc.getObject(TerminalObjects.ROOT_GROUP_NAME)
candidates = []
if root is not None:
candidates.extend(list(getattr(root, "Group", []) or []))
if not candidates:
candidates.extend(list(getattr(doc, "Objects", []) or []))
seen = set()
for obj in candidates:
name = getattr(obj, "Name", "")
if not name.startswith(TerminalObjects.DEVICE_GROUP_PREFIX):
continue
if "QetElementUuid" not in getattr(obj, "PropertiesList", []):
continue
if id(obj) in seen:
continue
seen.add(id(obj))
yield obj
def _mark_device(device_group, identity_sets):
element_uuid = (getattr(device_group, "QetElementUuid", "") or "").strip()
instance_id = (getattr(device_group, "QetInstanceId", "") or "").strip()
active = False
if element_uuid and element_uuid in identity_sets["device_element_uuids"]:
active = True
if instance_id and instance_id in identity_sets["device_instance_ids"]:
active = True
if active:
_set_status(device_group, SYNC_STATUS_ACTIVE)
return "active"
_set_status(
device_group,
SYNC_STATUS_STALE,
"This 3D device is not present in the latest 2D exchange payload.",
)
return "stale"
def _mark_cabinet(cabinet_group, identity_sets):
cabinet_instance_id = (getattr(cabinet_group, "QetCabinetInstanceId", "") or "").strip()
active = bool(cabinet_instance_id and cabinet_instance_id in identity_sets["cabinet_instance_ids"])
if active:
_set_status(cabinet_group, SYNC_STATUS_ACTIVE)
return "active"
_set_status(
cabinet_group,
SYNC_STATUS_STALE,
"This 3D cabinet is not present in the latest 2D exchange payload.",
)
return "stale"
def _mark_terminals(device_group, identity_sets):
report = {"active": 0, "stale": 0}
terminal_group = TerminalObjects.find_child_group_by_kind(
device_group,
TerminalObjects.TERMINAL_GROUP_KIND,
)
for terminal in TerminalObjects.collect_terminal_objects(terminal_group):
terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip()
if TerminalObjects.is_local_terminal_uuid(terminal_uuid):
_set_status(terminal, SYNC_STATUS_ACTIVE)
report["active"] += 1
continue
if terminal_uuid and terminal_uuid in identity_sets["terminal_uuids"]:
_set_status(terminal, SYNC_STATUS_ACTIVE)
report["active"] += 1
continue
_set_status(
terminal,
SYNC_STATUS_STALE,
"This 3D terminal is not present in the latest 2D exchange payload.",
)
report["stale"] += 1
return report
def _iter_wire_task_objects(doc):
task_group = doc.getObject("QETWiring_01_Tasks")
if task_group is None:
return []
result = []
for obj in list(getattr(task_group, "Group", []) or []):
if (getattr(obj, "RouteType", "") or "").strip() != "Task":
continue
if "QetWireUuid" not in getattr(obj, "PropertiesList", []):
continue
result.append(obj)
return result
def _iter_routed_wire_objects(doc):
if WiringObjects is None:
return []
try:
return WiringObjects.iter_routed_wire_objects(doc)
except Exception:
return []
def _mark_wire_object(obj, identity_sets, stale_reason):
wire_uuid = (getattr(obj, "QetWireUuid", "") or "").strip()
if wire_uuid and wire_uuid in identity_sets["wire_uuids"]:
_set_status(obj, SYNC_STATUS_ACTIVE)
return "active"
_set_status(obj, SYNC_STATUS_STALE, stale_reason)
return "stale"
def mark_stale_objects_from_payload(payload, doc=None):
if doc is None:
doc = getattr(App, "ActiveDocument", None)
if doc is None:
raise RuntimeError("No active FreeCAD document is available.")
if not isinstance(payload, dict):
raise RuntimeError("Exchange payload must be an object.")
identity_sets = _payload_identity_sets(payload)
report = {
"active_cabinets": 0,
"stale_cabinets": 0,
"active_devices": 0,
"stale_devices": 0,
"active_terminals": 0,
"stale_terminals": 0,
"active_wire_tasks": 0,
"stale_wire_tasks": 0,
"active_routed_wires": 0,
"stale_routed_wires": 0,
"warnings": [],
}
for cabinet_group in _iter_cabinet_groups(doc):
cabinet_status = _mark_cabinet(cabinet_group, identity_sets)
if cabinet_status == "active":
report["active_cabinets"] += 1
else:
report["stale_cabinets"] += 1
for device_group in _iter_device_groups(doc):
device_status = _mark_device(device_group, identity_sets)
if device_status == "active":
report["active_devices"] += 1
else:
report["stale_devices"] += 1
terminal_report = _mark_terminals(device_group, identity_sets)
report["active_terminals"] += terminal_report["active"]
report["stale_terminals"] += terminal_report["stale"]
for task in _iter_wire_task_objects(doc):
status = _mark_wire_object(
task,
identity_sets,
"This 3D wire task is not present in the latest 2D exchange payload.",
)
if status == "active":
report["active_wire_tasks"] += 1
else:
report["stale_wire_tasks"] += 1
for wire in _iter_routed_wire_objects(doc):
status = _mark_wire_object(
wire,
identity_sets,
"This routed 3D wire is not present in the latest 2D exchange payload.",
)
if status == "active":
report["active_routed_wires"] += 1
else:
report["stale_routed_wires"] += 1
return report

@ -556,21 +556,6 @@ 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
for terminal_uuid, terminal_obj in list(existing_by_uuid.items()):
if terminal_uuid in used_uuids:
continue
if terminal_obj in used_objects:
continue
if TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
report["warnings"].append(
"Removed stale terminal {0} from device {1}.".format(
terminal_uuid, device_element_uuid
)
)
TerminalObjects.remove_object_tree(doc, terminal_obj)
report["removed_terminals"] += 1
doc.recompute() doc.recompute()
if Gui is not None: if Gui is not None:
try: try:

@ -143,40 +143,6 @@ def _find_task_by_wire_uuid(task_group, wire_uuid):
return None return None
def _remove_task_object(doc, task_group, task):
try:
if task in list(getattr(task_group, "Group", []) or []):
task_group.removeObject(task)
except Exception:
try:
task_group.Group = [
candidate
for candidate in list(getattr(task_group, "Group", []) or [])
if candidate is not task
]
except Exception:
pass
try:
doc.removeObject(getattr(task, "Name", ""))
except Exception:
pass
def _remove_stale_wire_tasks(doc, task_group, active_wire_uuids):
active = {(wire_uuid or "").strip() for wire_uuid in active_wire_uuids if (wire_uuid or "").strip()}
removed = 0
for candidate in list(getattr(task_group, "Group", []) or []):
wire_uuid = (getattr(candidate, "QetWireUuid", "") or "").strip()
route_type = (getattr(candidate, "RouteType", "") or "").strip()
if not wire_uuid or route_type != "Task":
continue
if wire_uuid in active:
continue
_remove_task_object(doc, task_group, candidate)
removed += 1
return removed
def _ensure_string_property(obj, prop_name, value, description="QET wire task property"): def _ensure_string_property(obj, prop_name, value, description="QET wire task property"):
TerminalObjects.ensure_string_property( TerminalObjects.ensure_string_property(
obj, obj,
@ -273,7 +239,6 @@ def import_wire_tasks_from_payload(payload, doc=None):
} }
device_labels = _device_display_map(payload) device_labels = _device_display_map(payload)
active_wire_uuids = []
for index, item in enumerate(wires): for index, item in enumerate(wires):
try: try:
entry = _normalize_wire_entry(item, index, device_labels=device_labels) entry = _normalize_wire_entry(item, index, device_labels=device_labels)
@ -282,17 +247,10 @@ def import_wire_tasks_from_payload(payload, doc=None):
report["warnings"].append(str(exc)) report["warnings"].append(str(exc))
continue continue
active_wire_uuids.append(entry["wire_uuid"])
_task, created = _upsert_wire_task(doc, task_group, project_uuid, entry) _task, created = _upsert_wire_task(doc, task_group, project_uuid, entry)
if created: if created:
report["imported_tasks"] += 1 report["imported_tasks"] += 1
else: else:
report["updated_tasks"] += 1 report["updated_tasks"] += 1
report["removed_stale_tasks"] = _remove_stale_wire_tasks(
doc,
task_group,
active_wire_uuids,
)
return report return report

@ -268,6 +268,93 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertIs(app.ActiveDocument, scene_doc) self.assertIs(app.ActiveDocument, scene_doc)
self.assertIn("QETScene", app.set_active_document_calls) self.assertIn("QETScene", app.set_active_document_calls)
def test_legacy_singleton_cabinet_group_is_reused_without_reimport(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,
{"location_id": 42, "resolved_scene_path": str(cabinet_path)},
"project-1",
)
legacy_group = doc.addObject("App::DocumentObjectGroup", "QETCabinetModel")
root_group.addObject(legacy_group)
device_import._ensure_string_property(
legacy_group,
"QetCabinetResolvedScenePath",
"QET Exchange",
"",
str(cabinet_path),
)
existing_model = doc.addObject("Part::Feature", "CabinetBody")
legacy_group.addObject(existing_model)
import_calls = []
def fake_import_model(doc_arg, group_arg, model_path, merge=False, use_link_group=True):
import_calls.append((doc_arg, group_arg, model_path, merge, use_link_group))
return []
device_import._import_model_into_group = fake_import_model
report = {
"cabinet_imported": 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": [],
}
cabinet = {
"location_id": 42,
"resolved_scene_path": str(cabinet_path),
"display_text": "Main Cabinet",
}
device_import._import_cabinet_model(doc, root_group, cabinet, report)
self.assertEqual([], import_calls)
self.assertEqual(0, report["cabinet_imported"])
self.assertEqual(1, report["cabinet_reused"])
self.assertIs(doc.getObject("QETCabinetModel"), legacy_group)
self.assertEqual("42", legacy_group.QetCabinetInstanceId)
self.assertEqual(str(cabinet_path), legacy_group.QetCabinetResolvedScenePath)
self.assertEqual([existing_model], legacy_group.Group)
def test_cabinet_groups_are_separated_by_instance_id(self):
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root_group = device_import._ensure_root_group(doc, None, "project-1")
group_a = device_import._ensure_cabinet_model_group(
doc,
root_group,
{"location_id": 1, "resolved_scene_path": r"D:\cabinet.step", "display_text": "A"},
"project-1",
)
group_b = device_import._ensure_cabinet_model_group(
doc,
root_group,
{"location_id": 2, "resolved_scene_path": r"D:\cabinet.step", "display_text": "B"},
"project-1",
)
self.assertIsNot(group_a, group_b)
self.assertEqual("QETCabinet_1", group_a.Name)
self.assertEqual("QETCabinet_2", group_b.Name)
self.assertEqual("1", group_a.QetCabinetInstanceId)
self.assertEqual("2", group_b.QetCabinetInstanceId)
self.assertIn(group_a, root_group.Group)
self.assertIn(group_b, root_group.Group)
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)

@ -0,0 +1,184 @@
import importlib
import sys
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():
fake_freecad = types.ModuleType("FreeCAD")
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_gui = types.ModuleType("FreeCADGui")
fake_gui.commands = {}
fake_gui.addCommand = lambda name, command: fake_gui.commands.setdefault(name, command)
sys.modules["FreeCADGui"] = fake_gui
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()
def isDerivedFrom(self, type_name):
if type_name == "App::DocumentObjectGroup":
return self.TypeId == "App::DocumentObjectGroup"
return self.TypeId == type_name
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.Name = "FakeDoc"
self.Objects = []
self.recomputed = False
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.recomputed = True
def _reload_modules():
for name in ["TerminalObjects", "StaleObjectActions"]:
sys.modules.pop(name, None)
terminal_objects = importlib.import_module("TerminalObjects")
stale_object_actions = importlib.import_module("StaleObjectActions")
return terminal_objects, stale_object_actions
def _set_stale(terminal_objects, obj):
terminal_objects.ensure_string_property(
obj,
"QetSyncStatus",
"QET Sync",
"Latest 2D/3D synchronization status",
"Stale",
)
class StaleObjectActionsTest(unittest.TestCase):
def test_hides_and_shows_stale_object_trees(self):
_install_fake_freecad()
terminal_objects, stale_object_actions = _reload_modules()
doc = FakeDocument()
sys.modules["FreeCAD"].ActiveDocument = doc
cabinet = doc.addObject("App::DocumentObjectGroup", "QETCabinet_1")
terminal_objects.ensure_string_property(cabinet, "QetCabinetInstanceId", "QET Exchange", "", "1")
_set_stale(terminal_objects, cabinet)
cabinet_model = doc.addObject("Part::Feature", "CabinetModel")
cabinet.addObject(cabinet_model)
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_stale")
terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-stale")
_set_stale(terminal_objects, device)
model_child = doc.addObject("Part::Feature", "ImportedModel")
device.addObject(model_child)
terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_terminal_stale")
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
"device-stale",
"terminal-stale",
"instance-stale",
)
_set_stale(terminal_objects, terminal)
task = doc.addObject("Part::Feature", "QETWireTask_wire_stale")
terminal_objects.ensure_string_property(task, "QetWireUuid", "QET Wiring", "", "wire-stale")
terminal_objects.ensure_string_property(task, "RouteType", "QET Wiring", "", "Task")
_set_stale(terminal_objects, task)
routed_group = doc.addObject("App::DocumentObjectGroup", "QETWiring_04_Routed")
routed_wire = doc.addObject("Part::Feature", "QETWire_wire_stale")
terminal_objects.ensure_string_property(routed_wire, "QetWireUuid", "QET Wiring", "", "wire-stale")
routed_group.addObject(routed_wire)
_set_stale(terminal_objects, routed_wire)
report = stale_object_actions.hide_stale_objects(doc)
self.assertEqual(5, report["total"])
self.assertEqual(1, report["cabinets"])
self.assertEqual(1, report["devices"])
self.assertEqual(1, report["terminals"])
self.assertEqual(1, report["wire_tasks"])
self.assertEqual(1, report["routed_wires"])
self.assertFalse(cabinet.ViewObject.Visibility)
self.assertFalse(cabinet_model.ViewObject.Visibility)
self.assertFalse(device.ViewObject.Visibility)
self.assertFalse(model_child.ViewObject.Visibility)
self.assertFalse(terminal.ViewObject.Visibility)
self.assertFalse(task.ViewObject.Visibility)
self.assertFalse(routed_wire.ViewObject.Visibility)
self.assertTrue(doc.recomputed)
show_report = stale_object_actions.show_stale_objects(doc)
self.assertEqual(7, show_report["affected_objects"])
self.assertTrue(cabinet.ViewObject.Visibility)
self.assertTrue(cabinet_model.ViewObject.Visibility)
self.assertTrue(device.ViewObject.Visibility)
self.assertTrue(model_child.ViewObject.Visibility)
self.assertTrue(terminal.ViewObject.Visibility)
self.assertTrue(task.ViewObject.Visibility)
self.assertTrue(routed_wire.ViewObject.Visibility)
def test_registers_toolbar_commands(self):
_install_fake_freecad()
_terminal_objects, stale_object_actions = _reload_modules()
stale_object_actions.register_commands()
commands = sys.modules["FreeCADGui"].commands
self.assertIn("QET_Exchange_HideStaleObjects", commands)
self.assertIn("QET_Exchange_ShowStaleObjects", commands)
self.assertIn("QET_Exchange_SummarizeStaleObjects", commands)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,224 @@
import importlib
import sys
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():
fake_freecad = types.ModuleType("FreeCAD")
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
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()
def isDerivedFrom(self, type_name):
return self.TypeId == type_name
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)
def removeObject(self, child):
if child in self.Group:
self.Group.remove(child)
if self in child.InList:
child.InList.remove(self)
class FakeDocument:
def __init__(self):
self.Name = "FakeDoc"
self.Objects = []
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 _reload_modules():
for name in [
"DeviceImport",
"TerminalObjects",
"WiringObjects",
"StaleObjectSync",
]:
sys.modules.pop(name, None)
terminal_objects = importlib.import_module("TerminalObjects")
wiring_objects = importlib.import_module("WiringObjects")
stale_object_sync = importlib.import_module("StaleObjectSync")
return terminal_objects, wiring_objects, stale_object_sync
class StaleObjectSyncTest(unittest.TestCase):
def test_marks_devices_terminals_and_wires_missing_from_payload_as_stale(self):
_install_fake_freecad()
terminal_objects, wiring_objects, stale_object_sync = _reload_modules()
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
active_cabinet = doc.addObject("App::DocumentObjectGroup", "QETCabinet_1")
active_cabinet.addProperty("App::PropertyString", "QetCabinetInstanceId", "QET Exchange", "")
active_cabinet.QetCabinetInstanceId = "1"
root.addObject(active_cabinet)
stale_cabinet = doc.addObject("App::DocumentObjectGroup", "QETCabinet_2")
stale_cabinet.addProperty("App::PropertyString", "QetCabinetInstanceId", "QET Exchange", "")
stale_cabinet.QetCabinetInstanceId = "2"
root.addObject(stale_cabinet)
active_device = doc.addObject("App::Part", "QETDevice_device_active")
active_device.addProperty("App::PropertyString", "QetElementUuid", "QET Exchange", "")
active_device.QetElementUuid = "device-active"
active_device.addProperty("App::PropertyString", "QetInstanceId", "QET Exchange", "")
active_device.QetInstanceId = "instance-active"
root.addObject(active_device)
stale_device = doc.addObject("App::Part", "QETDevice_device_stale")
stale_device.addProperty("App::PropertyString", "QetElementUuid", "QET Exchange", "")
stale_device.QetElementUuid = "device-stale"
stale_device.addProperty("App::PropertyString", "QetInstanceId", "QET Exchange", "")
stale_device.QetInstanceId = "instance-stale"
root.addObject(stale_device)
active_terminals = terminal_objects.ensure_terminal_group(
doc,
active_device,
project_uuid="project-1",
instance_id="instance-active",
)
active_terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_terminal_active")
terminal_objects.set_terminal_semantics(
active_terminal,
"project-1",
"device-active",
"terminal-active",
"instance-active",
)
active_terminals.addObject(active_terminal)
stale_terminals = terminal_objects.ensure_terminal_group(
doc,
stale_device,
project_uuid="project-1",
instance_id="instance-stale",
)
stale_terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_terminal_stale")
terminal_objects.set_terminal_semantics(
stale_terminal,
"project-1",
"device-stale",
"terminal-stale",
"instance-stale",
)
stale_terminals.addObject(stale_terminal)
active_task = wiring_objects.create_wire_task(
doc,
"project-1",
"wire-active",
"wire-active",
"terminal-active",
"terminal-b",
"instance-active",
"instance-b",
)
stale_task = wiring_objects.create_wire_task(
doc,
"project-1",
"wire-stale",
"wire-stale",
"terminal-stale",
"terminal-c",
"instance-stale",
"instance-c",
)
payload = {
"project_uuid": "project-1",
"cabinet": {
"location_id": 1,
},
"devices": [
{
"element_uuid": "device-active",
"instance_id": "instance-active",
}
],
"terminals": [
{
"terminal_uuid": "terminal-active",
"instance_id": "instance-active",
"element_uuid": "device-active",
}
],
"wires": [
{
"wire_id": "wire-active",
"start_terminal_uuid": "terminal-active",
"end_terminal_uuid": "terminal-b",
}
],
}
report = stale_object_sync.mark_stale_objects_from_payload(payload, doc)
self.assertEqual(1, report["active_cabinets"])
self.assertEqual(1, report["stale_cabinets"])
self.assertEqual(1, report["active_devices"])
self.assertEqual(1, report["stale_devices"])
self.assertEqual(1, report["active_terminals"])
self.assertEqual(1, report["stale_terminals"])
self.assertEqual(1, report["active_wire_tasks"])
self.assertEqual(1, report["stale_wire_tasks"])
self.assertEqual("Active", active_cabinet.QetSyncStatus)
self.assertEqual("Stale", stale_cabinet.QetSyncStatus)
self.assertEqual("Active", active_device.QetSyncStatus)
self.assertEqual("Stale", stale_device.QetSyncStatus)
self.assertEqual("Active", active_terminal.QetSyncStatus)
self.assertEqual("Stale", stale_terminal.QetSyncStatus)
self.assertEqual("Active", active_task.QetSyncStatus)
self.assertEqual("Stale", stale_task.QetSyncStatus)
if __name__ == "__main__":
unittest.main()

@ -209,7 +209,7 @@ class WiringImportTest(unittest.TestCase):
self.assertEqual("W001-updated", task_group.Group[0].QetWireMark) self.assertEqual("W001-updated", task_group.Group[0].QetWireMark)
self.assertEqual("Routed", task_group.Group[0].RouteStatus) self.assertEqual("Routed", task_group.Group[0].RouteStatus)
def test_reimport_removes_stale_wire_tasks_not_in_current_payload(self): def test_reimport_keeps_stale_wire_tasks_for_sync_marking(self):
_install_fake_freecad() _install_fake_freecad()
terminal_objects, wiring_objects, wiring_import = _reload_modules() terminal_objects, wiring_objects, wiring_import = _reload_modules()
@ -242,10 +242,13 @@ class WiringImportTest(unittest.TestCase):
report = wiring_import.import_wire_tasks_from_payload(payload, doc) report = wiring_import.import_wire_tasks_from_payload(payload, doc)
self.assertEqual(1, report["imported_tasks"]) self.assertEqual(1, report["imported_tasks"])
self.assertEqual(1, report["removed_stale_tasks"]) self.assertEqual(0, report["removed_stale_tasks"])
self.assertIsNone(doc.getObject(stale_task.Name)) self.assertIsNotNone(doc.getObject(stale_task.Name))
self.assertEqual(1, len(task_group.Group)) self.assertEqual(2, len(task_group.Group))
self.assertEqual("direction:new-wire:0", task_group.Group[0].QetWireUuid) self.assertEqual(
{"conductor:old-wire", "direction:new-wire:0"},
{task.QetWireUuid for task in task_group.Group},
)
if __name__ == "__main__": if __name__ == "__main__":

Loading…
Cancel
Save