"""Headless test: connectors follow moved features across ALL assemblies. Simulates the test-file scenario: a plate with a hole, mated hole-to-face in two different assemblies. The hole is moved (rebuilt geometry) and the body-update connector recalculation must: 1. re-locate the hole connector on every instance of the component (both the active and a non-active assembly), 2. re-solve each mated pair so the partner parts follow, 3. never snap a planar connector onto the cylindrical hole (type match), 4. mark a connector invalid when its feature disappears. 5. auto-follow a FAR move when the candidate is unambiguous (single feature of its class) — the demo case, 6. NOT auto-apply an ambiguous far candidate (another same-class feature is nearer) — that needs a manual pick, simulated here. 7. backfill legacy connectors' entity_type from their auto-generated name. """ import os import sys os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) import numpy as np from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2 from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut from PySide6.QtWidgets import QApplication from fluency.ui.main_window import MainWindow from fluency.models.data_model import Assembly, Body from fluency.geometry_occ.kernel import OCCGeometryObject app = QApplication.instance() or QApplication([]) w = MainWindow() comp = w._current_component def make_plate(hole_xy): box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape() ax = gp_Ax2(gp_Pnt(float(hole_xy[0]), float(hole_xy[1]), 0.0), gp_Dir(0, 0, 1)) cyl = BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape() return BRepAlgoAPI_Cut(box, cyl).Shape() body = Body(name="plate") comp.bodies[body.id] = body body.geometry = OCCGeometryObject(make_plate((10.0, 5.0))) partner = w._project.add_component() pbody = Body(name="partner") partner.bodies[pbody.id] = pbody pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()) def make_pair(asm): ac1 = asm.add_component_instance(comp.id, name="A") ac2 = asm.add_component_instance(partner.id, name="B") ac1.position = np.zeros(3) ac1.rotation = np.eye(3) ac2.position = np.array([0.0, 0.0, 15.0]) ac2.rotation = np.eye(3) ac1.geom_cache[body.id] = body.geometry c1 = ac1.add_connector( position=(10.0, 5.0, 2.5), normal=(0.0, 0.0, 1.0), x_dir=(1.0, 0.0, 0.0), source_obj_id=f"asm_{ac1.id}_{body.id}", name="Conn hole A", entity_type="cylindrical_face", ) c2 = ac2.add_connector( position=(15.0, 15.0, 0.0), normal=(0.0, 0.0, -1.0), x_dir=(1.0, 0.0, 0.0), source_obj_id=f"asm_{ac2.id}_{pbody.id}", name="Conn face B", entity_type="planar_face", ) c1.is_grounded = True c1.partner_ac_id = ac2.id c1.partner_connector_id = c2.id c2.partner_ac_id = ac1.id c2.partner_connector_id = c1.id aconn = asm.add_connection(ac1.id, ac2.id) aconn.first_connector_id = c1.id aconn.second_connector_id = c2.id return ac1, ac2, c1, c2, aconn asm1 = w._project.get_active_assembly() asm2 = w._project.add_assembly(Assembly(name="second")) pair1 = make_pair(asm1) pair2 = make_pair(asm2) assert w._project.active_assembly == asm1.id # asm2 is the NON-active one # ── Move the hole: rebuild the plate with the hole at (25, 12) ────────── new_geom = OCCGeometryObject(make_plate((25.0, 12.0))) body.geometry = new_geom for asm in (asm1, asm2): for ac in asm.components.values(): if ac.component_id == comp.id: ac.geom_cache[body.id] = new_geom # ── The body-update auto path ─────────────────────────────────────────── w._recalculate_connectors() for (ac1, ac2, c1, c2, aconn), label in ((pair1, "asm1"), (pair2, "asm2")): # The hole connector followed the hole on every instance. assert np.allclose(c1.position, (25.0, 12.0, 2.5), atol=1e-6), (label, c1.position) # The mated pair re-aligned: both world connectors coincide. w1 = ac1.position + ac1.rotation @ np.asarray(c1.position) w2 = ac2.position + ac2.rotation @ np.asarray(c2.position) assert np.allclose(w1, w2, atol=1e-6), (label, w1, w2) print(f"{label}: connector at {np.round(c1.position, 3)}, " f"partner moved to {np.round(ac2.position, 3)}") # ── The 'Upd' button path: move the hole again, re-run the handler ───── w._refresh_connection_list() w._connection_list.setCurrentRow(0) # active assembly = asm1 geom3 = OCCGeometryObject(make_plate((35.0, 20.0))) body.geometry = geom3 for ac in asm1.components.values(): if ac.component_id == comp.id: ac.geom_cache[body.id] = geom3 w._on_update_connection_from_list() ac1, ac2, c1, c2, aconn = pair1 assert np.allclose(c1.position, (35.0, 20.0, 2.5), atol=1e-6), c1.position w1 = ac1.position + ac1.rotation @ np.asarray(c1.position) w2 = ac2.position + ac2.rotation @ np.asarray(c2.position) assert np.allclose(w1, w2, atol=1e-6), (w1, w2) print("Upd button: connector at", np.round(c1.position, 3), "partner at", np.round(ac2.position, 3)) # ── Type matching: a planar connector must not snap onto the hole ─────── ac1 = pair1[0] c3 = ac1.add_connector( position=(30.0, 20.0, 5.0), normal=(0.0, 0.0, 1.0), x_dir=(1.0, 0.0, 0.0), source_obj_id=f"asm_{ac1.id}_{body.id}", name="Conn face", entity_type="planar_face", ) res = w._redetect_connector_on_geometry(c3, ac1, comp) assert res is not None, "planar connector candidate missing" assert not c3.is_invalid, "pure relocator must not mutate the connector" assert np.allclose(c3.position, (30.0, 20.0, 5.0), atol=1e-6), c3.position assert np.allclose(res[1], (30.0, 20.0, 5.0), atol=1e-6), res[1] print("planar connector stayed on the face:", np.round(res[1], 3)) # ── Feature removed: relocator finds nothing; auto path marks invalid ── plain = OCCGeometryObject(BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()) body.geometry = plain ac1.geom_cache[body.id] = plain res = w._redetect_connector_on_geometry(pair1[2], ac1, comp) assert res is None, "hole connector should find no candidate on a plain box" w._recalculate_connectors() assert pair1[2].is_invalid, "auto path must mark the connector invalid" print("removed feature -> connector marked invalid") # ── Far move with a decoy: ambiguous candidate is NOT auto-applied ───── # The real hole moved to (25, 12) — ~12.8mm from the stored (35, 20) — but # a SECOND hole now sits at (28, 16), only ~8mm away. The nearest # candidate is ambiguous (different feature), so the auto path must leave # the connector alone and queue it for a manual pick. def make_plate2(holes): box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape() for hx, hy in holes: ax = gp_Ax2(gp_Pnt(hx, hy, 0.0), gp_Dir(0, 0, 1)) box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape() return box # ── Stage 1: the user's demo — a SINGLE hole moved far away ───────────── # 12.8mm from the stored position, but the only cylindrical face on the # body → unambiguous → must be auto-applied (and the mate re-solved). geom4 = OCCGeometryObject(make_plate2([(25.0, 12.0)])) body.geometry = geom4 for asm in (asm1, asm2): for ac in asm.components.values(): if ac.component_id == comp.id: ac.geom_cache[body.id] = geom4 w._recalculate_connectors() assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \ "unique far candidate must be auto-applied (the demo case)" assert not pair1[2].is_invalid w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position) w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position) assert np.allclose(w1, w2, atol=1e-6), (w1, w2) print("single far hole: auto-followed to", np.round(pair1[2].position, 3)) # ── Stage 2: far move with a decoy — ambiguous, NOT auto-applied ─────── # The real hole now sits at (32, 20) — 10.6mm from the stored (25, 12) — # while a decoy hole at (21, 7) is only 6.4mm away. The nearest # candidate is likely a DIFFERENT feature, so the auto path must leave # the connector alone and queue it for a manual pick. geom5 = OCCGeometryObject(make_plate2([(32.0, 20.0), (21.0, 7.0)])) body.geometry = geom5 for asm in (asm1, asm2): for ac in asm.components.values(): if ac.component_id == comp.id: ac.geom_cache[body.id] = geom5 w._recalculate_connectors() assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \ "ambiguous far candidate must not be auto-applied" assert not pair1[2].is_invalid, "ambiguous candidate is not a missing feature" # Simulate the user clicking the REAL hole in the relocate pick flow: w._relocate_pending = [(asm1, ac1, pair1[2])] w._on_relocate_picked( (32.0, 20.0, 2.5), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0), "cylindrical_face", f"asm_{ac1.id}_{body.id}", ) assert np.allclose(pair1[2].position, (32.0, 20.0, 2.5), atol=1e-6), pair1[2].position assert not pair1[2].is_invalid, "manual pick must re-validate the connector" assert w._relocate_pending is None, "pending queue must drain after the pick" w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position) w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position) assert np.allclose(w1, w2, atol=1e-6), (w1, w2) print("far move: manual pick re-homed connector at", np.round(pair1[2].position, 3), "partner at", np.round(pair1[1].position, 3)) # ── Legacy backfill: empty entity_type recovered from the auto name ──── from fluency.models.data_model import Connector legacy = Connector( name="Conn cylindrical_face anchor", position=(32.0, 20.0, 2.5), source_obj_id=f"asm_{ac1.id}_{body.id}", ) assert legacy.entity_type == "cylindrical_face", legacy.entity_type res = w._redetect_connector_on_geometry(legacy, ac1, comp) assert res is not None and res[0] < 1e-3, res print("legacy name backfill: entity_type =", legacy.entity_type) print("CONNECTOR_RELOCATE_OK")