You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2167 lines
75 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import json
import os
from pathlib import Path
import uuid
import json
from datetime import datetime
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
try:
import ImportGui
except ImportError:
ImportGui = None
import DevicePreview
import TemplateSemantics
import TerminalObjects
ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices"
CABINET_MODEL_GROUP_NAME = "QETCabinetModel"
CABINET_GROUP_PREFIX = "QETCabinet_"
DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
GROUP_KIND_TERMINALS = "Terminals"
GROUP_KIND_WIRES = "Wires"
ASSEMBLY_STATE_PENDING = "Pending"
ASSEMBLY_STATE_PLACED = "Placed"
class DeviceImportError(RuntimeError):
pass
def _debug_log_path():
local_app_data = os.environ.get("LOCALAPPDATA", "").strip()
if local_app_data:
return os.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log")
return os.path.join(str(Path.home()), "AppData", "Local", "QETDeps", "freecad_exchange_bootstrap.log")
def _append_debug_log(message):
try:
log_path = _debug_log_path()
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as handle:
handle.write(
"[{0}] {1}\n".format(
datetime.now().astimezone().isoformat(timespec="seconds"),
message,
)
)
except Exception:
pass
def _safe_token(value):
text = (value or "").strip()
if not text:
return "unknown"
chars = []
for ch in text:
if ch.isalnum():
chars.append(ch)
else:
chars.append("_")
return "".join(chars)
def _unique_object_name(doc, base_name):
base = _safe_token(base_name) or "QETObject"
if doc.getObject(base) is None:
return base
suffix = 1
while doc.getObject("{0}_{1}".format(base, suffix)) is not None:
suffix += 1
return "{0}_{1}".format(base, suffix)
def _native_path(value):
text = (value or "").strip()
if not text:
return ""
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):
return {obj.Name for obj in doc.Objects}
def _new_objects_since(doc, before_names):
return [obj for obj in doc.Objects if obj.Name not in before_names]
def _top_level_imported_objects(imported_objects):
imported_by_name = {obj.Name: obj for obj in imported_objects}
child_names = set()
for obj in imported_objects:
for parent in list(getattr(obj, "InList", []) or []):
if getattr(parent, "Name", None) in imported_by_name:
child_names.add(obj.Name)
for child in list(getattr(obj, "Group", []) or []):
if getattr(child, "Name", None) in imported_by_name:
child_names.add(child.Name)
return [obj for obj in imported_objects if obj.Name not in child_names]
def _top_level_document_objects(doc):
return _top_level_imported_objects(list(getattr(doc, "Objects", []) or []))
def _ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description)
setattr(obj, prop_name, value or "")
def _ensure_bool_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyBool", prop_name, group_name, description)
setattr(obj, prop_name, bool(value))
def _ensure_child_group(doc, parent_group, element_uuid, instance_id, name_prefix, label, group_kind, project_uuid=""):
target_uuid = (element_uuid or "").strip()
preferred_name = name_prefix + _safe_token(target_uuid)
group = doc.getObject(preferred_name)
if group is None:
for candidate in getattr(parent_group, "Group", []) or []:
if getattr(candidate, "QetGroupKind", "").strip() != group_kind:
continue
if target_uuid and getattr(candidate, "QetElementUuid", "").strip() != target_uuid:
continue
group = candidate
break
if group is None:
group = doc.addObject("App::DocumentObjectGroup", preferred_name)
if group not in getattr(parent_group, "Group", []):
parent_group.addObject(group)
group.Label = label
project_uuid = (project_uuid or "").strip() or getattr(group, "QetProjectUuid", "").strip()
element_uuid = (element_uuid or "").strip() or getattr(group, "QetElementUuid", "").strip()
instance_id = (instance_id or "").strip() or getattr(group, "QetInstanceId", "").strip()
_ensure_string_property(
group,
"QetGroupKind",
"QET Exchange",
"FreeCADExchange group kind",
group_kind,
)
_ensure_string_property(
group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
_ensure_string_property(
group,
"QetElementUuid",
"QET Exchange",
"Parent element UUID from QET exchange",
element_uuid,
)
_ensure_string_property(
group,
"QetInstanceId",
"QET Exchange",
"Parent instance id from QET exchange",
instance_id,
)
return group
def _ensure_document(scene_path):
preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene"
normalized_scene_path = _native_path(scene_path)
_append_debug_log(
"DeviceImport _ensure_document: preferred_name={0}, normalized_scene_path={1}".format(
preferred_name,
normalized_scene_path or "<empty>",
)
)
if normalized_scene_path and os.path.isfile(normalized_scene_path):
normalized_target = os.path.normcase(os.path.normpath(normalized_scene_path))
for candidate in App.listDocuments().values():
candidate_path = getattr(candidate, "FileName", "") or ""
if candidate_path and os.path.normcase(os.path.normpath(candidate_path)) == normalized_target:
_append_debug_log(
"DeviceImport _ensure_document reusing already open scene doc: name={0}, path={1}, objects={2}".format(
getattr(candidate, "Name", ""),
candidate_path,
len(list(getattr(candidate, "Objects", []) or [])),
)
)
_activate_document(candidate)
return candidate
try:
doc = App.openDocument(normalized_scene_path)
except Exception as exc:
raise DeviceImportError(
"Cannot open existing FreeCAD scene file: {0}".format(normalized_scene_path)
) from exc
if doc is None:
raise DeviceImportError(
"Cannot open existing FreeCAD scene file: {0}".format(normalized_scene_path)
)
_append_debug_log(
"DeviceImport _ensure_document opened existing scene doc: name={0}, path={1}, objects={2}".format(
getattr(doc, "Name", ""),
getattr(doc, "FileName", "") or normalized_scene_path,
len(list(getattr(doc, "Objects", []) or [])),
)
)
_activate_document(doc)
return doc
existing_doc = DevicePreview.find_main_exchange_document(preferred_name)
if existing_doc is not None:
_append_debug_log(
"DeviceImport _ensure_document reusing unsaved exchange doc: name={0}, path={1}, objects={2}".format(
getattr(existing_doc, "Name", ""),
getattr(existing_doc, "FileName", "") or "<unsaved>",
len(list(getattr(existing_doc, "Objects", []) or [])),
)
)
_activate_document(existing_doc)
return existing_doc
doc = App.newDocument(preferred_name)
_append_debug_log(
"DeviceImport _ensure_document created new scene doc: name={0}, path={1}, objects={2}".format(
getattr(doc, "Name", ""),
getattr(doc, "FileName", "") or "<unsaved>",
len(list(getattr(doc, "Objects", []) or [])),
)
)
_activate_document(doc)
return doc
def _activate_document(doc):
if doc is None:
return
current_doc = getattr(App, "ActiveDocument", None)
if current_doc is doc:
_append_debug_log(
"DeviceImport _activate_document skipped: already active name={0}, path={1}".format(
getattr(doc, "Name", ""),
getattr(doc, "FileName", "") or "<unsaved>",
)
)
return
_append_debug_log(
"DeviceImport _activate_document: name={0}, path={1}, objects={2}".format(
getattr(doc, "Name", ""),
getattr(doc, "FileName", "") or "<unsaved>",
len(list(getattr(doc, "Objects", []) or [])),
)
)
setter = getattr(App, "setActiveDocument", None)
if callable(setter):
try:
setter(doc.Name)
except Exception:
pass
try:
App.ActiveDocument = doc
except Exception:
pass
try:
Gui.ActiveDocument = Gui.getDocument(doc.Name)
except Exception:
pass
def _cabinet_label_text(cabinet):
if not isinstance(cabinet, dict):
return "QET Cabinet"
label = (cabinet.get("display_text") or "").strip()
if label:
return label
label = (cabinet.get("label") or "").strip()
if label:
return label
label = (cabinet.get("name") or "").strip()
if label:
return label
return "QET Cabinet"
def _ensure_root_group(doc, cabinet=None, project_uuid=""):
root = doc.getObject(ROOT_GROUP_NAME)
if root is None:
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
if isinstance(cabinet, dict):
root.Label = _cabinet_label_text(cabinet)
else:
root.Label = ROOT_GROUP_LABEL
_ensure_string_property(
root,
"QetCabinetLabel",
"QET Exchange",
"Cabinet label from QET exchange",
cabinet.get("label", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetName",
"QET Exchange",
"Cabinet name from QET exchange",
cabinet.get("name", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetDisplayText",
"QET Exchange",
"Cabinet display text from QET exchange",
cabinet.get("display_text", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetFileSet",
"QET Exchange",
"Associated fileset from QET exchange",
cabinet.get("associated_fileset", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetRelativePath",
"QET Exchange",
"Relative 3D cabinet path from QET exchange",
cabinet.get("three_d_relative_path", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetResolvedScenePath",
"QET Exchange",
"Resolved local cabinet scene path from QET exchange",
cabinet.get("resolved_scene_path", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetLocationId",
"QET Exchange",
"Cabinet location id from QET exchange",
str(cabinet.get("location_id") or "") if isinstance(cabinet, dict) else "",
)
project_uuid = (project_uuid or "").strip() or getattr(root, "QetProjectUuid", "").strip()
_ensure_string_property(
root,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
return root
def _cabinet_instance_id(cabinet):
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:
group = doc.addObject(
"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", []):
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
def _find_device_group(doc, element_uuid):
target_uuid = (element_uuid or "").strip()
if not target_uuid:
return None
preferred_name = DEVICE_GROUP_PREFIX + _safe_token(target_uuid)
obj = doc.getObject(preferred_name)
if obj is not None and getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return obj
for candidate in doc.Objects:
if not getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
continue
try:
if not candidate.isDerivedFrom("App::DocumentObjectGroup"):
continue
except Exception:
continue
if "QetElementUuid" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetElementUuid", "").strip() == target_uuid:
return candidate
return None
def _find_device_group_by_instance_id(doc, instance_id):
target_instance_id = (instance_id or "").strip()
if not target_instance_id:
return None
for candidate in doc.Objects:
if not getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
continue
if "QetInstanceId" not in getattr(candidate, "PropertiesList", []):
continue
if getattr(candidate, "QetInstanceId", "").strip() == target_instance_id:
return candidate
return None
def _device_label_text(display_tag, instance_id, element_uuid):
label = (display_tag or "").strip()
if label:
return label
fallback = (instance_id or "").strip() or (element_uuid or "").strip()
if fallback:
return fallback
return "QET Device"
def _device_warning_subject(display_tag, element_uuid):
label = (display_tag or "").strip()
element_uuid = (element_uuid or "").strip()
if label and element_uuid:
return "设备 {0} ({1})".format(label, element_uuid)
if label:
return "设备 {0}".format(label)
if element_uuid:
return "设备 {0}".format(element_uuid)
return "设备"
def _device_report_label(display_tag, instance_id, element_uuid=""):
label = (display_tag or "").strip()
if label:
return label
fallback = (instance_id or "").strip() or (element_uuid or "").strip()
return fallback or "未命名设备"
def _payload_device_instance_id(device):
if not isinstance(device, dict):
return ""
return (device.get("device_instance_id") or "").strip()
def _payload_device_element_uuid(device):
if not isinstance(device, dict):
return ""
for terminal in device.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
element_uuid = (terminal.get("element_uuid") or "").strip()
if element_uuid:
return element_uuid
return ""
def _payload_terminal_uuid_set(device):
result = set()
if not isinstance(device, dict):
return result
for terminal in device.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
terminal_uuid = (terminal.get("terminal_uuid") or "").strip()
if terminal_uuid:
result.add(terminal_uuid)
return result
def _terminal_signature_token(element_uuid, terminal_uuid):
return "{0}|{1}".format((element_uuid or "").strip(), (terminal_uuid or "").strip())
def _payload_terminal_signature_counts(device):
result = {}
if not isinstance(device, dict):
return result
for terminal in device.get("terminals", []) or []:
if not isinstance(terminal, dict):
continue
terminal_uuid = (terminal.get("terminal_uuid") or "").strip()
element_uuid = (terminal.get("element_uuid") or "").strip()
if not terminal_uuid:
continue
token = _terminal_signature_token(element_uuid, terminal_uuid)
result[token] = result.get(token, 0) + 1
return result
def _existing_qet_terminal_uuids(device_group):
terminal_group = TerminalObjects.find_child_group_by_kind(
device_group,
TerminalObjects.TERMINAL_GROUP_KIND,
)
result = set()
for terminal_obj in TerminalObjects.collect_terminal_objects(terminal_group):
terminal_uuid = (getattr(terminal_obj, "QetTerminalUuid", "") or "").strip()
if not terminal_uuid or TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
result.add(terminal_uuid)
return result
def _existing_qet_terminal_signature_counts(device_group):
raw_json = (getattr(device_group, "QetPayloadTerminalSignaturesJson", "") or "").strip()
if raw_json:
try:
parsed = json.loads(raw_json)
if isinstance(parsed, dict):
result = {}
for key, value in parsed.items():
key_text = str(key or "").strip()
if not key_text:
continue
try:
count_value = int(value)
except Exception:
count_value = 0
if count_value > 0:
result[key_text] = count_value
if result:
return result
except Exception:
pass
terminal_group = TerminalObjects.find_child_group_by_kind(
device_group,
TerminalObjects.TERMINAL_GROUP_KIND,
)
result = {}
for terminal_obj in TerminalObjects.collect_terminal_objects(terminal_group):
terminal_uuid = (getattr(terminal_obj, "QetTerminalUuid", "") or "").strip()
element_uuid = (getattr(terminal_obj, "QetElementUuid", "") or "").strip()
if not terminal_uuid or TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
token = _terminal_signature_token(element_uuid, terminal_uuid)
result[token] = result.get(token, 0) + 1
return result
def _store_device_payload_terminal_signatures(device_group, signature_counts):
normalized = {}
for key, value in (signature_counts or {}).items():
key_text = str(key or "").strip()
if not key_text:
continue
try:
count_value = int(value)
except Exception:
count_value = 0
if count_value > 0:
normalized[key_text] = count_value
_ensure_string_property(
device_group,
"QetPayloadTerminalSignaturesJson",
"QET Exchange",
"Serialized terminal-entry signatures from the last QET payload",
json.dumps(normalized, ensure_ascii=False, sort_keys=True),
)
def _device_change_detail(
display_tag,
instance_id,
element_uuid="",
change_types=None,
added_terminal_uuids=None,
removed_terminal_uuids=None,
previous_terminal_entry_count=0,
current_terminal_entry_count=0,
previous_display_tag="",
previous_model_path="",
resolved_model_path="",
):
return {
"display_tag": (display_tag or "").strip(),
"instance_id": (instance_id or "").strip(),
"element_uuid": (element_uuid or "").strip(),
"label": _device_report_label(display_tag, instance_id, element_uuid),
"change_types": list(change_types or []),
"added_terminal_uuids": list(added_terminal_uuids or []),
"removed_terminal_uuids": list(removed_terminal_uuids or []),
"previous_terminal_entry_count": int(previous_terminal_entry_count or 0),
"current_terminal_entry_count": int(current_terminal_entry_count or 0),
"previous_display_tag": (previous_display_tag or "").strip(),
"previous_model_path": (previous_model_path or "").strip(),
"resolved_model_path": (resolved_model_path or "").strip(),
}
def _update_device_group_metadata(device_group, root_group, element_uuid, instance_id, model_path, display_tag):
if device_group is None:
return
current_element_uuid = getattr(device_group, "QetElementUuid", "").strip()
current_instance_id = getattr(device_group, "QetInstanceId", "").strip()
current_model_path = getattr(device_group, "QetResolvedModelPath", "").strip()
final_element_uuid = (element_uuid or "").strip() or current_element_uuid
final_instance_id = (instance_id or "").strip() or current_instance_id
final_model_path = (model_path or "").strip() or current_model_path
final_display_tag = (display_tag or "").strip()
device_group.Label = _device_label_text(
final_display_tag,
final_instance_id,
final_element_uuid,
)
_ensure_string_property(
device_group,
"QetElementUuid",
"QET Exchange",
"2D element UUID from QET",
final_element_uuid,
)
_ensure_string_property(
device_group,
"QetInstanceId",
"QET Exchange",
"3D instance id from QET/FreeCAD exchange",
final_instance_id,
)
_ensure_string_property(
device_group,
"QetResolvedModelPath",
"QET Exchange",
"Resolved local model path from QET exchange",
final_model_path,
)
_ensure_string_property(
device_group,
"QetDisplayTag",
"QET Exchange",
"2D display tag from QET exchange",
final_display_tag,
)
_ensure_string_property(
device_group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
getattr(root_group, "QetProjectUuid", "").strip(),
)
def _set_device_assembly_state(device_group, state):
_ensure_string_property(
device_group,
"QetAssemblyState",
"QET Assembly",
"Assembly state in the FreeCAD scene.",
state,
)
def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path, display_tag, layout_index):
created_now = False
device_group = _find_device_group_by_instance_id(doc, instance_id)
if device_group is None:
device_group = _find_device_group(doc, element_uuid)
if device_group is not None and getattr(device_group, "TypeId", "") != "App::Part":
_remove_object_tree(doc, device_group)
device_group = None
if device_group is None:
group_token = (
(element_uuid or "").strip()
or (instance_id or "").strip()
or (display_tag or "").strip()
or "device-{0}".format(layout_index)
)
device_group = doc.addObject(
"App::Part",
DEVICE_GROUP_PREFIX + _safe_token(group_token),
)
created_now = True
if device_group not in getattr(root_group, "Group", []):
root_group.addObject(device_group)
_update_device_group_metadata(
device_group,
root_group,
element_uuid,
instance_id,
model_path,
display_tag,
)
_ensure_bool_property(
device_group,
"QetAutoPlaced",
"QET Exchange",
"Whether the device has been placed by the QET auto layout.",
created_now,
)
if created_now:
device_group.Placement = App.Placement()
_ensure_string_property(
device_group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
getattr(root_group, "QetProjectUuid", "").strip(),
)
_ensure_child_group(
doc,
device_group,
element_uuid,
instance_id,
TERMINAL_GROUP_PREFIX,
"QET Terminals",
GROUP_KIND_TERMINALS,
project_uuid=getattr(root_group, "QetProjectUuid", "").strip(),
)
_ensure_child_group(
doc,
device_group,
element_uuid,
instance_id,
WIRE_GROUP_PREFIX,
"QET Wires",
GROUP_KIND_WIRES,
project_uuid=getattr(root_group, "QetProjectUuid", "").strip(),
)
return device_group, created_now
def _register_pending_device(report, device_group, display_tag, instance_id, element_uuid, resolved_model_path):
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PENDING)
report.setdefault("pending_devices", 0)
report.setdefault("pending_device_details", [])
report["pending_devices"] += 1
report["pending_device_details"].append(
_device_change_detail(
display_tag,
instance_id,
element_uuid=element_uuid,
change_types=["待装配"],
resolved_model_path=resolved_model_path,
)
)
def _looks_like_qet_device_group(obj):
if obj is None:
return False
if not getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return False
return bool((getattr(obj, "QetInstanceId", "") or "").strip())
def _find_device_group_from_object(obj):
if _looks_like_qet_device_group(obj):
return obj
pending = list(getattr(obj, "InList", []) or [])
seen = set()
while pending:
parent = pending.pop(0)
parent_name = getattr(parent, "Name", "")
if parent_name in seen:
continue
seen.add(parent_name)
if _looks_like_qet_device_group(parent):
return parent
pending.extend(list(getattr(parent, "InList", []) or []))
return None
def list_pending_devices(doc):
if doc is None:
return []
root = doc.getObject(ROOT_GROUP_NAME)
if root is None:
return []
pending_devices = []
for child in list(getattr(root, "Group", []) or []):
if not _looks_like_qet_device_group(child):
continue
if (getattr(child, "QetAssemblyState", "") or "").strip() != ASSEMBLY_STATE_PENDING:
continue
pending_devices.append(
{
"device": child,
"instance_id": (getattr(child, "QetInstanceId", "") or "").strip(),
"element_uuid": (getattr(child, "QetElementUuid", "") or "").strip(),
"display_tag": (getattr(child, "QetDisplayTag", "") or "").strip(),
"label": getattr(child, "Label", "") or getattr(child, "Name", ""),
"resolved_model_path": (
getattr(child, "QetResolvedModelPath", "") or ""
).strip(),
}
)
return pending_devices
def _target_mount_kind(target_obj):
if target_obj is None:
return ""
kind = (getattr(target_obj, "QetCarrierKind", "") or "").strip()
if kind:
return kind
text = " ".join(
[
getattr(target_obj, "Label", "") or "",
getattr(target_obj, "Name", "") or "",
getattr(target_obj, "TypeId", "") or "",
]
).lower()
if "rail" in text or "din" in text or "导轨" in text:
return "rail"
if "wireduct" in text or "wire_duct" in text or "线槽" in text:
return "wire_duct"
if "plate" in text or "panel" in text or "安装板" in text or "面板" in text:
return "mounting_plate"
if "cabinet" in text or "" in text:
return "cabinet"
return ""
def _placement_for_mount_target(mount_target, fallback_rotation=None):
placement = getattr(mount_target, "Placement", None)
base = getattr(placement, "Base", None)
if placement is None or base is None:
return None
rotation = getattr(placement, "Rotation", None) or fallback_rotation or App.Rotation()
return App.Placement(base, rotation)
def _vector_payload(vector):
return {
"x": float(getattr(vector, "x", 0.0) or 0.0),
"y": float(getattr(vector, "y", 0.0) or 0.0),
"z": float(getattr(vector, "z", 0.0) or 0.0),
}
def _normalized_vector(vector):
if vector is None:
return None
x = float(getattr(vector, "x", 0.0) or 0.0)
y = float(getattr(vector, "y", 0.0) or 0.0)
z = float(getattr(vector, "z", 0.0) or 0.0)
length = (x * x + y * y + z * z) ** 0.5
if length <= 1e-9:
return None
return App.Vector(x / length, y / length, z / length)
def _placement_with_normal_offset(placement, normal=None, offset_mm=0.0):
if placement is None:
return None
normal = _normalized_vector(normal)
if normal is None or not float(offset_mm or 0.0):
return placement
base = getattr(placement, "Base", None)
if base is None:
return placement
offset = float(offset_mm or 0.0)
moved_base = App.Vector(
float(getattr(base, "x", 0.0) or 0.0) + normal.x * offset,
float(getattr(base, "y", 0.0) or 0.0) + normal.y * offset,
float(getattr(base, "z", 0.0) or 0.0) + normal.z * offset,
)
return App.Placement(moved_base, getattr(placement, "Rotation", App.Rotation()))
def _set_device_mount_metadata(device_group, mount_target, normal=None, offset_mm=0.0):
if device_group is None or mount_target is None:
return
target_name = getattr(mount_target, "Name", "") or ""
target_label = getattr(mount_target, "Label", "") or target_name
_ensure_string_property(
device_group,
"QetMountMode",
"QET Mount",
"How this QET device was mounted in the FreeCAD scene.",
"manual_insert",
)
_ensure_string_property(
device_group,
"QetMountHostName",
"QET Mount",
"Mount target object name.",
target_name,
)
_ensure_string_property(
device_group,
"QetMountHostLabel",
"QET Mount",
"Mount target object label.",
target_label,
)
_ensure_string_property(
device_group,
"QetMountHostKind",
"QET Mount",
"Mount target kind.",
_target_mount_kind(mount_target),
)
normal = _normalized_vector(normal)
if normal is not None:
_ensure_string_property(
device_group,
"QetMountHostNormalJson",
"QET Mount",
"Mount target face normal at insert time.",
json.dumps(_vector_payload(normal), sort_keys=True),
)
_ensure_string_property(
device_group,
"QetMountOffsetMm",
"QET Mount",
"Mount offset in target normal direction.",
"{0:.6f}".format(float(offset_mm or 0.0)),
)
def insert_pending_device(
doc,
device_group,
source_doc_cache=None,
mount_target=None,
mount_placement=None,
mount_normal=None,
mount_offset_mm=0.0,
):
if doc is None:
raise DeviceImportError("A FreeCAD document is required.")
device_group = _find_device_group_from_object(device_group)
if device_group is None:
raise DeviceImportError("请选择一个待装配 QET 设备。")
model_path = _native_path(getattr(device_group, "QetResolvedModelPath", ""))
if not model_path:
raise DeviceImportError("待装配设备缺少模型路径。")
if not os.path.isfile(model_path):
raise DeviceImportError("待装配设备模型文件不存在:{0}".format(model_path))
if not _supported_for_import(model_path):
raise DeviceImportError("待装配设备模型格式暂不支持:{0}".format(model_path))
existing_model_objects = _existing_model_objects(doc, device_group)
if existing_model_objects:
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
target_placement = mount_placement or _placement_for_mount_target(
mount_target,
getattr(getattr(device_group, "Placement", None), "Rotation", None),
)
target_placement = _placement_with_normal_offset(
target_placement,
mount_normal,
mount_offset_mm,
)
if target_placement is not None:
device_group.Placement = target_placement
_set_device_mount_metadata(
device_group,
mount_target,
normal=mount_normal,
offset_mm=mount_offset_mm,
)
return {
"device": device_group,
"imported_objects": existing_model_objects,
"already_placed": True,
}
_clear_group_contents(doc, device_group)
imported_objects = _import_model_into_group(
doc,
device_group,
model_path,
source_doc_cache=source_doc_cache if source_doc_cache is not None else {},
)
target_placement = mount_placement or _placement_for_mount_target(
mount_target,
getattr(getattr(device_group, "Placement", None), "Rotation", None),
)
target_placement = _placement_with_normal_offset(
target_placement,
mount_normal,
mount_offset_mm,
)
if target_placement is not None:
device_group.Placement = target_placement
_set_device_mount_metadata(
device_group,
mount_target,
normal=mount_normal,
offset_mm=mount_offset_mm,
)
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
try:
doc.recompute()
except Exception:
pass
return {
"device": device_group,
"imported_objects": list(imported_objects or []),
"already_placed": False,
}
def _remove_object_tree(doc, obj):
if obj is None:
return
obj_name = _object_name(obj)
if not obj_name or doc.getObject(obj_name) is None:
_detach_from_parent_groups(obj)
return
children = list(getattr(obj, "Group", []) or [])
for child in children:
_remove_object_tree(doc, child)
_detach_from_parent_groups(obj)
if doc.getObject(obj_name) is not None:
doc.removeObject(obj_name)
def _object_name(obj):
try:
return getattr(obj, "Name", "")
except Exception:
return ""
def _object_exists(doc, obj):
obj_name = _object_name(obj)
return bool(obj_name and doc.getObject(obj_name) is not None)
def _detach_from_parent_groups(obj):
try:
parents = list(getattr(obj, "InList", []) or [])
except Exception:
return
for parent in parents:
try:
group_children = list(getattr(parent, "Group", []) or [])
except Exception:
continue
if obj not in group_children:
continue
remover = getattr(parent, "removeObject", None)
if callable(remover):
try:
remover(obj)
continue
except Exception:
pass
try:
while obj in getattr(parent, "Group", []):
parent.Group.remove(obj)
except Exception:
pass
def _linked_document_objects(value):
if value is None:
return []
if hasattr(value, "Name") and hasattr(value, "TypeId"):
return [value]
if isinstance(value, dict):
result = []
for item in value.values():
result.extend(_linked_document_objects(item))
return result
if isinstance(value, (list, tuple, set)):
result = []
for item in value:
result.extend(_linked_document_objects(item))
return result
return []
def _remove_template_terminal_hint_object(doc, obj):
linked_objects = []
for prop_name in ("OriginFeatures",):
linked_objects.extend(_linked_document_objects(getattr(obj, prop_name, None)))
_remove_object_tree(doc, obj)
for linked_obj in linked_objects:
if linked_obj is not obj:
_remove_object_tree(doc, linked_obj)
def _remove_template_terminal_hints(doc, container):
removed = 0
if container is None:
return removed
for child in list(getattr(container, "Group", []) or []):
if TerminalObjects.is_template_terminal_object(child):
_remove_template_terminal_hint_object(doc, child)
removed += 1
continue
if hasattr(child, "Group"):
removed += _remove_template_terminal_hints(doc, child)
return removed
def _clear_group_contents(doc, group):
for child in list(getattr(group, "Group", []) or []):
child_name = getattr(child, "Name", "")
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
continue
if getattr(child, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}:
continue
_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 _existing_model_objects(doc, group):
return [
child
for child in _existing_group_objects(doc, group)
if not _is_exchange_sidecar_group(child)
]
def _is_exchange_sidecar_group(obj):
child_name = _object_name(obj)
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
return True
try:
if TerminalObjects.is_terminal_hint_object(obj):
return True
except Exception:
pass
return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}
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):
allowed_ids = {id(obj) for obj in direct_model_objects if obj is not None}
kept_children = []
for child in list(getattr(device_group, "Group", []) or []):
if id(child) in allowed_ids:
kept_children.append(child)
continue
if _is_exchange_sidecar_group(child):
kept_children.append(child)
try:
device_group.Group = kept_children
except Exception:
try:
device_group.Group[:] = kept_children
except Exception:
pass
def _document_object_links(obj):
linked_objects = []
for prop_name in ("Links", "OutList"):
linked_objects.extend(_linked_document_objects(getattr(obj, prop_name, None)))
return linked_objects
def _shape_copy(shape):
copier = getattr(shape, "copy", None)
if callable(copier):
try:
return copier()
except Exception:
pass
return shape
def _can_materialize_shape(obj):
if getattr(obj, "TypeId", "") == "Part::Feature":
return False
try:
shape = getattr(obj, "Shape", None)
except Exception:
return False
return shape is not None
def _materialize_shape_object(doc, source_obj):
source_name = _object_name(source_obj) or "Model"
target = doc.addObject(
"Part::Feature",
_unique_object_name(doc, source_name + "_Shape"),
)
target.Label = getattr(source_obj, "Label", source_name)
target.Shape = _shape_copy(getattr(source_obj, "Shape", None))
try:
target.Placement = getattr(source_obj, "Placement")
except Exception:
pass
try:
target.ViewObject.Visibility = getattr(source_obj.ViewObject, "Visibility", True)
except Exception:
pass
try:
target.ViewObject.ShapeColor = getattr(source_obj.ViewObject, "ShapeColor")
except Exception:
pass
return target
def _materialize_direct_model_objects(doc, device_group, model_objects):
direct_objects = []
obsolete_objects = []
materialized_labels = []
for obj in model_objects:
if not _object_exists(doc, obj):
continue
if not _can_materialize_shape(obj):
direct_objects.append(obj)
continue
static_obj = _materialize_shape_object(doc, obj)
if static_obj not in getattr(device_group, "Group", []):
device_group.addObject(static_obj)
direct_objects.append(static_obj)
materialized_labels.append((static_obj, getattr(obj, "Label", _object_name(obj))))
obsolete_objects.append(obj)
obsolete_objects.extend(_document_object_links(obj))
direct_names = {_object_name(obj) for obj in direct_objects}
removed_names = set()
for obsolete in obsolete_objects:
obsolete_name = _object_name(obsolete)
if not obsolete_name or obsolete_name in direct_names or obsolete_name in removed_names:
continue
_remove_object_tree(doc, obsolete)
removed_names.add(obsolete_name)
for obj, label in materialized_labels:
try:
obj.Label = label
except Exception:
pass
return direct_objects
def _generate_instance_id(project_uuid, element_uuid):
seed = "QET:{0}:{1}".format((project_uuid or "").strip(), (element_uuid or "").strip())
return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
def _supported_for_import(model_path):
suffix = Path(model_path).suffix.lower()
return suffix in {
".step",
".stp",
".iges",
".igs",
".brep",
".brp",
".fcstd",
}
def _import_model_into_group(
doc,
device_group,
model_path,
merge=False,
use_link_group=True,
source_doc_cache=None,
):
if Path(model_path).suffix.lower() == ".fcstd":
return _import_fcstd_into_group(
doc,
device_group,
model_path,
source_doc_cache=source_doc_cache,
)
before_names = _existing_object_names(doc)
try:
try:
ImportGui.insert(
name=model_path,
docName=doc.Name,
merge=bool(merge),
useLinkGroup=bool(use_link_group),
)
except Exception:
for obj in _new_objects_since(doc, before_names):
_remove_object_tree(doc, obj)
raise
imported_objects = _new_objects_since(doc, before_names)
top_level_objects = _top_level_imported_objects(imported_objects)
for obj in top_level_objects:
if obj not in getattr(device_group, "Group", []):
device_group.addObject(obj)
TemplateSemantics.clear_stored_template_slot_hints(device_group)
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):
normalized_target = os.path.normcase(os.path.normpath(model_path))
if source_doc_cache is not None:
cached_entry = source_doc_cache.get(normalized_target)
if cached_entry is not None:
cached_doc = cached_entry.get("doc")
if cached_doc is not None:
_append_debug_log(
"DeviceImport _open_fcstd_source_document cache hit: name={0}, path={1}, objects={2}, should_close={3}".format(
getattr(cached_doc, "Name", ""),
getattr(cached_doc, "FileName", "") or model_path,
len(list(getattr(cached_doc, "Objects", []) or [])),
bool(cached_entry.get("should_close")),
)
)
return cached_doc, False
for candidate in App.listDocuments().values():
candidate_path = getattr(candidate, "FileName", "") or ""
if candidate_path and os.path.normcase(os.path.normpath(candidate_path)) == normalized_target:
_append_debug_log(
"DeviceImport _open_fcstd_source_document reusing open source doc: name={0}, path={1}, objects={2}".format(
getattr(candidate, "Name", ""),
candidate_path,
len(list(getattr(candidate, "Objects", []) or [])),
)
)
if source_doc_cache is not None:
source_doc_cache[normalized_target] = {
"doc": candidate,
"should_close": False,
}
return candidate, False
source_doc = App.openDocument(model_path, hidden=True, temporary=True)
_append_debug_log(
"DeviceImport _open_fcstd_source_document opened temp source doc: name={0}, path={1}, objects={2}".format(
getattr(source_doc, "Name", "") if source_doc is not None else "<none>",
getattr(source_doc, "FileName", "") if source_doc is not None else model_path,
len(list(getattr(source_doc, "Objects", []) or [])) if source_doc is not None else -1,
)
)
if source_doc_cache is not None and source_doc is not None:
source_doc_cache[normalized_target] = {
"doc": source_doc,
"should_close": True,
}
return source_doc, False
return source_doc, True
def _import_fcstd_into_group(doc, device_group, model_path, source_doc_cache=None):
source_doc = None
should_close = False
try:
_append_debug_log(
"DeviceImport _import_fcstd_into_group start: target_doc={0}, target_objects={1}, device_group={2}, model_path={3}".format(
getattr(doc, "Name", ""),
len(list(getattr(doc, "Objects", []) or [])),
getattr(device_group, "Name", ""),
model_path,
)
)
source_doc, should_close = _open_fcstd_source_document(
model_path,
source_doc_cache=source_doc_cache,
)
if source_doc is None:
raise DeviceImportError("Cannot open FCStd file")
TemplateSemantics.clear_stored_template_slot_hints(device_group)
top_level_objects = _top_level_document_objects(source_doc)
_append_debug_log(
"DeviceImport _import_fcstd_into_group source ready: source_doc={0}, top_level_objects={1}, should_close={2}".format(
getattr(source_doc, "Name", ""),
len(top_level_objects),
should_close,
)
)
copied_objects = []
for source_obj in top_level_objects:
copied_obj = doc.copyObject(source_obj, True)
if copied_obj not in getattr(device_group, "Group", []):
device_group.addObject(copied_obj)
copied_objects.append(copied_obj)
template_slots = TemplateSemantics.collect_live_terminal_hints(device_group)
TemplateSemantics.store_template_slot_hints(device_group, template_slots)
_remove_template_terminal_hints(doc, device_group)
copied_model_objects = [
obj
for obj in copied_objects
if _object_exists(doc, obj)
]
direct_model_objects = _materialize_direct_model_objects(
doc,
device_group,
copied_model_objects,
)
_keep_only_direct_model_children(device_group, direct_model_objects)
_append_debug_log(
"DeviceImport _import_fcstd_into_group completed: copied_objects={0}, direct_model_objects={1}, target_doc_objects={2}".format(
len(copied_objects),
len(direct_model_objects),
len(list(getattr(doc, "Objects", []) or [])),
)
)
return direct_model_objects
finally:
if should_close and source_doc is not None:
try:
_append_debug_log(
"DeviceImport _import_fcstd_into_group closing temp source doc: name={0}, path={1}".format(
getattr(source_doc, "Name", ""),
getattr(source_doc, "FileName", "") or model_path,
)
)
App.closeDocument(source_doc.Name)
except Exception:
pass
_activate_document(doc)
def _close_cached_source_documents(source_doc_cache, target_doc=None):
if not source_doc_cache:
return
closed_count = 0
for normalized_target, cached_entry in list(source_doc_cache.items()):
cached_doc = cached_entry.get("doc")
should_close = bool(cached_entry.get("should_close"))
if not should_close or cached_doc is None:
continue
try:
_append_debug_log(
"DeviceImport _close_cached_source_documents closing cached doc: name={0}, path={1}".format(
getattr(cached_doc, "Name", ""),
getattr(cached_doc, "FileName", "") or normalized_target,
)
)
App.closeDocument(cached_doc.Name)
closed_count += 1
except Exception as exc:
_append_debug_log(
"DeviceImport _close_cached_source_documents failed: name={0}, error={1}".format(
getattr(cached_doc, "Name", ""),
exc,
)
)
source_doc_cache.clear()
_append_debug_log(
"DeviceImport _close_cached_source_documents completed: closed_count={0}".format(
closed_count
)
)
if target_doc is not None:
_activate_document(target_doc)
def _model_index(payload):
index = {}
for item in payload.get("device_models", []):
instance_id = (item.get("device_instance_id") or "").strip()
if instance_id and instance_id not in index:
index[instance_id] = item
return index
def _import_cabinet_model(doc, root_group, cabinet, report, source_doc_cache=None):
if not isinstance(cabinet, dict):
return
resolved_scene_path = _native_path(cabinet.get("resolved_scene_path", ""))
_append_debug_log(
"DeviceImport cabinet resolved_scene_path={0}".format(resolved_scene_path)
)
if not resolved_scene_path:
report["cabinet_skipped_missing_model"] += 1
return
if not os.path.isfile(resolved_scene_path):
report["cabinet_skipped_missing_file"] += 1
report["warnings"].append(
"机柜 3D 文件不存在:{0}".format(resolved_scene_path)
)
return
if not _supported_for_import(resolved_scene_path):
report["cabinet_skipped_unsupported_format"] += 1
report["warnings"].append(
"机柜 3D 文件格式暂不支持:{0}".format(resolved_scene_path)
)
return
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)
existing_model_objects = _existing_model_objects(doc, cabinet_group)
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)
_ensure_string_property(
cabinet_group,
"QetCabinetResolvedScenePath",
"QET Exchange",
"Resolved local cabinet scene path from QET exchange",
resolved_scene_path,
)
try:
_append_debug_log(
"DeviceImport importing cabinet model: {0}".format(
resolved_scene_path
)
)
_import_model_into_group(
doc,
cabinet_group,
resolved_scene_path,
merge=False,
use_link_group=True,
source_doc_cache=source_doc_cache,
)
_remove_model_objects(doc, existing_model_objects)
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")
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["warnings"].append(
"机柜 3D 导入失败:{0}".format(exc)
)
_append_debug_log(
"DeviceImport cabinet import failed: {0}".format(exc)
)
def import_devices_from_payload(payload, scene_path="", auto_insert_pending_devices=False):
_append_debug_log("DeviceImport.import_devices_from_payload entered")
doc = _ensure_document(scene_path)
cabinet = payload.get("cabinet")
project_uuid = (payload.get("project_uuid") or "").strip()
root_group = _ensure_root_group(doc, cabinet, project_uuid)
models_by_element = _model_index(payload)
source_doc_cache = {}
report = {
"document_name": doc.Name,
"scene_path": scene_path or "",
"total_devices": 0,
"imported_devices": 0,
"updated_devices": 0,
"reused_devices": 0,
"added_device_details": [],
"updated_device_details": [],
"reused_device_details": [],
"imported_without_instance_id": 0,
"skipped_missing_model": 0,
"skipped_missing_file": 0,
"skipped_unsupported_format": 0,
"skipped_import_error": 0,
"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,
"pending_devices": 0,
"pending_device_details": [],
"warnings": [],
}
try:
_import_cabinet_model(
doc,
root_group,
cabinet,
report,
source_doc_cache=source_doc_cache,
)
for index, device in enumerate(payload.get("devices", [])):
report["total_devices"] += 1
original_instance_id = _payload_device_instance_id(device)
instance_id = original_instance_id
element_uuid = _payload_device_element_uuid(device)
display_tag = (device.get("display_tag") or "").strip()
payload_terminal_uuids = _payload_terminal_uuid_set(device)
payload_terminal_signature_counts = _payload_terminal_signature_counts(device)
payload_terminal_entry_count = sum(payload_terminal_signature_counts.values())
existing_device_group = _find_device_group_by_instance_id(doc, instance_id)
if existing_device_group is None:
existing_device_group = _find_device_group(doc, element_uuid)
previous_display_tag = ""
previous_path = ""
existing_terminal_uuids = set()
existing_terminal_signature_counts = {}
existing_terminal_entry_count = 0
existing_model_objects = []
if existing_device_group is not None:
previous_display_tag = getattr(
existing_device_group,
"QetDisplayTag",
"",
).strip()
previous_path = getattr(
existing_device_group,
"QetResolvedModelPath",
"",
).strip()
existing_terminal_uuids = _existing_qet_terminal_uuids(
existing_device_group
)
existing_terminal_signature_counts = _existing_qet_terminal_signature_counts(
existing_device_group
)
existing_terminal_entry_count = sum(
existing_terminal_signature_counts.values()
)
existing_model_objects = _existing_model_objects(
doc, existing_device_group
)
model_info = models_by_element.get(instance_id, {})
resolved_model_path = _native_path(model_info.get("resolved_model_path", ""))
_append_debug_log(
"DeviceImport device instance_id={0}, display_tag={1}, resolved_model_path={2}".format(
instance_id, display_tag, resolved_model_path
)
)
if not resolved_model_path:
display_tag_changed = bool(
existing_device_group is not None
and previous_display_tag != display_tag
)
terminals_changed = bool(
existing_device_group is not None
and payload_terminal_signature_counts != existing_terminal_signature_counts
)
if existing_device_group is not None:
_update_device_group_metadata(
existing_device_group,
root_group,
element_uuid,
instance_id,
previous_path,
display_tag,
)
_store_device_payload_terminal_signatures(
existing_device_group,
payload_terminal_signature_counts,
)
if display_tag_changed:
change_types = ["标注"]
if terminals_changed:
change_types.append("端子")
report["updated_devices"] += 1
report["updated_device_details"].append(
_device_change_detail(
display_tag,
(instance_id or getattr(existing_device_group, "QetInstanceId", "")).strip(),
element_uuid=element_uuid,
change_types=change_types,
previous_terminal_entry_count=existing_terminal_entry_count,
current_terminal_entry_count=payload_terminal_entry_count,
previous_display_tag=previous_display_tag,
previous_model_path=previous_path,
resolved_model_path=previous_path,
)
)
elif terminals_changed:
report["updated_devices"] += 1
report["updated_device_details"].append(
_device_change_detail(
display_tag,
(instance_id or getattr(existing_device_group, "QetInstanceId", "")).strip(),
element_uuid=element_uuid,
change_types=["端子"],
added_terminal_uuids=sorted(
payload_terminal_uuids - existing_terminal_uuids
),
removed_terminal_uuids=sorted(
existing_terminal_uuids - payload_terminal_uuids
),
previous_terminal_entry_count=existing_terminal_entry_count,
current_terminal_entry_count=payload_terminal_entry_count,
previous_display_tag=previous_display_tag,
previous_model_path=previous_path,
resolved_model_path=previous_path,
)
)
report["skipped_missing_model"] += 1
report["warnings"].append(
"{0} 缺少 resolved_model_path已跳过。".format(
_device_warning_subject(display_tag, instance_id)
)
)
continue
if not os.path.isfile(resolved_model_path):
report["skipped_missing_file"] += 1
report["warnings"].append(
"{0} 的模型文件不存在:{1}".format(
_device_warning_subject(display_tag, instance_id),
resolved_model_path,
)
)
continue
if not _supported_for_import(resolved_model_path):
report["skipped_unsupported_format"] += 1
report["warnings"].append(
"{0} 的模型格式暂不支持:{1}".format(
_device_warning_subject(display_tag, instance_id),
resolved_model_path,
)
)
continue
if not instance_id:
instance_id = _generate_instance_id(
project_uuid, display_tag or element_uuid or "device-{0}".format(index)
)
report.setdefault("generated_instance_ids", 0)
report["generated_instance_ids"] += 1
device_group, created_now = _ensure_device_group(
doc,
root_group,
element_uuid,
instance_id,
resolved_model_path,
display_tag,
index,
)
same_source = (
_normalized_path_key(previous_path)
== _normalized_path_key(resolved_model_path)
)
added_terminal_uuids = sorted(
payload_terminal_uuids - existing_terminal_uuids
)
removed_terminal_uuids = sorted(
existing_terminal_uuids - payload_terminal_uuids
)
terminals_changed = bool(
added_terminal_uuids
or removed_terminal_uuids
or payload_terminal_signature_counts != existing_terminal_signature_counts
)
display_tag_changed = (
not created_now and previous_display_tag != display_tag
)
model_changed = (
not created_now
and (not existing_model_objects or not same_source)
)
if not auto_insert_pending_devices and not existing_model_objects:
_register_pending_device(
report,
device_group,
display_tag,
instance_id,
element_uuid,
resolved_model_path,
)
_append_debug_log(
"DeviceImport registered pending device without importing model: instance_id={0}, model_path={1}".format(
instance_id,
resolved_model_path,
)
)
continue
if existing_model_objects and same_source:
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
if display_tag_changed or terminals_changed:
change_types = []
if display_tag_changed:
change_types.append("标注")
if terminals_changed:
change_types.append("端子")
report["updated_devices"] += 1
report["updated_device_details"].append(
_device_change_detail(
display_tag,
instance_id,
element_uuid=element_uuid,
change_types=change_types,
added_terminal_uuids=added_terminal_uuids,
removed_terminal_uuids=removed_terminal_uuids,
previous_terminal_entry_count=existing_terminal_entry_count,
current_terminal_entry_count=payload_terminal_entry_count,
previous_display_tag=previous_display_tag,
previous_model_path=previous_path,
resolved_model_path=resolved_model_path,
)
)
_append_debug_log(
"DeviceImport import skipped: metadata-only change for instance_id={0}, display_tag_changed={1}, added_terminals={2}, removed_terminals={3}".format(
instance_id,
display_tag_changed,
len(added_terminal_uuids),
len(removed_terminal_uuids),
)
)
_store_device_payload_terminal_signatures(
device_group,
payload_terminal_signature_counts,
)
continue
report["reused_devices"] += 1
report["reused_device_details"].append(
_device_change_detail(
display_tag,
instance_id,
element_uuid=element_uuid,
previous_terminal_entry_count=existing_terminal_entry_count,
current_terminal_entry_count=payload_terminal_entry_count,
previous_display_tag=previous_display_tag,
previous_model_path=previous_path,
resolved_model_path=resolved_model_path,
)
)
_append_debug_log(
"DeviceImport import skipped: reused existing device group for instance_id={0}, model_path={1}, existing_model_objects={2}".format(
instance_id,
resolved_model_path,
len(existing_model_objects),
)
)
_store_device_payload_terminal_signatures(
device_group,
payload_terminal_signature_counts,
)
continue
if created_now or not existing_model_objects:
_clear_group_contents(doc, device_group)
try:
_append_debug_log(
"DeviceImport importing model for device_instance_id={0}: {1}".format(
instance_id, resolved_model_path
)
)
_import_model_into_group(
doc,
device_group,
resolved_model_path,
source_doc_cache=source_doc_cache,
)
_append_debug_log(
"DeviceImport import succeeded for device_instance_id={0}".format(
instance_id
)
)
if existing_model_objects:
_remove_model_objects(doc, existing_model_objects)
_set_device_assembly_state(device_group, ASSEMBLY_STATE_PLACED)
except Exception as exc:
if existing_model_objects:
_ensure_string_property(
device_group,
"QetResolvedModelPath",
"QET Exchange",
"Resolved local model path from QET exchange",
previous_path,
)
report["skipped_import_error"] += 1
report["warnings"].append(
"{0} 导入失败:{1}".format(
_device_warning_subject(display_tag, element_uuid or instance_id),
exc,
)
)
_append_debug_log(
"DeviceImport import failed for device_instance_id={0}: {1}".format(
instance_id, exc
)
)
continue
if created_now:
report["imported_devices"] += 1
report["added_device_details"].append(
_device_change_detail(
display_tag,
instance_id,
element_uuid=element_uuid,
previous_terminal_entry_count=existing_terminal_entry_count,
current_terminal_entry_count=payload_terminal_entry_count,
previous_display_tag=previous_display_tag,
previous_model_path=previous_path,
resolved_model_path=resolved_model_path,
)
)
else:
report["updated_devices"] += 1
change_types = []
if display_tag_changed:
change_types.append("标注")
if model_changed:
change_types.append("3D模型")
if terminals_changed:
change_types.append("端子")
if not change_types:
change_types.append("3D模型")
report["updated_device_details"].append(
_device_change_detail(
display_tag,
instance_id,
element_uuid=element_uuid,
change_types=change_types,
added_terminal_uuids=added_terminal_uuids,
removed_terminal_uuids=removed_terminal_uuids,
previous_terminal_entry_count=existing_terminal_entry_count,
current_terminal_entry_count=payload_terminal_entry_count,
previous_display_tag=previous_display_tag,
previous_model_path=previous_path,
resolved_model_path=resolved_model_path,
)
)
_store_device_payload_terminal_signatures(
device_group,
payload_terminal_signature_counts,
)
if not original_instance_id:
report["imported_without_instance_id"] += 1
finally:
_close_cached_source_documents(source_doc_cache, target_doc=doc)
TerminalObjects.sort_group_children(root_group)
doc.recompute()
_append_debug_log("DeviceImport ViewFit skipped during exchange import")
_append_debug_log(
"DeviceImport finished: cabinet_imported={0}, imported={1}, updated={2}, reused={3}, skipped_missing_model={4}, skipped_missing_file={5}, skipped_import_error={6}".format(
report["cabinet_imported"],
report["imported_devices"],
report["updated_devices"],
report["reused_devices"],
report["skipped_missing_model"],
report["skipped_missing_file"],
report["skipped_import_error"],
)
)
return report
class CommandInsertPendingDevice:
def GetResources(self):
return {
"MenuText": "插入待装配设备",
"ToolTip": "将选中的 QET 待装配设备模型插入当前 3D 场景",
}
def IsActive(self):
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
def Activated(self):
if Gui is None:
return
selection = list(Gui.Selection.getSelection() or [])
device_group = None
for obj in selection:
device_group = _find_device_group_from_object(obj)
if device_group is not None:
break
if device_group is None:
try:
App.Console.PrintWarning("[FreeCADExchange] 请先选择一个待装配 QET 设备。\n")
except Exception:
pass
return
try:
result = insert_pending_device(App.ActiveDocument, device_group)
try:
App.Console.PrintMessage(
"[FreeCADExchange] 已插入设备:{0},导入对象 {1} 个。\n".format(
getattr(result["device"], "Label", ""),
len(result.get("imported_objects", []) or []),
)
)
except Exception:
pass
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError("[FreeCADExchange] 插入待装配设备失败:{0}\n".format(exc))
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
try:
Gui.addCommand("QET_Exchange_InsertPendingDevice", CommandInsertPendingDevice())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register pending device command: {0}".format(exc))