9abeb6266a
- assembly forward proagation
239 lines
10 KiB
Python
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")
|