Merge branch 'dev' of https://git.ngsk.tech/liuhengyin/LightWork3D into dev
commit
6b8ffb4036
@ -0,0 +1,323 @@
|
||||
# FreeCADExchange write-back helpers.
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
import DeviceImport
|
||||
import TerminalObjects as TerminalObjects
|
||||
|
||||
try:
|
||||
import FreeCADGui as Gui
|
||||
except ImportError:
|
||||
Gui = None
|
||||
|
||||
|
||||
STATE_WRITEBACK_OBSERVER = "_qet_exchange_writeback_observer"
|
||||
|
||||
|
||||
class ExchangeWriteBackError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _append_debug_log(message):
|
||||
try:
|
||||
DeviceImport._append_debug_log(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _project_uuid_from_payload(payload):
|
||||
if isinstance(payload, dict):
|
||||
value = (payload.get("project_uuid") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _root_group(doc):
|
||||
try:
|
||||
return doc.getObject(TerminalObjects.ROOT_GROUP_NAME)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_device_group(obj):
|
||||
if obj is None:
|
||||
return False
|
||||
try:
|
||||
if not obj.Name.startswith(DeviceImport.DEVICE_GROUP_PREFIX):
|
||||
return False
|
||||
return "QetElementUuid" in getattr(obj, "PropertiesList", [])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _iter_device_groups(doc):
|
||||
root = _root_group(doc)
|
||||
if root is not None:
|
||||
for child in list(getattr(root, "Group", []) or []):
|
||||
if _is_device_group(child):
|
||||
yield child
|
||||
return
|
||||
|
||||
for obj in doc.Objects:
|
||||
if _is_device_group(obj):
|
||||
yield obj
|
||||
|
||||
|
||||
def _iter_terminal_objects(device_group):
|
||||
terminal_container = TerminalObjects.find_child_group_by_kind(
|
||||
device_group,
|
||||
TerminalObjects.TERMINAL_GROUP_KIND,
|
||||
)
|
||||
if terminal_container is None:
|
||||
return []
|
||||
return TerminalObjects.collect_terminal_objects(terminal_container)
|
||||
|
||||
|
||||
def _scene_path_from_doc(doc, scene_path=""):
|
||||
candidate = (scene_path or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
env_scene = os.environ.get("QET_FREECAD_SCENE_FILE", "").strip()
|
||||
if env_scene:
|
||||
return env_scene
|
||||
|
||||
file_name = getattr(doc, "FileName", "").strip()
|
||||
if file_name:
|
||||
return file_name
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _output_path_for_scene(scene_path):
|
||||
scene_path = (scene_path or "").strip()
|
||||
if not scene_path:
|
||||
return ""
|
||||
|
||||
path = Path(scene_path)
|
||||
if path.suffix.lower() == ".fcstd":
|
||||
return str(path.with_name("3d_to_2d.json"))
|
||||
if path.is_dir():
|
||||
return str(path / "3d_to_2d.json")
|
||||
if path.name.lower().endswith(".fcstd"):
|
||||
return str(path.with_name("3d_to_2d.json"))
|
||||
return str(path.parent / "3d_to_2d.json")
|
||||
|
||||
|
||||
def _format_timestamp():
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _collect_instance_bindings(doc):
|
||||
bindings = []
|
||||
seen = set()
|
||||
for device_group in _iter_device_groups(doc):
|
||||
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
|
||||
instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
if not element_uuid or not instance_id:
|
||||
continue
|
||||
key = (element_uuid, instance_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
bindings.append(
|
||||
{
|
||||
"element_uuid": element_uuid,
|
||||
"instance_id": instance_id,
|
||||
}
|
||||
)
|
||||
return bindings
|
||||
|
||||
|
||||
def _collect_terminal_bindings(doc):
|
||||
bindings = []
|
||||
seen = set()
|
||||
for device_group in _iter_device_groups(doc):
|
||||
instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
for terminal_obj in _iter_terminal_objects(device_group):
|
||||
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
|
||||
terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id
|
||||
if not terminal_uuid or not terminal_instance_id:
|
||||
continue
|
||||
key = (terminal_uuid, terminal_instance_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
bindings.append(
|
||||
{
|
||||
"terminal_uuid": terminal_uuid,
|
||||
"instance_id": terminal_instance_id,
|
||||
}
|
||||
)
|
||||
return bindings
|
||||
|
||||
|
||||
def _project_uuid_from_doc(doc, payload=None):
|
||||
root = _root_group(doc)
|
||||
if root is not None:
|
||||
project_uuid = getattr(root, "QetProjectUuid", "").strip()
|
||||
if project_uuid:
|
||||
return project_uuid
|
||||
return _project_uuid_from_payload(payload)
|
||||
|
||||
|
||||
def write_back_document(doc=None, scene_path="", payload=None):
|
||||
if doc is None:
|
||||
doc = App.ActiveDocument
|
||||
if doc is None:
|
||||
raise ExchangeWriteBackError("No active FreeCAD document is available.")
|
||||
|
||||
scene_path = _scene_path_from_doc(doc, scene_path)
|
||||
output_path = _output_path_for_scene(scene_path)
|
||||
if not output_path:
|
||||
raise ExchangeWriteBackError(
|
||||
"Cannot determine the 3d_to_2d.json output path."
|
||||
)
|
||||
|
||||
project_uuid = _project_uuid_from_doc(doc, payload)
|
||||
if not project_uuid:
|
||||
raise ExchangeWriteBackError(
|
||||
"Cannot determine project_uuid for write-back."
|
||||
)
|
||||
|
||||
report = {
|
||||
"schema_version": "1.0",
|
||||
"project_uuid": project_uuid,
|
||||
"generated_at": _format_timestamp(),
|
||||
"instances": _collect_instance_bindings(doc),
|
||||
"terminals": _collect_terminal_bindings(doc),
|
||||
"output_path": output_path,
|
||||
}
|
||||
|
||||
output_dir = str(Path(output_path).parent)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
Path(output_path).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": report["schema_version"],
|
||||
"project_uuid": report["project_uuid"],
|
||||
"generated_at": report["generated_at"],
|
||||
"instances": report["instances"],
|
||||
"terminals": report["terminals"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_append_debug_log(
|
||||
"write_back_document completed: instances={0}, terminals={1}, path={2}".format(
|
||||
len(report["instances"]),
|
||||
len(report["terminals"]),
|
||||
output_path,
|
||||
)
|
||||
)
|
||||
try:
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] Wrote 3d_to_2d.json to {0}\n".format(output_path)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return report
|
||||
|
||||
|
||||
def _is_exchange_document(doc):
|
||||
if doc is None:
|
||||
return False
|
||||
|
||||
if _root_group(doc) is not None:
|
||||
return True
|
||||
|
||||
for obj in doc.Objects:
|
||||
if _is_device_group(obj):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _WriteBackObserver:
|
||||
def slotFinishSaveDocument(self, doc, name):
|
||||
if not _is_exchange_document(doc):
|
||||
return
|
||||
try:
|
||||
write_back_document(doc, scene_path=name)
|
||||
except Exception as exc:
|
||||
_append_debug_log("write-back after save failed: {0}".format(exc))
|
||||
try:
|
||||
App.Console.PrintError(
|
||||
"[FreeCADExchange] write-back after save failed: {0}\n".format(exc)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_document_observer_installed():
|
||||
if getattr(App, STATE_WRITEBACK_OBSERVER, None) is not None:
|
||||
return getattr(App, STATE_WRITEBACK_OBSERVER)
|
||||
|
||||
observer = _WriteBackObserver()
|
||||
try:
|
||||
App.addDocumentObserver(observer)
|
||||
except Exception as exc:
|
||||
_append_debug_log("failed to add write-back observer: {0}".format(exc))
|
||||
return None
|
||||
|
||||
setattr(App, STATE_WRITEBACK_OBSERVER, observer)
|
||||
return observer
|
||||
|
||||
|
||||
class CommandWriteBack:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Write Back 3D Binding",
|
||||
"ToolTip": "Generate 3d_to_2d.json from the current FreeCAD document",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
def Activated(self):
|
||||
try:
|
||||
report = write_back_document(App.ActiveDocument)
|
||||
try:
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] Write-back completed: {0} instances, {1} terminals\n".format(
|
||||
len(report["instances"]),
|
||||
len(report["terminals"]),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
try:
|
||||
App.Console.PrintError(
|
||||
"[FreeCADExchange] Write-back failed: {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("QET_Exchange_WriteBack", CommandWriteBack())
|
||||
_COMMANDS_REGISTERED = True
|
||||
except Exception as exc:
|
||||
_append_debug_log("failed to register write-back command: {0}".format(exc))
|
||||
|
||||
|
||||
register_commands()
|
||||
ensure_document_observer_installed()
|
||||
@ -0,0 +1,236 @@
|
||||
# FreeCADExchange manual wiring helpers.
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
try:
|
||||
import FreeCADGui as Gui
|
||||
except ImportError:
|
||||
Gui = None
|
||||
|
||||
import DeviceImport
|
||||
import TerminalObjects as TerminalObjects
|
||||
|
||||
|
||||
class ManualWiringError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _append_debug_log(message):
|
||||
try:
|
||||
DeviceImport._append_debug_log(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _terminal_points(start_terminal, end_terminal, waypoints=None):
|
||||
points = [TerminalObjects.terminal_origin(start_terminal)]
|
||||
for point in waypoints or []:
|
||||
if isinstance(point, App.Vector):
|
||||
points.append(point)
|
||||
elif isinstance(point, (list, tuple)) and len(point) >= 3:
|
||||
points.append(App.Vector(float(point[0]), float(point[1]), float(point[2])))
|
||||
points.append(TerminalObjects.terminal_origin(end_terminal))
|
||||
return points
|
||||
|
||||
|
||||
def _wire_object_name(start_terminal, end_terminal):
|
||||
start_uuid = TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", ""))
|
||||
end_uuid = TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", ""))
|
||||
return "QETWire_{0}_{1}".format(start_uuid, end_uuid)
|
||||
|
||||
|
||||
def _set_wire_properties(obj, project_uuid, start_terminal, end_terminal):
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
"QetProjectUuid",
|
||||
"QET Exchange",
|
||||
"Project UUID for this wire",
|
||||
project_uuid,
|
||||
)
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
"QetStartTerminalUuid",
|
||||
"QET Exchange",
|
||||
"Start terminal UUID",
|
||||
getattr(start_terminal, "QetTerminalUuid", "").strip(),
|
||||
)
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
"QetEndTerminalUuid",
|
||||
"QET Exchange",
|
||||
"End terminal UUID",
|
||||
getattr(end_terminal, "QetTerminalUuid", "").strip(),
|
||||
)
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
"QetStartInstanceId",
|
||||
"QET Exchange",
|
||||
"Start device instance ID",
|
||||
getattr(start_terminal, "QetInstanceId", "").strip(),
|
||||
)
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
"QetEndInstanceId",
|
||||
"QET Exchange",
|
||||
"End device instance ID",
|
||||
getattr(end_terminal, "QetInstanceId", "").strip(),
|
||||
)
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
"RouteType",
|
||||
"QET Exchange",
|
||||
"Wire route type",
|
||||
"Manual",
|
||||
)
|
||||
|
||||
|
||||
def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback_group=None):
|
||||
for terminal in (start_terminal, end_terminal):
|
||||
element_uuid = getattr(terminal, "QetElementUuid", "").strip()
|
||||
if not element_uuid:
|
||||
continue
|
||||
|
||||
device_group = TerminalObjects.find_device_group(doc, element_uuid)
|
||||
if device_group is None:
|
||||
continue
|
||||
|
||||
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
return TerminalObjects.ensure_wire_group(
|
||||
doc,
|
||||
device_group,
|
||||
project_uuid=project_uuid,
|
||||
instance_id=device_instance_id,
|
||||
)
|
||||
|
||||
return fallback_group
|
||||
|
||||
|
||||
def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent_group=None):
|
||||
if not TerminalObjects.is_terminal_object(start_terminal):
|
||||
raise ManualWiringError("The start selection is not a valid terminal.")
|
||||
if not TerminalObjects.is_terminal_object(end_terminal):
|
||||
raise ManualWiringError("The end selection is not a valid terminal.")
|
||||
if start_terminal == end_terminal:
|
||||
raise ManualWiringError("The start and end terminal must be different.")
|
||||
|
||||
project_uuid = (
|
||||
getattr(start_terminal, "QetProjectUuid", "").strip()
|
||||
or getattr(end_terminal, "QetProjectUuid", "").strip()
|
||||
or getattr(DeviceImport._ensure_root_group(doc), "QetProjectUuid", "").strip()
|
||||
)
|
||||
if not project_uuid:
|
||||
raise ManualWiringError("A project UUID is required to create a wire.")
|
||||
|
||||
wire_base_name = TerminalObjects.safe_token(
|
||||
_wire_object_name(start_terminal, end_terminal)
|
||||
)
|
||||
wire_name = wire_base_name
|
||||
suffix = 1
|
||||
while doc.getObject(wire_name) is not None:
|
||||
wire_name = "{0}_{1}".format(wire_base_name, suffix)
|
||||
suffix += 1
|
||||
|
||||
wire_obj = doc.addObject("Part::Feature", wire_name)
|
||||
wire_obj.Label = "QET Manual Wire"
|
||||
|
||||
points = _terminal_points(start_terminal, end_terminal, waypoints=waypoints)
|
||||
if len(points) < 2:
|
||||
raise ManualWiringError("A wire requires at least two points.")
|
||||
|
||||
import Part
|
||||
|
||||
wire_obj.Shape = Part.makePolygon(points)
|
||||
_set_wire_properties(
|
||||
wire_obj,
|
||||
project_uuid,
|
||||
start_terminal,
|
||||
end_terminal,
|
||||
)
|
||||
|
||||
if parent_group is None:
|
||||
try:
|
||||
parent_group = _wire_parent_group(
|
||||
doc,
|
||||
project_uuid,
|
||||
start_terminal,
|
||||
end_terminal,
|
||||
fallback_group=DeviceImport._ensure_root_group(doc, project_uuid),
|
||||
)
|
||||
except Exception:
|
||||
parent_group = None
|
||||
if parent_group is not None and wire_obj not in getattr(parent_group, "Group", []):
|
||||
parent_group.addObject(wire_obj)
|
||||
|
||||
try:
|
||||
wire_obj.ViewObject.LineWidth = 2.0
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
wire_obj.ViewObject.LineColor = (0.0, 0.6, 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
doc.recompute()
|
||||
return wire_obj
|
||||
|
||||
|
||||
class CommandCreateManualWire:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Create Manual Wire",
|
||||
"ToolTip": "Create a manual wire between two selected terminals",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None and Gui is not None
|
||||
|
||||
def Activated(self):
|
||||
if Gui is None:
|
||||
return
|
||||
|
||||
selection = [
|
||||
obj
|
||||
for obj in Gui.Selection.getSelection()
|
||||
if TerminalObjects.is_terminal_object(obj)
|
||||
]
|
||||
if len(selection) != 2:
|
||||
try:
|
||||
App.Console.PrintWarning(
|
||||
"Select exactly two valid terminals before creating a wire.\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
create_manual_wire(App.ActiveDocument, selection[0], selection[1])
|
||||
try:
|
||||
Gui.SendMsgToActiveView("ViewFit")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
try:
|
||||
App.Console.PrintError(
|
||||
"[FreeCADExchange] manual wire creation failed: {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("QET_Exchange_CreateManualWire", CommandCreateManualWire())
|
||||
_COMMANDS_REGISTERED = True
|
||||
except Exception as exc:
|
||||
_append_debug_log("failed to register manual wiring command: {0}".format(exc))
|
||||
|
||||
|
||||
register_commands()
|
||||
@ -0,0 +1,332 @@
|
||||
# FreeCADExchange FCStd template authoring helpers.
|
||||
|
||||
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
|
||||
|
||||
|
||||
TEMPLATE_PROPERTY_GROUP = "QET Template"
|
||||
DEFAULT_TERMINAL_TYPE = "generic"
|
||||
|
||||
|
||||
class TemplateAuthoringError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_slot_name(slot_name):
|
||||
value = (slot_name or "").strip()
|
||||
if not value:
|
||||
raise TemplateAuthoringError("Terminal slot name is required.")
|
||||
return value
|
||||
|
||||
|
||||
def _terminal_object_name(slot_name):
|
||||
return "Terminal_{0}".format(TerminalObjects.safe_token(slot_name))
|
||||
|
||||
|
||||
def _ensure_template_property(obj, prop_name, value, prop_type="App::PropertyString"):
|
||||
if prop_type == "App::PropertyBool":
|
||||
TerminalObjects.ensure_bool_property(
|
||||
obj,
|
||||
prop_name,
|
||||
TEMPLATE_PROPERTY_GROUP,
|
||||
"QET template terminal property",
|
||||
bool(value),
|
||||
)
|
||||
else:
|
||||
TerminalObjects.ensure_string_property(
|
||||
obj,
|
||||
prop_name,
|
||||
TEMPLATE_PROPERTY_GROUP,
|
||||
"QET template terminal property",
|
||||
value,
|
||||
)
|
||||
|
||||
|
||||
def set_template_terminal_semantics(obj, slot_name, label="", terminal_type=DEFAULT_TERMINAL_TYPE):
|
||||
slot_name = _safe_slot_name(slot_name)
|
||||
label = (label or "").strip() or slot_name
|
||||
terminal_type = (terminal_type or "").strip() or DEFAULT_TERMINAL_TYPE
|
||||
|
||||
_ensure_template_property(obj, "Role", TerminalObjects.TERMINAL_ROLE)
|
||||
_ensure_template_property(obj, "CanWire", True, prop_type="App::PropertyBool")
|
||||
_ensure_template_property(obj, "QetTemplateSlotName", slot_name)
|
||||
_ensure_template_property(obj, "QetTerminalLabel", label)
|
||||
_ensure_template_property(obj, "QetTerminalType", terminal_type)
|
||||
obj.Label = label
|
||||
return obj
|
||||
|
||||
|
||||
def create_template_terminal(doc, slot_name, position, rotation=None, label="", terminal_type=DEFAULT_TERMINAL_TYPE):
|
||||
if doc is None:
|
||||
raise TemplateAuthoringError("An active FreeCAD document is required.")
|
||||
|
||||
slot_name = _safe_slot_name(slot_name)
|
||||
if position is None:
|
||||
raise TemplateAuthoringError("A terminal position is required.")
|
||||
|
||||
if rotation is None:
|
||||
rotation = App.Rotation()
|
||||
placement = App.Placement(position, rotation)
|
||||
terminal = TerminalObjects.create_lcs_object(
|
||||
doc,
|
||||
_terminal_object_name(slot_name),
|
||||
placement=placement,
|
||||
label=(label or slot_name),
|
||||
)
|
||||
set_template_terminal_semantics(
|
||||
terminal,
|
||||
slot_name,
|
||||
label=label or slot_name,
|
||||
terminal_type=terminal_type,
|
||||
)
|
||||
try:
|
||||
terminal.ViewObject.ShapeColor = (0.0, 0.75, 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
doc.recompute()
|
||||
return terminal
|
||||
|
||||
|
||||
def is_template_terminal(obj):
|
||||
if obj is None:
|
||||
return False
|
||||
return TerminalObjects.is_terminal_hint_object(obj)
|
||||
|
||||
|
||||
def _has_property(obj, prop_name):
|
||||
return prop_name in getattr(obj, "PropertiesList", [])
|
||||
|
||||
|
||||
def validate_template_terminals(doc):
|
||||
report = {
|
||||
"document_name": getattr(doc, "Name", ""),
|
||||
"total_terminals": 0,
|
||||
"valid_terminals": 0,
|
||||
"warnings": [],
|
||||
"terminals": [],
|
||||
}
|
||||
if doc is None:
|
||||
report["warnings"].append("No active FreeCAD document.")
|
||||
return report
|
||||
|
||||
for obj in list(getattr(doc, "Objects", []) or []):
|
||||
if not is_template_terminal(obj):
|
||||
continue
|
||||
|
||||
report["total_terminals"] += 1
|
||||
slot_name = getattr(obj, "QetTemplateSlotName", "").strip()
|
||||
can_wire = bool(getattr(obj, "CanWire", False))
|
||||
item = {
|
||||
"name": getattr(obj, "Name", ""),
|
||||
"label": getattr(obj, "Label", ""),
|
||||
"slot_name": slot_name,
|
||||
"can_wire": can_wire,
|
||||
}
|
||||
report["terminals"].append(item)
|
||||
|
||||
valid = True
|
||||
if not _has_property(obj, "QetTemplateSlotName") or not slot_name:
|
||||
report["warnings"].append(
|
||||
"Template terminal {0} is missing QetTemplateSlotName.".format(
|
||||
getattr(obj, "Name", "")
|
||||
)
|
||||
)
|
||||
valid = False
|
||||
if not can_wire:
|
||||
report["warnings"].append(
|
||||
"Template terminal {0} has CanWire disabled.".format(
|
||||
getattr(obj, "Name", "")
|
||||
)
|
||||
)
|
||||
valid = False
|
||||
if valid:
|
||||
report["valid_terminals"] += 1
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _fcstd_path(path):
|
||||
value = (path or "").strip()
|
||||
if not value:
|
||||
raise TemplateAuthoringError("A target FCStd path is required.")
|
||||
if not value.lower().endswith(".fcstd"):
|
||||
value = value + ".FCStd"
|
||||
return value
|
||||
|
||||
|
||||
def save_template_as_fcstd(doc, path):
|
||||
if doc is None:
|
||||
raise TemplateAuthoringError("An active FreeCAD document is required.")
|
||||
|
||||
target_path = _fcstd_path(path)
|
||||
report = validate_template_terminals(doc)
|
||||
if report["total_terminals"] <= 0:
|
||||
raise TemplateAuthoringError("At least one template terminal is required before saving.")
|
||||
if report["warnings"]:
|
||||
raise TemplateAuthoringError(
|
||||
"Template terminals must be valid before saving: {0}".format(
|
||||
"; ".join(report["warnings"])
|
||||
)
|
||||
)
|
||||
|
||||
doc.saveAs(target_path)
|
||||
report["path"] = target_path
|
||||
return report
|
||||
|
||||
|
||||
def _selection_position():
|
||||
if Gui is None:
|
||||
return None
|
||||
try:
|
||||
selection_ex = Gui.Selection.getSelectionEx()
|
||||
except Exception:
|
||||
return None
|
||||
if not selection_ex:
|
||||
return None
|
||||
|
||||
picked = selection_ex[0]
|
||||
picked_points = list(getattr(picked, "PickedPoints", []) or [])
|
||||
if picked_points:
|
||||
return picked_points[0]
|
||||
|
||||
obj = getattr(picked, "Object", None)
|
||||
shape = getattr(obj, "Shape", None)
|
||||
bound_box = getattr(shape, "BoundBox", None)
|
||||
if bound_box is None:
|
||||
return None
|
||||
return App.Vector(
|
||||
(bound_box.XMin + bound_box.XMax) * 0.5,
|
||||
(bound_box.YMin + bound_box.YMax) * 0.5,
|
||||
(bound_box.ZMin + bound_box.ZMax) * 0.5,
|
||||
)
|
||||
|
||||
|
||||
class CommandAddTemplateTerminal:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "添加模板端子",
|
||||
"ToolTip": "在 FCStd 设备模板中创建可接线端子 LCS",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None and Gui is not None
|
||||
|
||||
def Activated(self):
|
||||
if Gui is None:
|
||||
return
|
||||
position = _selection_position()
|
||||
if position is None:
|
||||
App.Console.PrintWarning(
|
||||
"Select a model point or model object before adding a template terminal.\n"
|
||||
)
|
||||
return
|
||||
|
||||
slot_name = "T{0}".format(
|
||||
len(validate_template_terminals(App.ActiveDocument)["terminals"]) + 1
|
||||
)
|
||||
try:
|
||||
create_template_terminal(App.ActiveDocument, slot_name, position)
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] Created template terminal {0}. Rename QetTemplateSlotName if needed.\n".format(
|
||||
slot_name
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
App.Console.PrintError(
|
||||
"[FreeCADExchange] template terminal creation failed: {0}\n".format(exc)
|
||||
)
|
||||
|
||||
|
||||
class CommandValidateTemplateTerminals:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "校验模板端子",
|
||||
"ToolTip": "校验当前 FCStd 模板中的电气端子 LCS",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
def Activated(self):
|
||||
report = validate_template_terminals(App.ActiveDocument)
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] Template terminals: {0} total, {1} valid\n".format(
|
||||
report["total_terminals"],
|
||||
report["valid_terminals"],
|
||||
)
|
||||
)
|
||||
for warning in report["warnings"]:
|
||||
App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning))
|
||||
|
||||
|
||||
class CommandSaveTemplateAsFCStd:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "保存模板为 FCStd",
|
||||
"ToolTip": "校验并保存当前文档为可复用 FCStd 设备模板",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
def Activated(self):
|
||||
if QtWidgets is None:
|
||||
App.Console.PrintError("[FreeCADExchange] Qt file dialog is not available.\n")
|
||||
return
|
||||
|
||||
file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName(
|
||||
None,
|
||||
"保存 FCStd 设备模板",
|
||||
"",
|
||||
"FreeCAD template (*.FCStd *.fcstd);;All files (*.*)",
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
report = save_template_as_fcstd(App.ActiveDocument, file_path)
|
||||
App.Console.PrintMessage(
|
||||
"[FreeCADExchange] Saved FCStd template: {0} ({1} terminals)\n".format(
|
||||
report["path"],
|
||||
report["valid_terminals"],
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
App.Console.PrintError(
|
||||
"[FreeCADExchange] template save failed: {0}\n".format(exc)
|
||||
)
|
||||
|
||||
|
||||
_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_Template_AddTerminal", CommandAddTemplateTerminal())
|
||||
Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals())
|
||||
Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd())
|
||||
_COMMANDS_REGISTERED = True
|
||||
|
||||
|
||||
register_commands()
|
||||
@ -0,0 +1,330 @@
|
||||
# FreeCADExchange GUI panel for FCStd equipment template authoring.
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
try:
|
||||
import FreeCADGui as Gui
|
||||
except ImportError:
|
||||
Gui = None
|
||||
|
||||
try:
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide2 import QtGui, QtWidgets
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide import QtGui
|
||||
from PySide import QtGui as QtWidgets
|
||||
except ImportError:
|
||||
QtGui = None
|
||||
QtWidgets = None
|
||||
|
||||
import TemplateAuthoring
|
||||
|
||||
|
||||
COMMAND_NAME = "QET_Template_OpenAuthoringPanel"
|
||||
MENU_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_MenuAction"
|
||||
TOOLBAR_OBJECT_NAME = "QET_Template_Authoring_Toolbar"
|
||||
TOOLBAR_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_ToolbarAction"
|
||||
TERMINAL_TYPE_OPTIONS = [
|
||||
("通用", "generic"),
|
||||
("主回路", "primary"),
|
||||
("电源", "power"),
|
||||
("控制", "control"),
|
||||
]
|
||||
|
||||
|
||||
def terminal_type_value(combo):
|
||||
value = None
|
||||
if hasattr(combo, "currentData"):
|
||||
value = combo.currentData()
|
||||
if value:
|
||||
return str(value).strip()
|
||||
|
||||
text = combo.currentText().strip() if hasattr(combo, "currentText") else ""
|
||||
for label, option_value in TERMINAL_TYPE_OPTIONS:
|
||||
if text == label or text == option_value:
|
||||
return option_value
|
||||
return "generic"
|
||||
|
||||
|
||||
def next_slot_name(report):
|
||||
terminals = list((report or {}).get("terminals", []) or [])
|
||||
return "T{0}".format(len(terminals) + 1)
|
||||
|
||||
|
||||
def terminal_list_text(report):
|
||||
rows = []
|
||||
for item in list((report or {}).get("terminals", []) or []):
|
||||
slot_name = (item.get("slot_name", "") or "").strip() or "(unnamed)"
|
||||
object_name = (item.get("name", "") or "").strip() or "(object)"
|
||||
suffix = "" if item.get("can_wire", False) and slot_name != "(unnamed)" else " [invalid]"
|
||||
rows.append("{0} - {1}{2}".format(slot_name, object_name, suffix))
|
||||
return rows
|
||||
|
||||
|
||||
class TemplateAuthoringTaskPanel:
|
||||
def __init__(self):
|
||||
if QtWidgets is None:
|
||||
raise TemplateAuthoring.TemplateAuthoringError("Qt widgets are not available.")
|
||||
|
||||
self.form = QtWidgets.QWidget()
|
||||
self.form.setWindowTitle("设备模板端子制作")
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self.form)
|
||||
|
||||
name_row = QtWidgets.QHBoxLayout()
|
||||
name_row.addWidget(QtWidgets.QLabel("端子名"))
|
||||
self.slot_name_edit = QtWidgets.QLineEdit()
|
||||
self.slot_name_edit.setPlaceholderText("P1")
|
||||
name_row.addWidget(self.slot_name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
type_row = QtWidgets.QHBoxLayout()
|
||||
type_row.addWidget(QtWidgets.QLabel("端子类型"))
|
||||
self.terminal_type_combo = QtWidgets.QComboBox()
|
||||
for label, value in TERMINAL_TYPE_OPTIONS:
|
||||
self.terminal_type_combo.addItem(label, value)
|
||||
type_row.addWidget(self.terminal_type_combo)
|
||||
layout.addLayout(type_row)
|
||||
|
||||
self.add_button = QtWidgets.QPushButton("添加端子")
|
||||
self.validate_button = QtWidgets.QPushButton("校验端子")
|
||||
self.save_button = QtWidgets.QPushButton("保存为 FCStd")
|
||||
self.refresh_button = QtWidgets.QPushButton("刷新列表")
|
||||
|
||||
layout.addWidget(self.add_button)
|
||||
layout.addWidget(self.validate_button)
|
||||
layout.addWidget(self.save_button)
|
||||
layout.addWidget(self.refresh_button)
|
||||
|
||||
self.terminal_list = QtWidgets.QListWidget()
|
||||
layout.addWidget(self.terminal_list)
|
||||
|
||||
self.status_label = QtWidgets.QLabel("")
|
||||
self.status_label.setWordWrap(True)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.add_button.clicked.connect(self.add_terminal)
|
||||
self.validate_button.clicked.connect(self.validate_terminals)
|
||||
self.save_button.clicked.connect(self.save_template)
|
||||
self.refresh_button.clicked.connect(self.refresh)
|
||||
|
||||
self.refresh()
|
||||
|
||||
def _document(self):
|
||||
doc = getattr(App, "ActiveDocument", None)
|
||||
if doc is None:
|
||||
raise TemplateAuthoring.TemplateAuthoringError("请先打开或创建一个 FreeCAD 文档。")
|
||||
return doc
|
||||
|
||||
def _set_status(self, message):
|
||||
self.status_label.setText(message)
|
||||
try:
|
||||
App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_error(self, message):
|
||||
self.status_label.setText(message)
|
||||
try:
|
||||
App.Console.PrintError("[FreeCADExchange] {0}\n".format(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def refresh(self):
|
||||
try:
|
||||
report = TemplateAuthoring.validate_template_terminals(getattr(App, "ActiveDocument", None))
|
||||
except Exception as exc:
|
||||
self._set_error(str(exc))
|
||||
return
|
||||
|
||||
self.terminal_list.clear()
|
||||
for row in terminal_list_text(report):
|
||||
self.terminal_list.addItem(row)
|
||||
|
||||
if not self.slot_name_edit.text().strip():
|
||||
self.slot_name_edit.setText(next_slot_name(report))
|
||||
|
||||
self.status_label.setText(
|
||||
"端子:{0} 个,有效:{1} 个".format(
|
||||
report.get("total_terminals", 0),
|
||||
report.get("valid_terminals", 0),
|
||||
)
|
||||
)
|
||||
|
||||
def add_terminal(self):
|
||||
try:
|
||||
doc = self._document()
|
||||
slot_name = self.slot_name_edit.text().strip()
|
||||
position = TemplateAuthoring._selection_position()
|
||||
if position is None:
|
||||
raise TemplateAuthoring.TemplateAuthoringError("请先在模型上选择端子位置。")
|
||||
terminal_type = terminal_type_value(self.terminal_type_combo)
|
||||
TemplateAuthoring.create_template_terminal(
|
||||
doc,
|
||||
slot_name,
|
||||
position,
|
||||
terminal_type=terminal_type,
|
||||
)
|
||||
self.slot_name_edit.clear()
|
||||
self.refresh()
|
||||
self._set_status("已添加端子:{0}".format(slot_name))
|
||||
except Exception as exc:
|
||||
self._set_error(str(exc))
|
||||
|
||||
def validate_terminals(self):
|
||||
try:
|
||||
report = TemplateAuthoring.validate_template_terminals(self._document())
|
||||
self.refresh()
|
||||
if report.get("warnings"):
|
||||
self._set_error("校验发现问题:{0}".format("; ".join(report["warnings"])))
|
||||
else:
|
||||
self._set_status(
|
||||
"校验通过:{0}/{1} 个端子有效".format(
|
||||
report.get("valid_terminals", 0),
|
||||
report.get("total_terminals", 0),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_error(str(exc))
|
||||
|
||||
def save_template(self):
|
||||
try:
|
||||
doc = self._document()
|
||||
file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self.form,
|
||||
"保存 FCStd 设备模板",
|
||||
"",
|
||||
"FreeCAD template (*.FCStd *.fcstd);;All files (*.*)",
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
report = TemplateAuthoring.save_template_as_fcstd(doc, file_path)
|
||||
self.refresh()
|
||||
self._set_status("已保存模板:{0}".format(report["path"]))
|
||||
except Exception as exc:
|
||||
self._set_error(str(exc))
|
||||
|
||||
def accept(self):
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
return True
|
||||
|
||||
|
||||
class CommandOpenTemplateAuthoringPanel:
|
||||
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 Gui is None or not hasattr(Gui, "Control"):
|
||||
return
|
||||
if hasattr(Gui.Control, "activeDialog") and Gui.Control.activeDialog():
|
||||
Gui.Control.closeDialog()
|
||||
Gui.Control.showDialog(TemplateAuthoringTaskPanel())
|
||||
|
||||
|
||||
_COMMANDS_REGISTERED = False
|
||||
_MENU_ACTION_INSTALLED = False
|
||||
_TOOLBAR_ACTION_INSTALLED = False
|
||||
|
||||
|
||||
def _tools_menu():
|
||||
if Gui is None or QtWidgets is None or QtGui is None:
|
||||
return None
|
||||
if not hasattr(Gui, "getMainWindow"):
|
||||
return None
|
||||
main_window = Gui.getMainWindow()
|
||||
if main_window is None:
|
||||
return None
|
||||
menu_bar = main_window.menuBar()
|
||||
for action in menu_bar.actions():
|
||||
menu = action.menu()
|
||||
text = action.text().replace("&", "").strip()
|
||||
if menu is not None and (text.startswith("工具") or text.startswith("Tools")):
|
||||
return menu
|
||||
return menu_bar.addMenu("工具")
|
||||
|
||||
|
||||
def install_menu_action():
|
||||
global _MENU_ACTION_INSTALLED
|
||||
if _MENU_ACTION_INSTALLED:
|
||||
return
|
||||
menu = _tools_menu()
|
||||
if menu is None or QtGui is None:
|
||||
return
|
||||
for action in menu.actions():
|
||||
if action.objectName() == MENU_ACTION_OBJECT_NAME:
|
||||
_MENU_ACTION_INSTALLED = True
|
||||
return
|
||||
action = QtGui.QAction("设备模板端子制作", menu)
|
||||
action.setObjectName(MENU_ACTION_OBJECT_NAME)
|
||||
action.triggered.connect(lambda: Gui.runCommand(COMMAND_NAME))
|
||||
menu.addAction(action)
|
||||
_MENU_ACTION_INSTALLED = True
|
||||
|
||||
|
||||
def install_toolbar_action():
|
||||
global _TOOLBAR_ACTION_INSTALLED
|
||||
if _TOOLBAR_ACTION_INSTALLED:
|
||||
return
|
||||
if Gui is None or QtGui is None or QtWidgets is None or not hasattr(Gui, "getMainWindow"):
|
||||
return
|
||||
main_window = Gui.getMainWindow()
|
||||
if main_window is None:
|
||||
return
|
||||
|
||||
toolbar = None
|
||||
for candidate in main_window.findChildren(QtWidgets.QToolBar):
|
||||
if candidate.objectName() == TOOLBAR_OBJECT_NAME:
|
||||
toolbar = candidate
|
||||
break
|
||||
if toolbar is None:
|
||||
toolbar = main_window.addToolBar("QET模板")
|
||||
toolbar.setObjectName(TOOLBAR_OBJECT_NAME)
|
||||
|
||||
for action in toolbar.actions():
|
||||
if action.objectName() == TOOLBAR_ACTION_OBJECT_NAME:
|
||||
_TOOLBAR_ACTION_INSTALLED = True
|
||||
return
|
||||
|
||||
action = QtGui.QAction("设备模板端子制作", toolbar)
|
||||
action.setObjectName(TOOLBAR_ACTION_OBJECT_NAME)
|
||||
action.setToolTip("打开设备模板端子制作面板")
|
||||
action.triggered.connect(lambda: Gui.runCommand(COMMAND_NAME))
|
||||
toolbar.addAction(action)
|
||||
_TOOLBAR_ACTION_INSTALLED = True
|
||||
|
||||
|
||||
def _warn_register_commands_failed(target, exc):
|
||||
try:
|
||||
App.Console.PrintWarning(
|
||||
"[FreeCADExchange] {0} installation skipped: {1}\n".format(target, exc)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_commands():
|
||||
global _COMMANDS_REGISTERED
|
||||
if Gui is None or not hasattr(Gui, "addCommand"):
|
||||
return
|
||||
if not _COMMANDS_REGISTERED:
|
||||
Gui.addCommand(COMMAND_NAME, CommandOpenTemplateAuthoringPanel())
|
||||
_COMMANDS_REGISTERED = True
|
||||
try:
|
||||
install_menu_action()
|
||||
except RuntimeError as exc:
|
||||
_warn_register_commands_failed("menu action", exc)
|
||||
try:
|
||||
install_toolbar_action()
|
||||
except RuntimeError as exc:
|
||||
_warn_register_commands_failed("toolbar action", exc)
|
||||
@ -0,0 +1,363 @@
|
||||
# FreeCADExchange template semantics helpers.
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
import TerminalObjects as TerminalObjects
|
||||
|
||||
|
||||
def _sidecar_candidates(model_path):
|
||||
native = TerminalObjects.native_path(model_path)
|
||||
if not native:
|
||||
return []
|
||||
|
||||
path = Path(native)
|
||||
base = path.stem
|
||||
parent = path.parent
|
||||
return [
|
||||
parent / (base + ".terminals.json"),
|
||||
parent / (base + ".qet_terminals.json"),
|
||||
parent / (base + ".terminal_slots.json"),
|
||||
parent / (base + ".qet_template.json"),
|
||||
]
|
||||
|
||||
|
||||
def _load_json_file(path):
|
||||
try:
|
||||
raw_text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _vector_from_value(value):
|
||||
if isinstance(value, App.Vector):
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
if {"x", "y", "z"}.issubset(value.keys()):
|
||||
return App.Vector(
|
||||
float(value["x"]),
|
||||
float(value["y"]),
|
||||
float(value["z"]),
|
||||
)
|
||||
|
||||
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||||
return App.Vector(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _rotation_from_value(value):
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
axis = _vector_from_value(value.get("axis"))
|
||||
angle = value.get("angle")
|
||||
if axis is None or angle is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
angle = float(angle)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"axis": axis,
|
||||
"angle": angle,
|
||||
}
|
||||
|
||||
|
||||
def _rotation_from_object(source_object):
|
||||
if source_object is None:
|
||||
return None
|
||||
|
||||
rotation = None
|
||||
placement = getattr(source_object, "Placement", None)
|
||||
if placement is not None:
|
||||
rotation = getattr(placement, "Rotation", None)
|
||||
if rotation is None:
|
||||
rotation = getattr(source_object, "Rotation", None)
|
||||
if rotation is None:
|
||||
return None
|
||||
|
||||
axis = getattr(rotation, "Axis", None)
|
||||
if axis is None:
|
||||
axis = getattr(rotation, "axis", None)
|
||||
angle = getattr(rotation, "Angle", None)
|
||||
if angle is None:
|
||||
angle = getattr(rotation, "angle", None)
|
||||
|
||||
axis = _vector_from_value(axis)
|
||||
if axis is None or angle is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
angle = float(angle)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"axis": axis,
|
||||
"angle": angle,
|
||||
}
|
||||
|
||||
|
||||
def _slot_from_payload(item, source, index, source_object=None):
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
name = (item.get("name") or item.get("slot_name") or item.get("label") or "").strip()
|
||||
if not name:
|
||||
name = "SLOT_{0}".format(index + 1)
|
||||
|
||||
label = (item.get("label") or item.get("display_tag") or name).strip()
|
||||
base = None
|
||||
placement = item.get("placement")
|
||||
if isinstance(placement, dict):
|
||||
base = _vector_from_value(placement.get("base"))
|
||||
if base is None:
|
||||
base = _vector_from_value(item.get("base"))
|
||||
if base is None:
|
||||
base = _vector_from_value(item.get("position"))
|
||||
if base is None:
|
||||
base = _vector_from_value(item.get("origin"))
|
||||
if base is None:
|
||||
base = _vector_from_value(item.get("point"))
|
||||
|
||||
if base is None:
|
||||
x = item.get("x")
|
||||
y = item.get("y")
|
||||
z = item.get("z")
|
||||
if x is not None and y is not None and z is not None:
|
||||
base = App.Vector(float(x), float(y), float(z))
|
||||
|
||||
if base is None:
|
||||
return None
|
||||
|
||||
rotation = None
|
||||
placement_rotation = None
|
||||
placement = item.get("placement")
|
||||
if isinstance(placement, dict):
|
||||
placement_rotation = _rotation_from_value(placement.get("rotation"))
|
||||
rotation = _rotation_from_value(item.get("rotation"))
|
||||
if rotation is None:
|
||||
rotation = placement_rotation
|
||||
if rotation is None:
|
||||
rotation = _rotation_from_object(source_object)
|
||||
|
||||
slot = {
|
||||
"name": name,
|
||||
"label": label,
|
||||
"base": base,
|
||||
"source": source,
|
||||
"source_object": source_object,
|
||||
}
|
||||
if rotation is not None:
|
||||
slot["rotation"] = rotation
|
||||
return slot
|
||||
|
||||
|
||||
def _is_child_group(obj, group_kind):
|
||||
try:
|
||||
return (
|
||||
obj is not None
|
||||
and obj.isDerivedFrom("App::DocumentObjectGroup")
|
||||
and getattr(obj, "QetGroupKind", "").strip() == group_kind
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def collect_terminal_hints(container):
|
||||
hints = []
|
||||
if container is None:
|
||||
return hints
|
||||
|
||||
for child in list(getattr(container, "Group", []) or []):
|
||||
if TerminalObjects.is_terminal_hint_object(child) and not TerminalObjects.is_terminal_object(child):
|
||||
hint = _slot_from_payload(
|
||||
{
|
||||
"name": getattr(child, "Name", ""),
|
||||
"label": getattr(child, "Label", "") or getattr(child, "Name", ""),
|
||||
"base": [
|
||||
getattr(child.Placement.Base, "x", 0.0),
|
||||
getattr(child.Placement.Base, "y", 0.0),
|
||||
getattr(child.Placement.Base, "z", 0.0),
|
||||
],
|
||||
},
|
||||
"model",
|
||||
len(hints),
|
||||
source_object=child,
|
||||
)
|
||||
if hint is not None:
|
||||
hints.append(hint)
|
||||
continue
|
||||
|
||||
if _is_child_group(child, TerminalObjects.TERMINAL_GROUP_KIND):
|
||||
continue
|
||||
if _is_child_group(child, TerminalObjects.WIRE_GROUP_KIND):
|
||||
continue
|
||||
|
||||
if hasattr(child, "Group"):
|
||||
hints.extend(collect_terminal_hints(child))
|
||||
|
||||
hints.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
|
||||
return hints
|
||||
|
||||
|
||||
def collect_model_bounding_box(container):
|
||||
boxes = []
|
||||
|
||||
def walk(obj):
|
||||
if obj is None:
|
||||
return
|
||||
|
||||
if TerminalObjects.is_terminal_hint_object(obj):
|
||||
return
|
||||
|
||||
if _is_child_group(obj, TerminalObjects.TERMINAL_GROUP_KIND):
|
||||
return
|
||||
if _is_child_group(obj, TerminalObjects.WIRE_GROUP_KIND):
|
||||
return
|
||||
|
||||
shape = getattr(obj, "Shape", None)
|
||||
if shape is not None:
|
||||
try:
|
||||
if not shape.isNull():
|
||||
boxes.append(shape.BoundBox)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for child in list(getattr(obj, "Group", []) or []):
|
||||
walk(child)
|
||||
|
||||
walk(container)
|
||||
|
||||
if not boxes:
|
||||
return None
|
||||
|
||||
x_min = min(box.XMin for box in boxes)
|
||||
y_min = min(box.YMin for box in boxes)
|
||||
z_min = min(box.ZMin for box in boxes)
|
||||
x_max = max(box.XMax for box in boxes)
|
||||
y_max = max(box.YMax for box in boxes)
|
||||
z_max = max(box.ZMax for box in boxes)
|
||||
|
||||
return {
|
||||
"x_min": x_min,
|
||||
"y_min": y_min,
|
||||
"z_min": z_min,
|
||||
"x_max": x_max,
|
||||
"y_max": y_max,
|
||||
"z_max": z_max,
|
||||
"x_span": x_max - x_min,
|
||||
"y_span": y_max - y_min,
|
||||
"z_span": z_max - z_min,
|
||||
"x_center": (x_min + x_max) * 0.5,
|
||||
"y_center": (y_min + y_max) * 0.5,
|
||||
"z_center": (z_min + z_max) * 0.5,
|
||||
}
|
||||
|
||||
|
||||
def _fallback_slot_base(bbox, index, count):
|
||||
if bbox is None:
|
||||
step = 8.0
|
||||
return App.Vector(20.0, float(index) * step, 0.0)
|
||||
|
||||
span_x = max(bbox["x_span"], 1.0)
|
||||
span_y = max(bbox["y_span"], 1.0)
|
||||
span_z = max(bbox["z_span"], 1.0)
|
||||
offset = max(8.0, max(span_x, span_y, span_z) * 0.15)
|
||||
|
||||
if count <= 1:
|
||||
y_pos = bbox["y_center"]
|
||||
z_pos = bbox["z_center"]
|
||||
else:
|
||||
if span_y >= span_z:
|
||||
step = span_y / float(count + 1)
|
||||
y_pos = bbox["y_min"] + step * float(index + 1)
|
||||
z_pos = bbox["z_center"]
|
||||
else:
|
||||
step = span_z / float(count + 1)
|
||||
y_pos = bbox["y_center"]
|
||||
z_pos = bbox["z_min"] + step * float(index + 1)
|
||||
|
||||
return App.Vector(bbox["x_max"] + offset, y_pos, z_pos)
|
||||
|
||||
|
||||
def build_fallback_terminal_slots(container, count):
|
||||
bbox = collect_model_bounding_box(container)
|
||||
slots = []
|
||||
for index in range(max(0, count)):
|
||||
slots.append(
|
||||
{
|
||||
"name": "SLOT_{0}".format(index + 1),
|
||||
"label": "SLOT_{0}".format(index + 1),
|
||||
"base": _fallback_slot_base(bbox, index, count),
|
||||
"source": "fallback",
|
||||
"source_object": None,
|
||||
}
|
||||
)
|
||||
return slots
|
||||
|
||||
|
||||
def load_sidecar_terminal_slots(model_path):
|
||||
for sidecar_path in _sidecar_candidates(model_path):
|
||||
if not sidecar_path.is_file():
|
||||
continue
|
||||
|
||||
payload = _load_json_file(sidecar_path)
|
||||
if payload is None:
|
||||
continue
|
||||
|
||||
entries = payload.get("terminal_slots")
|
||||
if entries is None:
|
||||
entries = payload.get("terminals")
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
|
||||
slots = []
|
||||
for index, item in enumerate(entries):
|
||||
slot = _slot_from_payload(item, "sidecar", index)
|
||||
if slot is not None:
|
||||
slots.append(slot)
|
||||
|
||||
if slots:
|
||||
slots.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
|
||||
return slots
|
||||
return []
|
||||
|
||||
|
||||
def resolve_terminal_slots(device_group, model_path, desired_count):
|
||||
desired_count = max(0, int(desired_count or 0))
|
||||
hints = collect_terminal_hints(device_group)
|
||||
sidecar_slots = load_sidecar_terminal_slots(model_path)
|
||||
|
||||
slots = []
|
||||
slots.extend(hints[:desired_count])
|
||||
|
||||
if len(slots) < desired_count and sidecar_slots:
|
||||
for slot in sidecar_slots:
|
||||
if len(slots) >= desired_count:
|
||||
break
|
||||
slots.append(slot)
|
||||
|
||||
if len(slots) < desired_count:
|
||||
fallback_slots = build_fallback_terminal_slots(device_group, desired_count)
|
||||
for slot in fallback_slots:
|
||||
if len(slots) >= desired_count:
|
||||
break
|
||||
slots.append(slot)
|
||||
|
||||
return slots[:desired_count]
|
||||
@ -0,0 +1,325 @@
|
||||
# FreeCADExchange terminal import helpers.
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
|
||||
import DeviceImport
|
||||
import TerminalObjects as TerminalObjects
|
||||
import TemplateSemantics
|
||||
|
||||
|
||||
class TerminalImportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _append_debug_log(message):
|
||||
try:
|
||||
DeviceImport._append_debug_log(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_terminal_entry(item, index):
|
||||
if not isinstance(item, dict):
|
||||
raise TerminalImportError(
|
||||
"Terminal entry #{0} must be an object.".format(index)
|
||||
)
|
||||
|
||||
terminal_uuid = (item.get("terminal_uuid") or "").strip()
|
||||
if not terminal_uuid:
|
||||
raise TerminalImportError(
|
||||
"Terminal entry #{0} is missing terminal_uuid.".format(index)
|
||||
)
|
||||
|
||||
instance_id = (item.get("instance_id") or "").strip()
|
||||
element_uuid = (item.get("element_uuid") or "").strip()
|
||||
|
||||
return {
|
||||
"terminal_uuid": terminal_uuid,
|
||||
"instance_id": instance_id,
|
||||
"element_uuid": element_uuid,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_visible(obj):
|
||||
try:
|
||||
if getattr(obj, "ViewObject", None) is not None:
|
||||
obj.ViewObject.Visibility = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _hide_object(obj):
|
||||
try:
|
||||
if getattr(obj, "ViewObject", None) is not None:
|
||||
obj.ViewObject.Visibility = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _terminal_existing_index(container):
|
||||
index = OrderedDict()
|
||||
for obj in TerminalObjects.collect_terminal_objects(container):
|
||||
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
|
||||
if terminal_uuid and terminal_uuid not in index:
|
||||
index[terminal_uuid] = obj
|
||||
return index
|
||||
|
||||
|
||||
def _device_key(device_group):
|
||||
return getattr(device_group, "QetInstanceId", "").strip() or getattr(
|
||||
device_group, "QetElementUuid", ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _locate_device_group(doc, entry):
|
||||
instance_id = entry["instance_id"]
|
||||
element_uuid = entry["element_uuid"]
|
||||
|
||||
device_group = None
|
||||
if instance_id:
|
||||
device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id)
|
||||
if device_group is None and element_uuid:
|
||||
device_group = DeviceImport._find_device_group(doc, element_uuid)
|
||||
|
||||
return device_group
|
||||
|
||||
|
||||
def _terminal_container_for_device(doc, device_group, project_uuid):
|
||||
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
return TerminalObjects.ensure_terminal_group(
|
||||
doc,
|
||||
device_group,
|
||||
project_uuid=project_uuid,
|
||||
instance_id=device_instance_id,
|
||||
)
|
||||
|
||||
|
||||
def _is_device_group(obj):
|
||||
try:
|
||||
return (
|
||||
obj is not None
|
||||
and getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX)
|
||||
and "QetElementUuid" in getattr(obj, "PropertiesList", [])
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _terminal_slot_label(slot, terminal_uuid):
|
||||
label = (slot.get("label") or "").strip()
|
||||
if label:
|
||||
return label
|
||||
return terminal_uuid
|
||||
|
||||
|
||||
def _slot_base(slot):
|
||||
base = slot.get("base")
|
||||
if isinstance(base, App.Vector):
|
||||
return base
|
||||
return App.Vector(0, 0, 0)
|
||||
|
||||
|
||||
def _slot_placement(slot):
|
||||
base = _slot_base(slot)
|
||||
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 _create_terminal_object(doc, terminal_uuid, slot, terminal_group, project_uuid, element_uuid, instance_id):
|
||||
name_hint = "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid))
|
||||
terminal_obj = TerminalObjects.create_lcs_object(
|
||||
doc,
|
||||
name_hint,
|
||||
placement=_slot_placement(slot),
|
||||
label=_terminal_slot_label(slot, terminal_uuid),
|
||||
)
|
||||
terminal_group.addObject(terminal_obj)
|
||||
TerminalObjects.set_terminal_semantics(
|
||||
terminal_obj,
|
||||
project_uuid,
|
||||
element_uuid,
|
||||
terminal_uuid,
|
||||
instance_id,
|
||||
label=_terminal_slot_label(slot, terminal_uuid),
|
||||
slot_name=slot.get("name", ""),
|
||||
)
|
||||
_ensure_visible(terminal_obj)
|
||||
return terminal_obj
|
||||
|
||||
|
||||
def import_terminals_from_payload(payload, scene_path=""):
|
||||
_append_debug_log("TerminalImport.import_terminals_from_payload entered")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise TerminalImportError("Exchange payload must be an object.")
|
||||
|
||||
project_uuid = (payload.get("project_uuid") or "").strip()
|
||||
if not project_uuid:
|
||||
raise TerminalImportError("Field 'project_uuid' is required for terminal import.")
|
||||
|
||||
doc = DeviceImport._ensure_document(scene_path)
|
||||
root_group = DeviceImport._ensure_root_group(doc, project_uuid)
|
||||
_ = root_group
|
||||
|
||||
terminal_entries = payload.get("terminals", [])
|
||||
if not isinstance(terminal_entries, list):
|
||||
raise TerminalImportError("Field 'terminals' must be a list.")
|
||||
|
||||
report = {
|
||||
"document_name": doc.Name,
|
||||
"scene_path": scene_path or "",
|
||||
"project_uuid": project_uuid,
|
||||
"total_terminals": 0,
|
||||
"imported_terminals": 0,
|
||||
"updated_terminals": 0,
|
||||
"removed_terminals": 0,
|
||||
"reused_template_hints": 0,
|
||||
"skipped_missing_device": 0,
|
||||
"skipped_invalid_entry": 0,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
grouped = OrderedDict()
|
||||
for index, item in enumerate(terminal_entries):
|
||||
report["total_terminals"] += 1
|
||||
try:
|
||||
entry = _normalize_terminal_entry(item, index)
|
||||
except TerminalImportError as exc:
|
||||
report["skipped_invalid_entry"] += 1
|
||||
report["warnings"].append(str(exc))
|
||||
continue
|
||||
|
||||
device_group = _locate_device_group(doc, entry)
|
||||
if device_group is None:
|
||||
report["skipped_missing_device"] += 1
|
||||
report["warnings"].append(
|
||||
"Terminal {0} could not find its parent device.".format(
|
||||
entry["terminal_uuid"]
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
key = device_group.Name
|
||||
if key not in grouped:
|
||||
grouped[key] = {"device_group": device_group, "entries": []}
|
||||
grouped[key]["entries"].append(entry)
|
||||
|
||||
device_candidates = []
|
||||
if root_group is not None:
|
||||
device_candidates.extend(list(getattr(root_group, "Group", []) or []))
|
||||
if not device_candidates:
|
||||
device_candidates.extend(doc.Objects)
|
||||
|
||||
for device_group in device_candidates:
|
||||
if not _is_device_group(device_group):
|
||||
continue
|
||||
|
||||
item = grouped.get(device_group.Name, {"entries": []})
|
||||
entries = item["entries"]
|
||||
device_element_uuid = getattr(device_group, "QetElementUuid", "").strip()
|
||||
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
|
||||
resolved_model_path = getattr(device_group, "QetResolvedModelPath", "").strip()
|
||||
|
||||
terminal_group = _terminal_container_for_device(doc, device_group, project_uuid)
|
||||
existing_by_uuid = _terminal_existing_index(terminal_group)
|
||||
used_uuids = set()
|
||||
slots = TemplateSemantics.resolve_terminal_slots(
|
||||
device_group,
|
||||
resolved_model_path,
|
||||
len(entries),
|
||||
)
|
||||
|
||||
for index, entry in enumerate(entries):
|
||||
terminal_uuid = entry["terminal_uuid"]
|
||||
payload_instance_id = entry["instance_id"]
|
||||
if payload_instance_id and payload_instance_id != device_instance_id:
|
||||
report["warnings"].append(
|
||||
"Terminal {0} references instance_id {1} but device {2} uses {3}. The device value was kept."
|
||||
.format(terminal_uuid, payload_instance_id, device_element_uuid, device_instance_id)
|
||||
)
|
||||
|
||||
slot = slots[index] if index < len(slots) else {
|
||||
"name": "SLOT_{0}".format(index + 1),
|
||||
"label": terminal_uuid,
|
||||
"base": App.Vector(0, 0, 0),
|
||||
"source": "fallback",
|
||||
"source_object": None,
|
||||
}
|
||||
|
||||
terminal_obj = existing_by_uuid.get(terminal_uuid)
|
||||
if terminal_obj is None:
|
||||
terminal_obj = _create_terminal_object(
|
||||
doc,
|
||||
terminal_uuid,
|
||||
slot,
|
||||
terminal_group,
|
||||
project_uuid,
|
||||
device_element_uuid,
|
||||
device_instance_id,
|
||||
)
|
||||
report["imported_terminals"] += 1
|
||||
else:
|
||||
TerminalObjects.set_terminal_semantics(
|
||||
terminal_obj,
|
||||
project_uuid,
|
||||
device_element_uuid,
|
||||
terminal_uuid,
|
||||
device_instance_id,
|
||||
label=_terminal_slot_label(slot, terminal_uuid),
|
||||
slot_name=slot.get("name", ""),
|
||||
)
|
||||
try:
|
||||
terminal_obj.Placement = _slot_placement(slot)
|
||||
except Exception:
|
||||
pass
|
||||
_ensure_visible(terminal_obj)
|
||||
report["updated_terminals"] += 1
|
||||
|
||||
if terminal_obj not in getattr(terminal_group, "Group", []):
|
||||
terminal_group.addObject(terminal_obj)
|
||||
|
||||
used_uuids.add(terminal_uuid)
|
||||
source_obj = slot.get("source_object")
|
||||
if source_obj is not None:
|
||||
_hide_object(source_obj)
|
||||
report["reused_template_hints"] += 1
|
||||
|
||||
for terminal_uuid, terminal_obj in list(existing_by_uuid.items()):
|
||||
if terminal_uuid in used_uuids:
|
||||
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()
|
||||
try:
|
||||
Gui.SendMsgToActiveView("ViewFit")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_append_debug_log(
|
||||
"TerminalImport finished: imported={0}, updated={1}, removed={2}".format(
|
||||
report["imported_terminals"],
|
||||
report["updated_terminals"],
|
||||
report["removed_terminals"],
|
||||
)
|
||||
)
|
||||
return report
|
||||
@ -0,0 +1,386 @@
|
||||
# FreeCADExchange terminal and object helpers.
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
|
||||
ROOT_GROUP_NAME = "QETExchangeDevices"
|
||||
ROOT_GROUP_LABEL = "QET Exchange Devices"
|
||||
DEVICE_GROUP_PREFIX = "QETDevice_"
|
||||
TERMINAL_GROUP_PREFIX = "QETTerminals_"
|
||||
WIRE_GROUP_PREFIX = "QETWires_"
|
||||
TERMINAL_GROUP_KIND = "Terminals"
|
||||
WIRE_GROUP_KIND = "Wires"
|
||||
TERMINAL_ROLE = "Terminal"
|
||||
|
||||
|
||||
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 native_path(value):
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return os.path.normpath(os.path.expandvars(os.path.expanduser(text)))
|
||||
|
||||
|
||||
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 _unique_object_name(doc, base_name):
|
||||
name = safe_token(base_name) or "QETObject"
|
||||
if doc.getObject(name) is None:
|
||||
return name
|
||||
|
||||
suffix = 1
|
||||
while doc.getObject("{0}_{1}".format(name, suffix)) is not None:
|
||||
suffix += 1
|
||||
return "{0}_{1}".format(name, suffix)
|
||||
|
||||
|
||||
def _group_kind(obj):
|
||||
return (getattr(obj, "QetGroupKind", "") or "").strip()
|
||||
|
||||
|
||||
def _is_group_candidate(obj):
|
||||
try:
|
||||
return bool(obj and obj.isDerivedFrom("App::DocumentObjectGroup"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_root_group(doc, project_uuid=""):
|
||||
root = doc.getObject(ROOT_GROUP_NAME)
|
||||
if root is None:
|
||||
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
|
||||
root.Label = ROOT_GROUP_LABEL
|
||||
project_uuid = (project_uuid or "").strip() or getattr(root, "QetProjectUuid", "").strip()
|
||||
ensure_string_property(
|
||||
root,
|
||||
"QetProjectUuid",
|
||||
"QET Exchange",
|
||||
"Project UUID for the exchange document",
|
||||
project_uuid,
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def ensure_named_child_group(
|
||||
doc,
|
||||
parent_group,
|
||||
name_prefix,
|
||||
label,
|
||||
group_kind,
|
||||
project_uuid="",
|
||||
element_uuid="",
|
||||
instance_id="",
|
||||
):
|
||||
target_element_uuid = (element_uuid or "").strip()
|
||||
preferred_name = name_prefix + safe_token(target_element_uuid)
|
||||
group = doc.getObject(preferred_name)
|
||||
|
||||
if group is None and _is_group_candidate(parent_group):
|
||||
for candidate in getattr(parent_group, "Group", []) or []:
|
||||
if _group_kind(candidate) != group_kind:
|
||||
continue
|
||||
if target_element_uuid and getattr(candidate, "QetElementUuid", "").strip() != target_element_uuid:
|
||||
continue
|
||||
group = candidate
|
||||
break
|
||||
|
||||
if group is None:
|
||||
group = doc.addObject(
|
||||
"App::DocumentObjectGroup",
|
||||
_unique_object_name(doc, preferred_name),
|
||||
)
|
||||
|
||||
if parent_group is not None and 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 for the exchange document",
|
||||
project_uuid,
|
||||
)
|
||||
ensure_string_property(
|
||||
group,
|
||||
"QetElementUuid",
|
||||
"QET Exchange",
|
||||
"Parent element UUID for the exchange group",
|
||||
element_uuid,
|
||||
)
|
||||
ensure_string_property(
|
||||
group,
|
||||
"QetInstanceId",
|
||||
"QET Exchange",
|
||||
"Parent instance UUID for the exchange group",
|
||||
instance_id,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
def ensure_terminal_group(doc, device_group, project_uuid="", instance_id=""):
|
||||
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
|
||||
label = "QET Terminals"
|
||||
return ensure_named_child_group(
|
||||
doc,
|
||||
device_group,
|
||||
TERMINAL_GROUP_PREFIX,
|
||||
label,
|
||||
TERMINAL_GROUP_KIND,
|
||||
project_uuid=project_uuid,
|
||||
element_uuid=element_uuid,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
|
||||
def ensure_wire_group(doc, device_group, project_uuid="", instance_id=""):
|
||||
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
|
||||
label = "QET Wires"
|
||||
return ensure_named_child_group(
|
||||
doc,
|
||||
device_group,
|
||||
WIRE_GROUP_PREFIX,
|
||||
label,
|
||||
WIRE_GROUP_KIND,
|
||||
project_uuid=project_uuid,
|
||||
element_uuid=element_uuid,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
|
||||
def find_child_group_by_kind(parent_group, group_kind):
|
||||
if parent_group is None:
|
||||
return None
|
||||
for candidate in getattr(parent_group, "Group", []) or []:
|
||||
if _group_kind(candidate) == group_kind:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
return obj
|
||||
|
||||
for candidate in doc.Objects:
|
||||
if DEVICE_GROUP_PREFIX not in getattr(candidate, "Name", ""):
|
||||
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 "QetInstanceId" in getattr(candidate, "PropertiesList", []):
|
||||
if getattr(candidate, "QetInstanceId", "").strip() == target_instance_id:
|
||||
if getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def is_lcs_like(obj):
|
||||
if obj is None:
|
||||
return False
|
||||
try:
|
||||
if obj.isDerivedFrom("App::LocalCoordinateSystem"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return getattr(obj, "TypeId", "") in {
|
||||
"Part::LocalCoordinateSystem",
|
||||
"PartDesign::CoordinateSystem",
|
||||
}
|
||||
|
||||
|
||||
def is_terminal_hint_object(obj):
|
||||
if not is_lcs_like(obj):
|
||||
return False
|
||||
role = getattr(obj, "Role", "")
|
||||
return isinstance(role, str) and role.strip() == TERMINAL_ROLE
|
||||
|
||||
|
||||
def is_terminal_object(obj):
|
||||
if not is_terminal_hint_object(obj):
|
||||
return False
|
||||
|
||||
if "QetTerminalUuid" not in getattr(obj, "PropertiesList", []):
|
||||
return False
|
||||
if "CanWire" not in getattr(obj, "PropertiesList", []):
|
||||
return False
|
||||
return bool(getattr(obj, "CanWire", False))
|
||||
|
||||
|
||||
def terminal_origin(obj):
|
||||
try:
|
||||
placement = getattr(obj, "Placement", None)
|
||||
if placement is not None:
|
||||
return App.Vector(placement.Base.x, placement.Base.y, placement.Base.z)
|
||||
except Exception:
|
||||
pass
|
||||
return App.Vector(0, 0, 0)
|
||||
|
||||
|
||||
def create_lcs_object(doc, name_hint, placement=None, label=None):
|
||||
base_name = safe_token(name_hint) or "QETTerminal"
|
||||
object_name = _unique_object_name(doc, base_name)
|
||||
lcs = None
|
||||
for type_name in ("Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"):
|
||||
try:
|
||||
lcs = doc.addObject(type_name, object_name)
|
||||
break
|
||||
except Exception:
|
||||
lcs = None
|
||||
if lcs is None:
|
||||
raise RuntimeError("FreeCAD does not provide a usable LCS object type.")
|
||||
|
||||
if label:
|
||||
lcs.Label = label
|
||||
elif name_hint:
|
||||
lcs.Label = name_hint
|
||||
|
||||
if placement is not None:
|
||||
try:
|
||||
lcs.Placement = placement
|
||||
except Exception:
|
||||
pass
|
||||
return lcs
|
||||
|
||||
|
||||
def set_terminal_semantics(
|
||||
obj,
|
||||
project_uuid,
|
||||
element_uuid,
|
||||
terminal_uuid,
|
||||
instance_id,
|
||||
label="",
|
||||
slot_name="",
|
||||
):
|
||||
ensure_string_property(
|
||||
obj,
|
||||
"QetProjectUuid",
|
||||
"QET Exchange",
|
||||
"Project UUID for this terminal",
|
||||
project_uuid,
|
||||
)
|
||||
ensure_string_property(
|
||||
obj,
|
||||
"QetElementUuid",
|
||||
"QET Exchange",
|
||||
"Parent element UUID for this terminal",
|
||||
element_uuid,
|
||||
)
|
||||
ensure_string_property(
|
||||
obj,
|
||||
"QetTerminalUuid",
|
||||
"QET Exchange",
|
||||
"Terminal UUID from QET",
|
||||
terminal_uuid,
|
||||
)
|
||||
ensure_string_property(
|
||||
obj,
|
||||
"QetInstanceId",
|
||||
"QET Exchange",
|
||||
"Parent instance UUID for this terminal",
|
||||
instance_id,
|
||||
)
|
||||
ensure_string_property(
|
||||
obj,
|
||||
"Role",
|
||||
"QET Exchange",
|
||||
"Terminal role marker",
|
||||
TERMINAL_ROLE,
|
||||
)
|
||||
ensure_bool_property(
|
||||
obj,
|
||||
"CanWire",
|
||||
"QET Exchange",
|
||||
"Whether the terminal can be used for wiring",
|
||||
True,
|
||||
)
|
||||
if slot_name:
|
||||
ensure_string_property(
|
||||
obj,
|
||||
"QetTemplateSlotName",
|
||||
"QET Exchange",
|
||||
"Template slot name",
|
||||
slot_name,
|
||||
)
|
||||
|
||||
terminal_label = (label or "").strip() or terminal_uuid or "QET Terminal"
|
||||
obj.Label = terminal_label
|
||||
return obj
|
||||
|
||||
|
||||
def remove_object_tree(doc, obj):
|
||||
if obj is None:
|
||||
return
|
||||
|
||||
children = list(getattr(obj, "Group", []) or [])
|
||||
for child in children:
|
||||
remove_object_tree(doc, child)
|
||||
|
||||
if doc.getObject(obj.Name) is not None:
|
||||
doc.removeObject(obj.Name)
|
||||
|
||||
|
||||
def collect_terminal_objects(container):
|
||||
result = []
|
||||
if container is None:
|
||||
return result
|
||||
|
||||
children = list(getattr(container, "Group", []) or [])
|
||||
for child in children:
|
||||
if is_terminal_object(child):
|
||||
result.append(child)
|
||||
continue
|
||||
if _is_group_candidate(child):
|
||||
result.extend(collect_terminal_objects(child))
|
||||
return result
|
||||
@ -0,0 +1,192 @@
|
||||
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
|
||||
self.Rotation = 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
|
||||
|
||||
fake_part = types.ModuleType("Part")
|
||||
fake_part.makePolygon = lambda points: tuple(points)
|
||||
sys.modules["Part"] = fake_part
|
||||
|
||||
|
||||
class FakeViewObject:
|
||||
def __init__(self):
|
||||
self.Visibility = True
|
||||
self.LineWidth = None
|
||||
self.LineColor = None
|
||||
|
||||
|
||||
class FakeObject:
|
||||
def __init__(self, name, type_id):
|
||||
self.Name = name
|
||||
self.Label = name
|
||||
self.TypeId = type_id
|
||||
self.PropertiesList = []
|
||||
self.Group = []
|
||||
self.ViewObject = FakeViewObject()
|
||||
self.Shape = None
|
||||
|
||||
def isDerivedFrom(self, type_name):
|
||||
if self.TypeId == type_name:
|
||||
return True
|
||||
if type_name == "App::DocumentObjectGroup":
|
||||
return self.TypeId == "App::DocumentObjectGroup"
|
||||
if type_name == "App::LocalCoordinateSystem":
|
||||
return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"}
|
||||
return False
|
||||
|
||||
def addProperty(self, prop_type, prop_name, group_name, description):
|
||||
if prop_name not in self.PropertiesList:
|
||||
self.PropertiesList.append(prop_name)
|
||||
|
||||
def addObject(self, child):
|
||||
if child not in self.Group:
|
||||
self.Group.append(child)
|
||||
|
||||
|
||||
class FakeDocument:
|
||||
def __init__(self):
|
||||
self.Objects = []
|
||||
self.Name = "FakeDoc"
|
||||
|
||||
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",
|
||||
"DeviceImport",
|
||||
"ManualWiring",
|
||||
]:
|
||||
sys.modules.pop(name, None)
|
||||
import DeviceImport
|
||||
import ManualWiring
|
||||
import TerminalObjects
|
||||
|
||||
return DeviceImport, ManualWiring, TerminalObjects
|
||||
|
||||
|
||||
class ManualWiringGroupTest(unittest.TestCase):
|
||||
def test_manual_wire_is_added_to_device_wire_group(self):
|
||||
_install_fake_freecad()
|
||||
device_import, manual_wiring, terminal_objects = _reload_modules()
|
||||
|
||||
doc = FakeDocument()
|
||||
root = terminal_objects.ensure_root_group(doc, "project-1")
|
||||
device_group = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
|
||||
root.addObject(device_group)
|
||||
terminal_objects.ensure_string_property(
|
||||
device_group,
|
||||
"QetElementUuid",
|
||||
"QET Exchange",
|
||||
"Element UUID",
|
||||
"device-a",
|
||||
)
|
||||
terminal_objects.ensure_string_property(
|
||||
device_group,
|
||||
"QetInstanceId",
|
||||
"QET Exchange",
|
||||
"Instance ID",
|
||||
"instance-a",
|
||||
)
|
||||
terminal_objects.ensure_string_property(
|
||||
device_group,
|
||||
"QetProjectUuid",
|
||||
"QET Exchange",
|
||||
"Project UUID",
|
||||
"project-1",
|
||||
)
|
||||
|
||||
start_terminal = FakeObject("TerminalStart", "Part::LocalCoordinateSystem")
|
||||
terminal_objects.set_terminal_semantics(
|
||||
start_terminal,
|
||||
"project-1",
|
||||
"device-a",
|
||||
"terminal-start",
|
||||
"instance-a",
|
||||
label="Start",
|
||||
)
|
||||
end_terminal = FakeObject("TerminalEnd", "Part::LocalCoordinateSystem")
|
||||
terminal_objects.set_terminal_semantics(
|
||||
end_terminal,
|
||||
"project-1",
|
||||
"device-a",
|
||||
"terminal-end",
|
||||
"instance-a",
|
||||
label="End",
|
||||
)
|
||||
|
||||
wire = manual_wiring.create_manual_wire(doc, start_terminal, end_terminal)
|
||||
|
||||
wire_group = terminal_objects.find_child_group_by_kind(
|
||||
device_group,
|
||||
terminal_objects.WIRE_GROUP_KIND,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(wire_group)
|
||||
self.assertIn(wire, wire_group.Group)
|
||||
self.assertNotIn(wire, root.Group)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,112 @@
|
||||
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_modules():
|
||||
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_freecadgui = types.ModuleType("FreeCADGui")
|
||||
fake_freecadgui.addCommand = lambda *args, **kwargs: None
|
||||
fake_freecadgui.Control = types.SimpleNamespace(
|
||||
activeDialog=lambda: False,
|
||||
showDialog=lambda panel: panel,
|
||||
closeDialog=lambda: None,
|
||||
)
|
||||
sys.modules["FreeCADGui"] = fake_freecadgui
|
||||
|
||||
fake_template_authoring = types.ModuleType("TemplateAuthoring")
|
||||
fake_template_authoring.validate_template_terminals = lambda doc: {
|
||||
"terminals": [],
|
||||
"total_terminals": 0,
|
||||
"valid_terminals": 0,
|
||||
"warnings": [],
|
||||
}
|
||||
fake_template_authoring._selection_position = lambda: None
|
||||
fake_template_authoring.create_template_terminal = lambda *args, **kwargs: None
|
||||
fake_template_authoring.save_template_as_fcstd = lambda *args, **kwargs: {}
|
||||
sys.modules["TemplateAuthoring"] = fake_template_authoring
|
||||
|
||||
|
||||
def _reload_panel_module():
|
||||
sys.modules.pop("TemplateAuthoringPanel", None)
|
||||
return importlib.import_module("TemplateAuthoringPanel")
|
||||
|
||||
|
||||
class TemplateAuthoringPanelTest(unittest.TestCase):
|
||||
def test_register_commands_ignores_menu_install_runtime_errors(self):
|
||||
_install_fake_modules()
|
||||
panel_module = _reload_panel_module()
|
||||
panel_module._COMMANDS_REGISTERED = False
|
||||
|
||||
def raise_deleted_menu_error():
|
||||
raise RuntimeError("Internal C++ object already deleted")
|
||||
|
||||
panel_module.install_menu_action = raise_deleted_menu_error
|
||||
panel_module.install_toolbar_action = raise_deleted_menu_error
|
||||
|
||||
panel_module.register_commands()
|
||||
|
||||
def test_terminal_type_options_show_chinese_labels_with_stable_values(self):
|
||||
_install_fake_modules()
|
||||
panel_module = _reload_panel_module()
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
("通用", "generic"),
|
||||
("主回路", "primary"),
|
||||
("电源", "power"),
|
||||
("控制", "control"),
|
||||
],
|
||||
panel_module.TERMINAL_TYPE_OPTIONS,
|
||||
)
|
||||
|
||||
def test_next_slot_name_uses_next_terminal_number(self):
|
||||
_install_fake_modules()
|
||||
panel_module = _reload_panel_module()
|
||||
|
||||
self.assertEqual(
|
||||
"T3",
|
||||
panel_module.next_slot_name(
|
||||
{
|
||||
"terminals": [
|
||||
{"slot_name": "P1"},
|
||||
{"slot_name": "P2"},
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def test_terminal_list_text_marks_invalid_terminal(self):
|
||||
_install_fake_modules()
|
||||
panel_module = _reload_panel_module()
|
||||
|
||||
rows = panel_module.terminal_list_text(
|
||||
{
|
||||
"terminals": [
|
||||
{"name": "Terminal_P1", "slot_name": "P1", "can_wire": True},
|
||||
{"name": "Broken", "slot_name": "", "can_wire": False},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(["P1 - Terminal_P1", "(unnamed) - Broken [invalid]"], rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,209 @@
|
||||
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
|
||||
self.Rotation = rotation
|
||||
|
||||
fake_freecad = types.ModuleType("FreeCAD")
|
||||
fake_freecad.Vector = Vector
|
||||
fake_freecad.Rotation = Rotation
|
||||
fake_freecad.Placement = Placement
|
||||
fake_freecad.ActiveDocument = None
|
||||
fake_freecad.Console = types.SimpleNamespace(
|
||||
PrintMessage=lambda *args, **kwargs: None,
|
||||
PrintWarning=lambda *args, **kwargs: None,
|
||||
PrintError=lambda *args, **kwargs: None,
|
||||
)
|
||||
sys.modules["FreeCAD"] = fake_freecad
|
||||
|
||||
fake_freecadgui = types.ModuleType("FreeCADGui")
|
||||
fake_freecadgui.addCommand = lambda *args, **kwargs: None
|
||||
fake_freecadgui.Selection = types.SimpleNamespace(getSelectionEx=lambda: [])
|
||||
sys.modules["FreeCADGui"] = fake_freecadgui
|
||||
|
||||
|
||||
def _install_fake_freecad_without_gui_commands():
|
||||
_install_fake_freecad()
|
||||
sys.modules["FreeCADGui"] = types.ModuleType("FreeCADGui")
|
||||
|
||||
|
||||
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.ViewObject = FakeViewObject()
|
||||
self.Placement = None
|
||||
|
||||
def isDerivedFrom(self, type_name):
|
||||
if self.TypeId == type_name:
|
||||
return True
|
||||
if type_name == "App::DocumentObjectGroup":
|
||||
return self.TypeId == "App::DocumentObjectGroup"
|
||||
if type_name == "App::LocalCoordinateSystem":
|
||||
return self.TypeId in {
|
||||
"Part::LocalCoordinateSystem",
|
||||
"PartDesign::CoordinateSystem",
|
||||
}
|
||||
return False
|
||||
|
||||
def addProperty(self, prop_type, prop_name, group_name, description):
|
||||
if prop_name not in self.PropertiesList:
|
||||
self.PropertiesList.append(prop_name)
|
||||
|
||||
def addObject(self, child):
|
||||
if child not in self.Group:
|
||||
self.Group.append(child)
|
||||
|
||||
|
||||
class FakeDocument:
|
||||
def __init__(self):
|
||||
self.Name = "TemplateDoc"
|
||||
self.Objects = []
|
||||
self.recomputed = False
|
||||
self.saved_path = ""
|
||||
|
||||
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 saveAs(self, path):
|
||||
self.saved_path = path
|
||||
|
||||
|
||||
def _reload_modules():
|
||||
for name in ["TerminalObjects", "TemplateAuthoring"]:
|
||||
sys.modules.pop(name, None)
|
||||
return importlib.import_module("TemplateAuthoring")
|
||||
|
||||
|
||||
class TemplateAuthoringTest(unittest.TestCase):
|
||||
def test_template_authoring_command_titles_are_chinese(self):
|
||||
_install_fake_freecad()
|
||||
template_authoring = _reload_modules()
|
||||
|
||||
self.assertEqual(
|
||||
"添加模板端子",
|
||||
template_authoring.CommandAddTemplateTerminal().GetResources()["MenuText"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"校验模板端子",
|
||||
template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"保存模板为 FCStd",
|
||||
template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"],
|
||||
)
|
||||
|
||||
def test_import_skips_command_registration_when_gui_has_no_add_command(self):
|
||||
_install_fake_freecad_without_gui_commands()
|
||||
|
||||
template_authoring = _reload_modules()
|
||||
|
||||
self.assertTrue(hasattr(template_authoring, "save_template_as_fcstd"))
|
||||
|
||||
def test_create_template_terminal_writes_lcs_semantics(self):
|
||||
_install_fake_freecad()
|
||||
template_authoring = _reload_modules()
|
||||
app = sys.modules["FreeCAD"]
|
||||
doc = FakeDocument()
|
||||
|
||||
terminal = template_authoring.create_template_terminal(
|
||||
doc,
|
||||
"P1",
|
||||
app.Vector(10, 20, 30),
|
||||
terminal_type="primary",
|
||||
)
|
||||
|
||||
self.assertEqual("Terminal_P1", terminal.Name)
|
||||
self.assertEqual("P1", terminal.Label)
|
||||
self.assertEqual("Terminal", terminal.Role)
|
||||
self.assertTrue(terminal.CanWire)
|
||||
self.assertEqual("P1", terminal.QetTemplateSlotName)
|
||||
self.assertEqual("P1", terminal.QetTerminalLabel)
|
||||
self.assertEqual("primary", terminal.QetTerminalType)
|
||||
self.assertEqual(10.0, terminal.Placement.Base.x)
|
||||
self.assertEqual(20.0, terminal.Placement.Base.y)
|
||||
self.assertEqual(30.0, terminal.Placement.Base.z)
|
||||
self.assertTrue(doc.recomputed)
|
||||
|
||||
def test_validate_template_terminals_reports_missing_slot_name(self):
|
||||
_install_fake_freecad()
|
||||
template_authoring = _reload_modules()
|
||||
doc = FakeDocument()
|
||||
terminal = doc.addObject("Part::LocalCoordinateSystem", "BrokenTerminal")
|
||||
terminal.addProperty("App::PropertyString", "Role", "QET Template", "role")
|
||||
terminal.Role = "Terminal"
|
||||
terminal.addProperty(
|
||||
"App::PropertyBool",
|
||||
"CanWire",
|
||||
"QET Template",
|
||||
"can wire",
|
||||
)
|
||||
terminal.CanWire = True
|
||||
|
||||
report = template_authoring.validate_template_terminals(doc)
|
||||
|
||||
self.assertEqual(1, report["total_terminals"])
|
||||
self.assertEqual(0, report["valid_terminals"])
|
||||
self.assertEqual(1, len(report["warnings"]))
|
||||
self.assertIn("QetTemplateSlotName", report["warnings"][0])
|
||||
|
||||
def test_save_template_as_fcstd_adds_extension_and_saves_valid_template(self):
|
||||
_install_fake_freecad()
|
||||
template_authoring = _reload_modules()
|
||||
app = sys.modules["FreeCAD"]
|
||||
doc = FakeDocument()
|
||||
template_authoring.create_template_terminal(doc, "P1", app.Vector(1, 2, 3))
|
||||
|
||||
report = template_authoring.save_template_as_fcstd(doc, "D:/tmp/current-transformer")
|
||||
|
||||
self.assertEqual("D:/tmp/current-transformer.FCStd", doc.saved_path)
|
||||
self.assertEqual("D:/tmp/current-transformer.FCStd", report["path"])
|
||||
self.assertEqual(1, report["valid_terminals"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,162 @@
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange"
|
||||
if str(MODULE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MODULE_DIR))
|
||||
|
||||
|
||||
def _install_fake_freecad():
|
||||
class Vector:
|
||||
def __init__(self, x=0.0, y=0.0, z=0.0):
|
||||
self.x = float(x)
|
||||
self.y = float(y)
|
||||
self.z = float(z)
|
||||
|
||||
class Rotation:
|
||||
def __init__(self, axis=None, angle=None):
|
||||
self.axis = axis
|
||||
self.angle = angle
|
||||
|
||||
class Placement:
|
||||
def __init__(self, base=None, rotation=None):
|
||||
self.Base = base
|
||||
self.Rotation = 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
|
||||
fake_freecad.newDocument = lambda name: types.SimpleNamespace(Name=name, Objects=[], getObject=lambda item: 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
|
||||
|
||||
|
||||
def _reload_exchange_modules():
|
||||
for name in [
|
||||
"TerminalObjects",
|
||||
"TemplateSemantics",
|
||||
"DeviceImport",
|
||||
"TerminalImport",
|
||||
]:
|
||||
sys.modules.pop(name, None)
|
||||
template_semantics = importlib.import_module("TemplateSemantics")
|
||||
terminal_import = importlib.import_module("TerminalImport")
|
||||
return template_semantics, terminal_import
|
||||
|
||||
|
||||
class TemplateSemanticsRotationTest(unittest.TestCase):
|
||||
def test_terminal_hint_keeps_source_object_rotation(self):
|
||||
_install_fake_freecad()
|
||||
template_semantics, _ = _reload_exchange_modules()
|
||||
|
||||
fake_lcs = types.SimpleNamespace(
|
||||
Name="TerminalA1",
|
||||
Label="Terminal A1",
|
||||
TypeId="Part::LocalCoordinateSystem",
|
||||
Role="Terminal",
|
||||
Placement=types.SimpleNamespace(
|
||||
Base=sys.modules["FreeCAD"].Vector(4, 5, 6),
|
||||
Rotation=sys.modules["FreeCAD"].Rotation(
|
||||
sys.modules["FreeCAD"].Vector(0, 1, 0),
|
||||
37.5,
|
||||
),
|
||||
),
|
||||
)
|
||||
container = types.SimpleNamespace(Group=[fake_lcs])
|
||||
|
||||
hints = template_semantics.collect_terminal_hints(container)
|
||||
|
||||
self.assertEqual(1, len(hints))
|
||||
self.assertIn("rotation", hints[0])
|
||||
self.assertEqual(37.5, hints[0]["rotation"]["angle"])
|
||||
self.assertEqual(0.0, hints[0]["rotation"]["axis"].x)
|
||||
self.assertEqual(1.0, hints[0]["rotation"]["axis"].y)
|
||||
self.assertEqual(0.0, hints[0]["rotation"]["axis"].z)
|
||||
|
||||
def test_sidecar_rotation_is_normalized_from_payload(self):
|
||||
_install_fake_freecad()
|
||||
template_semantics, _ = _reload_exchange_modules()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
model_path = Path(temp_dir) / "Relay.step"
|
||||
model_path.write_text("", encoding="utf-8")
|
||||
sidecar_path = Path(temp_dir) / "Relay.qet_template.json"
|
||||
sidecar_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"terminal_slots": [
|
||||
{
|
||||
"name": "A1",
|
||||
"label": "A1",
|
||||
"position": {"x": 10, "y": 20, "z": 30},
|
||||
"rotation": {
|
||||
"axis": {"x": 0, "y": 0, "z": 1},
|
||||
"angle": 90,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
slots = template_semantics.load_sidecar_terminal_slots(str(model_path))
|
||||
|
||||
self.assertEqual(1, len(slots))
|
||||
self.assertIn("rotation", slots[0])
|
||||
self.assertEqual(90.0, slots[0]["rotation"]["angle"])
|
||||
self.assertEqual(0.0, slots[0]["rotation"]["axis"].x)
|
||||
self.assertEqual(0.0, slots[0]["rotation"]["axis"].y)
|
||||
self.assertEqual(1.0, slots[0]["rotation"]["axis"].z)
|
||||
|
||||
|
||||
class TerminalPlacementTest(unittest.TestCase):
|
||||
def test_slot_placement_uses_rotation_metadata(self):
|
||||
_install_fake_freecad()
|
||||
_, terminal_import = _reload_exchange_modules()
|
||||
|
||||
slot = {
|
||||
"base": sys.modules["FreeCAD"].Vector(1, 2, 3),
|
||||
"rotation": {
|
||||
"axis": sys.modules["FreeCAD"].Vector(0, 0, 1),
|
||||
"angle": 45.0,
|
||||
},
|
||||
}
|
||||
|
||||
placement = terminal_import._slot_placement(slot)
|
||||
|
||||
self.assertEqual(1.0, placement.Base.x)
|
||||
self.assertEqual(2.0, placement.Base.y)
|
||||
self.assertEqual(3.0, placement.Base.z)
|
||||
self.assertIsNotNone(placement.Rotation)
|
||||
self.assertEqual(45.0, placement.Rotation.angle)
|
||||
self.assertEqual(0.0, placement.Rotation.axis.x)
|
||||
self.assertEqual(0.0, placement.Rotation.axis.y)
|
||||
self.assertEqual(1.0, placement.Rotation.axis.z)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue