feature/FreeCAD工程端子与手动布线-zwl-0521
parent
6b8ffb4036
commit
14f7bae6bd
@ -0,0 +1,370 @@
|
||||
# FreeCADExchange FCStd template instantiation helpers.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
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 DeviceImport
|
||||
import TemplateSemantics
|
||||
import TerminalObjects
|
||||
|
||||
|
||||
COMMAND_IMPORT_TEMPLATE_INSTANCE = "QET_Template_ImportInstance"
|
||||
COMMAND_CREATE_ENGINEERING_TERMINALS = "QET_Template_CreateEngineeringTerminals"
|
||||
|
||||
|
||||
class TemplateInstantiationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _debug(message):
|
||||
try:
|
||||
DeviceImport._append_debug_log(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _project_uuid_for_document(doc):
|
||||
root = TerminalObjects.ensure_root_group(doc)
|
||||
project_uuid = getattr(root, "QetProjectUuid", "").strip()
|
||||
if project_uuid:
|
||||
return project_uuid
|
||||
project_uuid = "local-project"
|
||||
TerminalObjects.ensure_string_property(
|
||||
root,
|
||||
"QetProjectUuid",
|
||||
"QET Exchange",
|
||||
"Project UUID for the exchange document",
|
||||
project_uuid,
|
||||
)
|
||||
return project_uuid
|
||||
|
||||
|
||||
def _local_terminal_uuid(instance_id, element_uuid, slot_name):
|
||||
owner = (instance_id or "").strip() or (element_uuid or "").strip() or "device"
|
||||
slot = (slot_name or "").strip() or "slot"
|
||||
return "local:{0}:{1}".format(owner, slot)
|
||||
|
||||
|
||||
def _existing_terminal_by_slot(terminal_group):
|
||||
result = {}
|
||||
for obj in TerminalObjects.collect_terminal_objects(terminal_group):
|
||||
slot_name = getattr(obj, "QetTemplateSlotName", "").strip()
|
||||
if slot_name and slot_name not in result:
|
||||
result[slot_name] = obj
|
||||
return result
|
||||
|
||||
|
||||
def _terminal_group_for_device(doc, device_group, project_uuid):
|
||||
instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
return TerminalObjects.ensure_terminal_group(
|
||||
doc,
|
||||
device_group,
|
||||
project_uuid=project_uuid,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
|
||||
def _slot_label(slot, fallback):
|
||||
return (slot.get("label") or slot.get("name") or fallback or "QET Terminal").strip()
|
||||
|
||||
|
||||
def _slot_placement(slot):
|
||||
base = slot.get("base")
|
||||
if not isinstance(base, App.Vector):
|
||||
base = App.Vector(0, 0, 0)
|
||||
|
||||
rotation = App.Rotation()
|
||||
rotation_value = slot.get("rotation")
|
||||
if isinstance(rotation_value, dict):
|
||||
axis = rotation_value.get("axis")
|
||||
angle = rotation_value.get("angle")
|
||||
if isinstance(axis, App.Vector) and angle is not None:
|
||||
try:
|
||||
rotation = App.Rotation(axis, float(angle))
|
||||
except Exception:
|
||||
rotation = App.Rotation()
|
||||
return App.Placement(base, rotation)
|
||||
|
||||
|
||||
def _iter_device_groups(doc):
|
||||
result = []
|
||||
for obj in list(getattr(doc, "Objects", []) or []):
|
||||
if getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX):
|
||||
if "QetElementUuid" in getattr(obj, "PropertiesList", []):
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_engineering_terminals_for_device(doc, device_group):
|
||||
if doc is None:
|
||||
raise TemplateInstantiationError("A FreeCAD document is required.")
|
||||
if device_group is None:
|
||||
raise TemplateInstantiationError("A device group is required.")
|
||||
|
||||
project_uuid = (
|
||||
getattr(device_group, "QetProjectUuid", "").strip()
|
||||
or _project_uuid_for_document(doc)
|
||||
)
|
||||
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
|
||||
instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
model_path = getattr(device_group, "QetResolvedModelPath", "").strip()
|
||||
|
||||
slots = TemplateSemantics.collect_terminal_hints(device_group)
|
||||
terminal_group = _terminal_group_for_device(doc, device_group, project_uuid)
|
||||
existing_by_slot = _existing_terminal_by_slot(terminal_group)
|
||||
|
||||
report = {
|
||||
"device": getattr(device_group, "Name", ""),
|
||||
"slots": len(slots),
|
||||
"created_terminals": 0,
|
||||
"updated_terminals": 0,
|
||||
"skipped_slots": 0,
|
||||
}
|
||||
|
||||
for index, slot in enumerate(slots):
|
||||
slot_name = (slot.get("name") or "SLOT_{0}".format(index + 1)).strip()
|
||||
if not slot_name:
|
||||
report["skipped_slots"] += 1
|
||||
continue
|
||||
|
||||
terminal_obj = existing_by_slot.get(slot_name)
|
||||
if terminal_obj is None:
|
||||
terminal_obj = TerminalObjects.create_lcs_object(
|
||||
doc,
|
||||
"QETTerminal_{0}_{1}".format(
|
||||
TerminalObjects.safe_token(instance_id or element_uuid),
|
||||
TerminalObjects.safe_token(slot_name),
|
||||
),
|
||||
placement=_slot_placement(slot),
|
||||
label=_slot_label(slot, slot_name),
|
||||
)
|
||||
terminal_group.addObject(terminal_obj)
|
||||
report["created_terminals"] += 1
|
||||
else:
|
||||
try:
|
||||
terminal_obj.Placement = _slot_placement(slot)
|
||||
except Exception:
|
||||
pass
|
||||
report["updated_terminals"] += 1
|
||||
|
||||
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
|
||||
if not terminal_uuid:
|
||||
terminal_uuid = _local_terminal_uuid(instance_id, element_uuid, slot_name)
|
||||
|
||||
TerminalObjects.set_terminal_semantics(
|
||||
terminal_obj,
|
||||
project_uuid,
|
||||
element_uuid,
|
||||
terminal_uuid,
|
||||
instance_id,
|
||||
label=_slot_label(slot, slot_name),
|
||||
slot_name=slot_name,
|
||||
)
|
||||
try:
|
||||
terminal_obj.ViewObject.Visibility = True
|
||||
terminal_obj.ViewObject.ShapeColor = (0.0, 0.75, 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
source_obj = slot.get("source_object")
|
||||
if source_obj is not None and source_obj is not terminal_obj:
|
||||
try:
|
||||
source_obj.ViewObject.Visibility = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
doc.recompute()
|
||||
except Exception:
|
||||
pass
|
||||
_debug(
|
||||
"TemplateInstantiation terminals: device={0}, model={1}, slots={2}, created={3}, updated={4}".format(
|
||||
getattr(device_group, "Name", ""),
|
||||
model_path,
|
||||
report["slots"],
|
||||
report["created_terminals"],
|
||||
report["updated_terminals"],
|
||||
)
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def ensure_engineering_terminals_for_selection_or_all(doc=None):
|
||||
doc = doc or getattr(App, "ActiveDocument", None)
|
||||
if doc is None:
|
||||
raise TemplateInstantiationError("请先打开 FreeCAD 工程。")
|
||||
|
||||
selected = []
|
||||
if Gui is not None and hasattr(Gui, "Selection"):
|
||||
try:
|
||||
selected = list(Gui.Selection.getSelection()) or []
|
||||
except Exception:
|
||||
selected = []
|
||||
|
||||
devices = [
|
||||
obj
|
||||
for obj in selected
|
||||
if getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX)
|
||||
]
|
||||
if not devices:
|
||||
devices = _iter_device_groups(doc)
|
||||
|
||||
if not devices:
|
||||
raise TemplateInstantiationError("没有找到 QET 设备实例。")
|
||||
|
||||
reports = [ensure_engineering_terminals_for_device(doc, device) for device in devices]
|
||||
return {
|
||||
"devices": len(reports),
|
||||
"created_terminals": sum(item["created_terminals"] for item in reports),
|
||||
"updated_terminals": sum(item["updated_terminals"] for item in reports),
|
||||
"reports": reports,
|
||||
}
|
||||
|
||||
|
||||
def create_device_instance_from_template(doc, model_path, label=""):
|
||||
if doc is None:
|
||||
raise TemplateInstantiationError("A FreeCAD document is required.")
|
||||
model_path = os.path.normpath(os.path.expanduser(os.path.expandvars(model_path or "")))
|
||||
if not model_path:
|
||||
raise TemplateInstantiationError("请选择 FCStd 设备模板。")
|
||||
if not os.path.isfile(model_path):
|
||||
raise TemplateInstantiationError("FCStd 设备模板不存在:{0}".format(model_path))
|
||||
if Path(model_path).suffix.lower() != ".fcstd":
|
||||
raise TemplateInstantiationError("请选择 .FCStd 设备模板。")
|
||||
|
||||
project_uuid = _project_uuid_for_document(doc)
|
||||
root = TerminalObjects.ensure_root_group(doc, project_uuid)
|
||||
element_uuid = str(uuid.uuid4())
|
||||
instance_id = str(uuid.uuid4())
|
||||
display_label = (label or "").strip() or Path(model_path).stem
|
||||
device_group, _created = DeviceImport._ensure_device_group(
|
||||
doc,
|
||||
root,
|
||||
element_uuid,
|
||||
instance_id,
|
||||
model_path,
|
||||
display_label,
|
||||
0,
|
||||
)
|
||||
DeviceImport._clear_group_contents(doc, device_group)
|
||||
DeviceImport._import_model_into_group(doc, device_group, model_path)
|
||||
report = ensure_engineering_terminals_for_device(doc, device_group)
|
||||
try:
|
||||
doc.recompute()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"device": device_group,
|
||||
"element_uuid": element_uuid,
|
||||
"instance_id": instance_id,
|
||||
"terminal_report": report,
|
||||
}
|
||||
|
||||
|
||||
class CommandImportTemplateInstance:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "导入模板实例",
|
||||
"ToolTip": "把 FCStd 设备模板作为工程设备实例导入当前场景",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
|
||||
|
||||
def Activated(self):
|
||||
if QtWidgets is None:
|
||||
try:
|
||||
App.Console.PrintError("[FreeCADExchange] Qt file dialog is not available.\n")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
file_path, _selected_filter = QtWidgets.QFileDialog.getOpenFileName(
|
||||
None,
|
||||
"选择 FCStd 设备模板",
|
||||
"",
|
||||
"FreeCAD template (*.FCStd *.fcstd);;All files (*.*)",
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
result = create_device_instance_from_template(App.ActiveDocument, file_path)
|
||||
try:
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] 已导入设备实例:{0},端子新增 {1} 个。\n".format(
|
||||
getattr(result["device"], "Label", ""),
|
||||
result["terminal_report"]["created_terminals"],
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
try:
|
||||
App.Console.PrintError("[FreeCADExchange] 导入模板实例失败:{0}\n".format(exc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class CommandCreateEngineeringTerminals:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "生成工程端子",
|
||||
"ToolTip": "把设备模板 LCS 槽位转换为可布线工程端子",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
|
||||
|
||||
def Activated(self):
|
||||
try:
|
||||
report = ensure_engineering_terminals_for_selection_or_all(App.ActiveDocument)
|
||||
try:
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] 工程端子生成完成:设备 {0} 个,新增 {1} 个,更新 {2} 个。\n".format(
|
||||
report["devices"],
|
||||
report["created_terminals"],
|
||||
report["updated_terminals"],
|
||||
)
|
||||
)
|
||||
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:
|
||||
return
|
||||
try:
|
||||
Gui.addCommand(COMMAND_IMPORT_TEMPLATE_INSTANCE, CommandImportTemplateInstance())
|
||||
Gui.addCommand(COMMAND_CREATE_ENGINEERING_TERMINALS, CommandCreateEngineeringTerminals())
|
||||
_COMMANDS_REGISTERED = True
|
||||
except Exception as exc:
|
||||
_debug("failed to register template instantiation commands: {0}".format(exc))
|
||||
@ -0,0 +1,168 @@
|
||||
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():
|
||||
class Vector:
|
||||
def __init__(self, x=0.0, y=0.0, z=0.0):
|
||||
self.x = float(x)
|
||||
self.y = float(y)
|
||||
self.z = float(z)
|
||||
|
||||
class Rotation:
|
||||
def __init__(self, axis=None, angle=None):
|
||||
self.Axis = axis
|
||||
self.Angle = angle
|
||||
|
||||
class Placement:
|
||||
def __init__(self, base=None, rotation=None):
|
||||
self.Base = base or Vector()
|
||||
self.Rotation = rotation or Rotation()
|
||||
|
||||
fake_freecad = types.ModuleType("FreeCAD")
|
||||
fake_freecad.Vector = Vector
|
||||
fake_freecad.Rotation = Rotation
|
||||
fake_freecad.Placement = Placement
|
||||
fake_freecad.Console = types.SimpleNamespace(
|
||||
PrintMessage=lambda *args, **kwargs: None,
|
||||
PrintWarning=lambda *args, **kwargs: None,
|
||||
PrintError=lambda *args, **kwargs: None,
|
||||
)
|
||||
fake_freecad.ActiveDocument = None
|
||||
sys.modules["FreeCAD"] = fake_freecad
|
||||
|
||||
fake_freecadgui = types.ModuleType("FreeCADGui")
|
||||
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
|
||||
fake_freecadgui.addCommand = lambda *args, **kwargs: None
|
||||
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
|
||||
sys.modules["FreeCADGui"] = fake_freecadgui
|
||||
|
||||
fake_importgui = types.ModuleType("ImportGui")
|
||||
fake_importgui.insert = lambda *args, **kwargs: None
|
||||
sys.modules["ImportGui"] = fake_importgui
|
||||
|
||||
|
||||
class FakeViewObject:
|
||||
def __init__(self):
|
||||
self.Visibility = True
|
||||
self.ShapeColor = None
|
||||
|
||||
|
||||
class FakeObject:
|
||||
def __init__(self, name, type_id):
|
||||
self.Name = name
|
||||
self.Label = name
|
||||
self.TypeId = type_id
|
||||
self.PropertiesList = []
|
||||
self.Group = []
|
||||
self.InList = []
|
||||
self.ViewObject = FakeViewObject()
|
||||
self.Placement = sys.modules["FreeCAD"].Placement()
|
||||
|
||||
def isDerivedFrom(self, type_name):
|
||||
if self.TypeId == type_name:
|
||||
return True
|
||||
if type_name == "App::DocumentObjectGroup":
|
||||
return self.TypeId in {"App::DocumentObjectGroup", "App::Part"}
|
||||
if type_name == "App::LocalCoordinateSystem":
|
||||
return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"}
|
||||
return False
|
||||
|
||||
def addProperty(self, prop_type, prop_name, group_name, description):
|
||||
if prop_name not in self.PropertiesList:
|
||||
self.PropertiesList.append(prop_name)
|
||||
|
||||
def addObject(self, child):
|
||||
if child not in self.Group:
|
||||
self.Group.append(child)
|
||||
if self not in child.InList:
|
||||
child.InList.append(self)
|
||||
|
||||
|
||||
class FakeDocument:
|
||||
def __init__(self):
|
||||
self.Name = "QETScene"
|
||||
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 removeObject(self, name):
|
||||
self.Objects = [obj for obj in self.Objects if obj.Name != name]
|
||||
|
||||
def recompute(self):
|
||||
return None
|
||||
|
||||
|
||||
def _reload_modules():
|
||||
for name in [
|
||||
"TerminalObjects",
|
||||
"TemplateSemantics",
|
||||
"DeviceImport",
|
||||
"TemplateInstantiation",
|
||||
]:
|
||||
sys.modules.pop(name, None)
|
||||
return importlib.import_module("TemplateInstantiation"), importlib.import_module("TerminalObjects")
|
||||
|
||||
|
||||
class TemplateInstantiationTest(unittest.TestCase):
|
||||
def test_template_lcs_slots_become_engineering_terminals(self):
|
||||
_install_fake_freecad()
|
||||
template_instantiation, terminal_objects = _reload_modules()
|
||||
app = sys.modules["FreeCAD"]
|
||||
|
||||
doc = FakeDocument()
|
||||
root = terminal_objects.ensure_root_group(doc, "project-1")
|
||||
device = doc.addObject("App::Part", "QETDevice_ct_1")
|
||||
root.addObject(device)
|
||||
terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "ct-1")
|
||||
terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "inst-1")
|
||||
terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1")
|
||||
|
||||
p1 = doc.addObject("Part::LocalCoordinateSystem", "P1")
|
||||
p1.Placement = app.Placement(app.Vector(10, 20, 30), app.Rotation())
|
||||
p1.addProperty("App::PropertyString", "Role", "QET Template", "")
|
||||
p1.Role = "Terminal"
|
||||
p1.addProperty("App::PropertyBool", "CanWire", "QET Template", "")
|
||||
p1.CanWire = True
|
||||
p1.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "")
|
||||
p1.QetTemplateSlotName = "P1"
|
||||
device.addObject(p1)
|
||||
|
||||
report = template_instantiation.ensure_engineering_terminals_for_device(doc, device)
|
||||
|
||||
terminal_group = terminal_objects.find_child_group_by_kind(
|
||||
device,
|
||||
terminal_objects.TERMINAL_GROUP_KIND,
|
||||
)
|
||||
terminals = terminal_objects.collect_terminal_objects(terminal_group)
|
||||
|
||||
self.assertEqual(1, report["created_terminals"])
|
||||
self.assertEqual(1, len(terminals))
|
||||
self.assertEqual("local:inst-1:P1", terminals[0].QetTerminalUuid)
|
||||
self.assertEqual("inst-1", terminals[0].QetInstanceId)
|
||||
self.assertEqual("ct-1", terminals[0].QetElementUuid)
|
||||
self.assertEqual("P1", terminals[0].QetTemplateSlotName)
|
||||
self.assertTrue(terminals[0].CanWire)
|
||||
self.assertFalse(p1.ViewObject.Visibility)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue