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_uses_template_slot_name_before_object_name(self): _install_fake_freecad() template_semantics, _ = _reload_exchange_modules() fake_lcs = types.SimpleNamespace( Name="Terminal_P1", Label="端子一", TypeId="Part::LocalCoordinateSystem", Role="Terminal", QetTemplateSlotName="P1", Placement=types.SimpleNamespace( Base=sys.modules["FreeCAD"].Vector(1, 2, 3), Rotation=sys.modules["FreeCAD"].Rotation(), ), ) container = types.SimpleNamespace(Group=[fake_lcs]) hints = template_semantics.collect_terminal_hints(container) self.assertEqual("P1", hints[0]["name"]) 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 TerminalSlotResolutionPolicyTest(unittest.TestCase): def test_resolve_terminal_slots_returns_empty_when_model_has_no_template_slots(self): _install_fake_freecad() template_semantics, _ = _reload_exchange_modules() container = types.SimpleNamespace(Group=[]) slots = template_semantics.resolve_terminal_slots(container, "", 2) self.assertEqual([], slots) def test_resolve_terminal_slots_does_not_pad_template_hints_with_bbox_fallback(self): _install_fake_freecad() template_semantics, _ = _reload_exchange_modules() fake_lcs = types.SimpleNamespace( Name="Terminal_P1", Label="P1", TypeId="Part::LocalCoordinateSystem", Role="Terminal", QetTemplateSlotName="P1", Placement=types.SimpleNamespace( Base=sys.modules["FreeCAD"].Vector(10, 20, 30), Rotation=sys.modules["FreeCAD"].Rotation(), ), ) container = types.SimpleNamespace(Group=[fake_lcs]) slots = template_semantics.resolve_terminal_slots(container, "", 2) self.assertEqual(1, len(slots)) self.assertEqual("P1", slots[0]["name"]) self.assertNotEqual("fallback", slots[0].get("source")) 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()