Files
fluencyCAD/.test_connector_relocate.py
T
bklronin 9abeb6266a - Assembly instaiated operations
- assembly forward proagation
2026-08-19 20:14:14 +02:00

239 lines
10 KiB
Python

"""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")