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