diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 25d2dda..d4b9d39 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,11 +4,17 @@
-
+
-
+
+
+
+
+
+
+
@@ -120,14 +126,6 @@
1703867682707
-
-
- 1735601786207
-
-
-
- 1735601786207
- 1735652081552
@@ -512,7 +510,15 @@
1787058413911
-
+
+
+ 1787091089061
+
+
+
+ 1787091089061
+
+
@@ -533,7 +539,6 @@
-
@@ -558,6 +563,7 @@
-
+
+
\ No newline at end of file
diff --git a/.test_connector_relocate.py b/.test_connector_relocate.py
new file mode 100644
index 0000000..6b4ff9d
--- /dev/null
+++ b/.test_connector_relocate.py
@@ -0,0 +1,238 @@
+"""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")
diff --git a/.test_demo_repro.py b/.test_demo_repro.py
new file mode 100644
index 0000000..8cbd90e
--- /dev/null
+++ b/.test_demo_repro.py
@@ -0,0 +1,158 @@
+"""Headless repro: load assemblytest.fluency, compare sketch circle centers
+vs. saved body hole axes vs. connector positions, then run the real
+body-update path and check where connectors land.
+"""
+
+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.BRepAdaptor import BRepAdaptor_Surface
+from OCP.GeomAbs import GeomAbs_Cylinder
+from OCP.TopAbs import TopAbs_FACE
+from OCP.TopExp import TopExp_Explorer
+from OCP.TopoDS import TopoDS
+
+from PySide6.QtWidgets import QApplication
+
+from fluency.ui.main_window import MainWindow
+
+def etype(e):
+ if isinstance(e, dict):
+ return e.get("type")
+ return getattr(e, "entity_type", None) or getattr(e, "type", None)
+
+def egeom(e):
+ if isinstance(e, dict):
+ return e.get("geometry")
+ return getattr(e, "geometry", None)
+
+app = QApplication.instance() or QApplication([])
+w = MainWindow()
+
+path = os.path.join(os.path.dirname(__file__), "assemblytest.fluency")
+ok = w._open_project_file(path)
+assert ok, "failed to open demo file"
+
+proj = w._project
+
+for comp in proj.components.values():
+ print(f"=== {comp.name} ({comp.id})")
+ for sk in comp.sketches.values():
+ occ = sk.occ_sketch
+ if occ is None:
+ print(" sketch with no occ_sketch:", sk.id)
+ continue
+ for e in occ._entities.values():
+ t = etype(e)
+ if t == "circle":
+ g = egeom(e)
+ cid = e.get("id") if isinstance(e, dict) else e.id
+ print(f" circle id={cid} center=({g[0][0]!r}, {g[0][1]!r}) r={g[1]!r}")
+ for body in comp.bodies.values():
+ if not body.geometry:
+ print(f" body {body.name}: no geometry")
+ continue
+ shape = w._kernel._get_shape(body.geometry)
+ print(f" body {body.name}: extrude len={body.extrude_length}")
+ expl = TopExp_Explorer(shape, TopAbs_FACE)
+ while expl.More():
+ face = TopoDS.Face_s(expl.Current())
+ try:
+ adaptor = BRepAdaptor_Surface(face)
+ if adaptor.GetType() == GeomAbs_Cylinder:
+ cyl = adaptor.Cylinder()
+ loc = cyl.Location()
+ d = cyl.Axis().Direction()
+ print(
+ f" cyl axis at ({loc.X()!r}, {loc.Y()!r}) "
+ f"dir=({d.X():.4f},{d.Y():.4f},{d.Z():.4f})"
+ )
+ except Exception:
+ pass
+ expl.Next()
+
+asm = proj.get_active_assembly()
+print("=== active assembly:", asm.name)
+for ac in asm.components.values():
+ comp = proj.get_component_by_id(ac.component_id)
+ print(f" instance '{ac.name}' -> {comp.name}, pos={np.round(ac.position, 4)}")
+ for conn in ac.connectors.values():
+ print(
+ f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
+ f"normal={np.round(conn.normal, 3)} et={conn.entity_type!r} "
+ f"invalid={conn.is_invalid}"
+ )
+
+# ── Run the real update path: rebuild bodies from sketch, recalc connectors ──
+# Activate the component with the holes (Component 1).
+comp1 = None
+for comp in proj.components.values():
+ if any(
+ etype(e) == "circle"
+ for sk in comp.sketches.values()
+ for e in (sk.occ_sketch._entities.values() if sk.occ_sketch else [])
+ ):
+ comp1 = comp
+ break
+assert comp1 is not None
+w._current_component = comp1
+print("=== running _update_bodies_from_sketch()")
+w._update_bodies_from_sketch()
+print("=== running _recalculate_connectors()")
+w._recalculate_connectors()
+
+print("=== after update")
+for comp in proj.components.values():
+ for body in comp.bodies.values():
+ if not body.geometry:
+ continue
+ shape = w._kernel._get_shape(body.geometry)
+ expl = TopExp_Explorer(shape, TopAbs_FACE)
+ axes = []
+ while expl.More():
+ face = TopoDS.Face_s(expl.Current())
+ try:
+ adaptor = BRepAdaptor_Surface(face)
+ if adaptor.GetType() == GeomAbs_Cylinder:
+ loc = adaptor.Cylinder().Location()
+ axes.append((round(loc.X(), 9), round(loc.Y(), 9)))
+ except Exception:
+ pass
+ expl.Next()
+ print(f" {comp.name} body axes: {axes}")
+ for sk in comp.sketches.values():
+ for e in sk.occ_sketch._entities.values():
+ t = etype(e)
+ if t == "circle":
+ g = egeom(e)
+ print(f" {comp.name} circle: ({g[0][0]!r}, {g[0][1]!r})")
+
+asm = proj.get_active_assembly()
+for ac in asm.components.values():
+ for conn in ac.connectors.values():
+ print(
+ f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
+ f"invalid={conn.is_invalid}"
+ )
+
+# Partner alignment check: for each connection, world positions of the pair.
+for aconn in asm.connections:
+ a1 = asm.components.get(aconn.first_ac_id)
+ a2 = asm.components.get(aconn.second_ac_id)
+ c1 = a1.connectors.get(aconn.first_connector_id)
+ c2 = a2.connectors.get(aconn.second_connector_id)
+ if c1 is None or c2 is None:
+ continue
+ w1 = a1.position + a1.rotation @ np.asarray(c1.position, dtype=float)
+ w2 = a2.position + a2.rotation @ np.asarray(c2.position, dtype=float)
+ print(
+ f" conn {aconn.id[:8]}: w1={np.round(w1, 6)} w2={np.round(w2, 6)} "
+ f"gap={float(np.linalg.norm(w1 - w2))!r}"
+ )
+
+print("DEMO_REPRO_DONE")
diff --git a/.test_instance_ops.py b/.test_instance_ops.py
new file mode 100644
index 0000000..5fbf906
--- /dev/null
+++ b/.test_instance_ops.py
@@ -0,0 +1,89 @@
+"""Round-trip smoke test: instance-local sketches + modifiers survive save/load.
+
+Builds a minimal project (component with a body, assembly with two instances,
+one carrying an instance sketch + cut modifier + fillet modifier), saves to a
+temp .fluency, reloads, and asserts:
+ 1. the shared component is untouched by instance work,
+ 2. the instance sketch + modifiers round-trip with sketch refs intact,
+ 3. the plain instance has none.
+"""
+import os
+import tempfile
+
+import numpy as np
+
+from fluency.models.data_model import (
+ Project, Component, Body, Sketch, Feature, Assembly, AssemblyComponent,
+)
+from fluency.io.project_io import save_project, load_project
+
+
+def main():
+ project = Project(name="inst test")
+ comp = project.add_component()
+ comp.name = "BasePart"
+ body = comp.add_body(Body(name="MainBody"))
+ base_sketch = comp.add_sketch(Sketch(name="BaseSketch"))
+ body.features.append(
+ Feature(operation="extrude", sketch=base_sketch, length=10.0)
+ )
+
+ asm = project.add_assembly(Assembly(name="TestAsm"))
+ ac1 = asm.add_component_instance(comp.id, name="Instance A")
+ ac1.position = np.array([0.0, 0.0, 0.0])
+ ac2 = asm.add_component_instance(comp.id, name="Instance B")
+ ac2.position = np.array([50.0, 0.0, 0.0])
+
+ # Instance A: local sketch + cut modifier referencing it + fillet.
+ inst_sketch = ac1.add_instance_sketch()
+ inst_sketch.name = "InstCutSketch"
+ ac1.add_modifier(body.id, Feature(operation="cut", sketch=inst_sketch,
+ length=5.0, through_all=True))
+ ac1.add_modifier(body.id, Feature(operation="fillet", radius=1.0))
+
+ # ---- save / load ----
+ fd, path = tempfile.mkstemp(suffix=".fluency")
+ os.close(fd)
+ try:
+ save_project(project, path)
+ loaded, _view = load_project(path)
+
+ lcomp = loaded.components[comp.id]
+ lac1 = None
+ lac2 = None
+ for lasm in loaded.assemblies.values():
+ for ac in lasm.components.values():
+ if ac.name == "Instance A":
+ lac1 = ac
+ elif ac.name == "Instance B":
+ lac2 = ac
+ assert lac1 is not None and lac2 is not None, "instances missing"
+
+ # 1. component untouched
+ assert len(lcomp.sketches) == 1, "component sketch count changed"
+ assert len(lcomp.bodies[body.id].features) == 1, "feature chain changed"
+ assert not getattr(lcomp.bodies[body.id], "modifiers", None)
+
+ # 2. instance A round-trip
+ assert len(lac1.sketches) == 1, "instance sketch missing"
+ lsk_id, lsk = next(iter(lac1.sketches.items()))
+ assert lsk.name == "InstCutSketch"
+ mods = lac1.modifiers
+ lbody_id = next(iter(lcomp.bodies))
+ assert len(mods.get(lbody_id, [])) == 2, f"modifiers missing: {mods}"
+ cut, fil = mods[lbody_id][0], mods[lbody_id][1]
+ assert cut.operation == "cut" and fil.operation == "fillet"
+ assert cut.sketch is not None and cut.sketch.id == lsk_id, \
+ "cut sketch ref did not resolve to instance sketch"
+ assert cut.length == 5.0 and cut.through_all
+
+ # 3. plain instance clean
+ assert not lac2.sketches and not lac2.modifiers
+
+ print("ROUND_TRIP_OK")
+ finally:
+ os.unlink(path)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.test_relocate_fallback.py b/.test_relocate_fallback.py
new file mode 100644
index 0000000..295aeed
--- /dev/null
+++ b/.test_relocate_fallback.py
@@ -0,0 +1,83 @@
+"""Headless smoke: the manual re-pick fallback path (prompt -> frame -> pick mode)."""
+
+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, QMessageBox
+
+from fluency.ui.main_window import MainWindow
+from fluency.models.data_model import Body
+from fluency.geometry_occ.kernel import OCCGeometryObject
+
+app = QApplication.instance() or QApplication([])
+w = MainWindow()
+
+comp = w._current_component
+body = Body(name="plate")
+comp.bodies[body.id] = body
+box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
+ax = gp_Ax2(gp_Pnt(10.0, 5.0, 0.0), gp_Dir(0, 0, 1))
+box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
+body.geometry = OCCGeometryObject(box)
+
+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())
+
+asm = w._project.get_active_assembly()
+ac1 = asm.add_component_instance(comp.id, name="A")
+ac2 = asm.add_component_instance(partner.id, name="B")
+ac1.geom_cache[body.id] = body.geometry
+c1 = ac1.add_connector(
+ position=(10.0, 5.0, 2.5), normal=(0, 0, 1), x_dir=(1, 0, 0),
+ source_obj_id=f"asm_{ac1.id}_{body.id}",
+ name="Conn hole", entity_type="cylindrical_face",
+)
+c1.partner_ac_id = ac2.id
+
+# Activate the assembly view so the prompt path is taken.
+w._assembly_view_active = True
+w._selected_assembly_component_id = ac1.id
+
+# Force a Yes from the question dialog, and record that it actually fired.
+asked = {}
+def fake_question(parent, title, text, buttons, default):
+ asked["title"] = title
+ return QMessageBox.StandardButton.Yes
+QMessageBox.question = staticmethod(fake_question)
+def fake_warning(*a, **k):
+ return QMessageBox.StandardButton.Ok
+QMessageBox.warning = staticmethod(fake_warning)
+
+w._prompt_relocate_unresolved([(asm, ac1, c1)])
+assert asked.get("title") == "Connector Position Needed", asked
+assert w._relocate_pending == [(asm, ac1, c1)], w._relocate_pending
+assert w._viewer_3d._connector_pick_mode, "pick mode must be active"
+
+# A wrong-part click must not consume the pending entry.
+w._on_relocate_picked((0, 0, 0), (0, 0, 1), (1, 0, 0), "planar_face", f"asm_{ac2.id}_{pbody.id}")
+assert w._relocate_pending == [(asm, ac1, c1)], "wrong part must not consume the pick"
+
+# The right click re-homes and drains.
+w._on_relocate_picked((40.0, 30.0, 2.5), (0, 0, 1), (1, 0, 0), "cylindrical_face", f"asm_{ac1.id}_{body.id}")
+assert w._relocate_pending is None
+assert np.allclose(c1.position, (40.0, 30.0, 2.5)), c1.position
+assert not w._viewer_3d._connector_pick_mode, "pick mode must be off after completion"
+
+# Esc cancel mid-flight clears the state.
+w._relocate_pending = [(asm, ac1, c1)]
+w._start_relocate_pick_next()
+w._on_connector_pick_cancelled()
+assert w._relocate_pending is None
+assert not w._viewer_3d._connector_pick_mode
+print("RELOCATE_PICK_FALLBACK_OK")
diff --git a/.test_underlay_curves.py b/.test_underlay_curves.py
new file mode 100644
index 0000000..385a691
--- /dev/null
+++ b/.test_underlay_curves.py
@@ -0,0 +1,149 @@
+"""Headless test: update_external_entities handles circle/arc dict entries.
+
+Reproduces the crash where a re-projected face contains a circular edge
+(e.g. an instance cut hole): _project_face_to_uv returns a mixed list of
+polylines + curve dicts, and update_external_entities used to unpack the
+dict entries as (u, v) tuples.
+
+Covers:
+ 1. mixed projection (polylines + circle dict) -> rebuild + rebind path,
+ no crash, no duplicate/orphan curve entities, user geometry re-anchored.
+ 2. repeated update with the same mixed projection -> stable (idempotent).
+ 3. polylines-only same-topology projection -> in-place path still works
+ (external ids preserved).
+"""
+import math
+
+from fluency.geometry_occ.sketch import OCCSketch
+
+RECT = [
+ [(0.0, 0.0), (10.0, 0.0)],
+ [(10.0, 0.0), (10.0, 10.0)],
+ [(10.0, 10.0), (0.0, 10.0)],
+ [(0.0, 10.0), (0.0, 0.0)],
+]
+
+
+def _counts(sk):
+ ents = list(sk._entities.values())
+ return {
+ "ext_points": sum(
+ 1
+ for e in ents
+ if e.entity_type == "point" and getattr(e, "is_external", False)
+ ),
+ "ext_lines": sum(
+ 1
+ for e in ents
+ if e.entity_type == "line" and getattr(e, "is_external", False)
+ ),
+ "circles": sum(1 for e in ents if e.entity_type == "circle"),
+ "arcs": sum(1 for e in ents if e.entity_type == "arc"),
+ "user_points": sum(
+ 1
+ for e in ents
+ if e.entity_type == "point" and not getattr(e, "is_external", False)
+ ),
+ }
+
+
+def test_mixed_projection_rebuild():
+ sk = OCCSketch()
+ sk.add_external_polylines([list(p) for p in RECT])
+ center = sk.add_external_point(5.0, 5.0)
+ sk.add_circle(center, 2.0)
+ user = sk.add_point(5.0, 5.0)
+ assert sk.constrain_coincident(user, center)
+ assert sk.solve()
+
+ # Re-projection: same rectangle, circle moved + resized -> mixed list.
+ new_proj = [
+ list(p) for p in RECT
+ ] + [
+ {"type": "circle", "center": [6.0, 6.0], "radius": 1.5},
+ ]
+ old_ext_ids = set(sk._external_entity_ids)
+ assert sk.update_external_entities(new_proj), "rebuild + rebind solve failed"
+
+ c = _counts(sk)
+ assert c["circles"] == 1, f"duplicate circle entities: {c}"
+ assert c["ext_points"] == 5, f"ext point count wrong (expect 4 corners + 1 centre): {c}"
+ assert c["ext_lines"] == 4, f"ext line count wrong (expect 4): {c}"
+ assert c["user_points"] == 1
+ # The coincident rebind must anchor the user point to the NEW centre.
+ ux, uy = user.geometry
+ assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6, f"user point at {(ux, uy)}"
+ # Rebuild path: fresh external ids.
+ assert not (old_ext_ids & sk._external_entity_ids)
+
+ # Idempotent second pass with the same projection.
+ assert sk.update_external_entities(list(new_proj)), "second pass failed"
+ c2 = _counts(sk)
+ assert c2 == c, f"counts changed on second pass: {c} -> {c2}"
+ ux, uy = user.geometry
+ assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6
+ print("test_mixed_projection_rebuild OK")
+
+
+def test_polylines_only_inplace():
+ sk = OCCSketch()
+ sk.add_external_polylines([list(p) for p in RECT])
+ corner = None
+ for eid in sk._external_entity_ids:
+ ent = sk._entities[eid]
+ if ent.entity_type == "point" and ent.geometry == (0.0, 0.0):
+ corner = ent
+ break
+ assert corner is not None
+ user = sk.add_point(0.0, 0.0)
+ assert sk.constrain_coincident(user, corner)
+ assert sk.solve()
+
+ # Same topology, slightly shifted rectangle -> in-place move.
+ moved = [[(u + 1.0, v + 2.0) for (u, v) in poly] for poly in RECT]
+ old_ext_ids = set(sk._external_entity_ids)
+ assert sk.update_external_entities(moved), "in-place solve failed"
+ assert sk._external_entity_ids == old_ext_ids, "in-place path must keep ids"
+ ux, uy = user.geometry
+ assert math.hypot(ux - 1.0, uy - 2.0) < 1e-6, f"user point at {(ux, uy)}"
+ print("test_polylines_only_inplace OK")
+
+
+def test_arc_import_shares_corners():
+ """_import_external_curves must merge arc endpoints with existing
+ polyline corner points (no floating duplicate endpoints)."""
+ sk = OCCSketch()
+ # Rectangle with the top-right corner filleted: the arc endpoints must
+ # land on the truncated-edge corner points, not create new ones.
+ r = 2.0
+ sk.add_external_polylines([
+ [(0.0, 0.0), (10.0, 0.0)],
+ [(10.0, 0.0), (10.0, 10.0 - r)],
+ [(10.0 - r, 10.0), (0.0, 10.0)],
+ [(0.0, 10.0), (0.0, 0.0)],
+ ])
+ sk._import_external_curves(
+ [],
+ [
+ {
+ "type": "arc",
+ "center": [10.0 - r, 10.0 - r],
+ "start": [10.0, 10.0 - r],
+ "end": [10.0 - r, 10.0],
+ "radius": r,
+ },
+ ],
+ )
+ c = _counts(sk)
+ # 5 corners + 1 arc centre, NO extra endpoint entities.
+ assert c["ext_points"] == 6, f"expected 6 ext points, got {c}"
+ assert c["arcs"] == 1, f"expected 1 arc, got {c}"
+ assert sk.solve()
+ print("test_arc_import_shares_corners OK")
+
+
+if __name__ == "__main__":
+ test_mixed_projection_rebuild()
+ test_polylines_only_inplace()
+ test_arc_import_shares_corners()
+ print("UNDERLAY_CURVES_OK")
diff --git a/assemblytest.fluency b/assemblytest.fluency
new file mode 100644
index 0000000..add25df
Binary files /dev/null and b/assemblytest.fluency differ
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index 6316da1..16852e0 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -327,9 +327,16 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity,
end_point: SketchEntity,
sweep: Optional[float] = None,
+ register: bool = True,
) -> OCCSketchEntity:
"""Add an arc (added to solver + tracked).
+ *register* False keeps the arc tracked-only (no solver entity, no
+ handle) — for reference geometry whose three reference points are
+ all already fixed, e.g. external underlay arcs: registering the
+ arc on top of three dragged points over-constrains the solver
+ (SolveSpace reports the system as inconsistent).
+
The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
@@ -362,7 +369,11 @@ class OCCSketch(SketchInterface):
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
- if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
+ if register and (
+ center_entity.handle is None
+ or start_entity.handle is None
+ or end_entity.handle is None
+ ):
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry
@@ -386,17 +397,20 @@ class OCCSketch(SketchInterface):
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
- if self._wp_normal_handle is None:
- self._wp_normal_handle = self._make_arc_normal_3d()
- nm: Any = self._wp_normal_handle
- assert nm is not None # _make_arc_normal_3d always returns a handle
- arc_handle = self._solver.add_arc(
- nm,
- center_entity.handle,
- start_entity.handle,
- end_entity.handle,
- self._wp,
- )
+ if register:
+ if self._wp_normal_handle is None:
+ self._wp_normal_handle = self._make_arc_normal_3d()
+ nm: Any = self._wp_normal_handle
+ assert nm is not None # _make_arc_normal_3d always returns a handle
+ arc_handle = self._solver.add_arc(
+ nm,
+ center_entity.handle,
+ start_entity.handle,
+ end_entity.handle,
+ self._wp,
+ )
+ else:
+ arc_handle = None
entity = OCCSketchEntity(
entity_id=entity_id,
@@ -418,6 +432,9 @@ class OCCSketch(SketchInterface):
"end": end_point.id,
"radius": radius,
"sweep": sweep,
+ # Tracked-only arcs (register=False) have no solver entity and
+ # are skipped by ``_rebuild_solver``.
+ "in_solver": register,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
@@ -587,6 +604,70 @@ class OCCSketch(SketchInterface):
pass
return all_points, all_lines
+ def _import_external_curves(
+ self,
+ circles: List[Dict[str, Any]],
+ arcs: List[Dict[str, Any]],
+ ) -> None:
+ """Import projected circle/arc dicts as external underlay entities.
+
+ Mirrors the widget's initial underlay import: a circle becomes a
+ fixed external centre point plus a tracked circle entity; an arc
+ becomes a fixed external centre point, endpoint entities (shared
+ with the polyline corners already imported, so the arc connects
+ to the adjacent lines), and an arc entity. Must be called after
+ ``add_external_polylines`` and before ``_rebuild_solver`` — the
+ rebuild re-registers arcs in the fresh solver and re-fixes every
+ external point.
+ """
+ for c in circles:
+ try:
+ center_uv = (float(c["center"][0]), float(c["center"][1]))
+ center_pt = self.add_external_point(center_uv[0], center_uv[1])
+ self.add_circle(center_pt, float(c["radius"]))
+ except Exception as exc:
+ logger.debug("external circle import failed: %s", exc)
+
+ # Generous tolerance: arc endpoints come from a fresh projection of
+ # the face and must land on the existing corner points despite float
+ # drift (same rule as the widget's initial import).
+ merge_tol = 1e-3
+
+ def find_pt(u: float, v: float) -> Optional[OCCSketchEntity]:
+ best: Optional[OCCSketchEntity] = None
+ best_d = merge_tol
+ for eid in self._external_entity_ids:
+ ent = self._entities.get(eid)
+ if ent is None or ent.entity_type != "point" or ent.geometry is None:
+ continue
+ d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
+ if d <= best_d:
+ best_d = d
+ best = ent
+ return best
+
+ for a in arcs:
+ try:
+ center_uv = (float(a["center"][0]), float(a["center"][1]))
+ start_uv = (float(a["start"][0]), float(a["start"][1]))
+ end_uv = (float(a["end"][0]), float(a["end"][1]))
+ center_pt = self.add_external_point(center_uv[0], center_uv[1])
+ start_pt = find_pt(start_uv[0], start_uv[1])
+ if start_pt is None:
+ start_pt = self.add_external_point(start_uv[0], start_uv[1])
+ end_pt = find_pt(end_uv[0], end_uv[1])
+ if end_pt is None:
+ end_pt = self.add_external_point(end_uv[0], end_uv[1])
+ # register=False: all three reference points are external
+ # (dragged/fixed) — registering the arc on top would
+ # over-constrain the solver (inconsistent).
+ self.add_arc(
+ center_pt, float(a["radius"]), start_pt, end_pt,
+ sweep=None, register=False,
+ )
+ except Exception as exc:
+ logger.debug("external arc import failed: %s", exc)
+
def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints.
@@ -606,6 +687,21 @@ class OCCSketch(SketchInterface):
self._lines.pop(eid, None)
self._circles.pop(eid, None)
self._arcs.pop(eid, None)
+ # Underlay circle/arc entities are tracked (not tagged external):
+ # drop any whose reference points just went away, or a re-import
+ # would pile stale duplicates on top of the fresh ones.
+ for cid, (cent_id, _radius) in list(self._circles.items()):
+ if cent_id in removed:
+ del self._circles[cid]
+ self._entities.pop(cid, None)
+ for aid, arc_data in list(self._arcs.items()):
+ if (
+ arc_data.get("center") in removed
+ or arc_data.get("start") in removed
+ or arc_data.get("end") in removed
+ ):
+ del self._arcs[aid]
+ self._entities.pop(aid, None)
# Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph).
@@ -637,7 +733,7 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids)
- def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
+ def update_external_entities(self, polylines: List[Any]) -> bool:
"""Re-project external (underlay) entities from updated source geometry.
Called when the 3D body the underlay was projected from has been
@@ -646,6 +742,11 @@ class OCCSketch(SketchInterface):
body so user geometry constrained to it propagates through the
solver.
+ *polylines* is the raw output of ``_project_face_to_uv``: a mixed
+ list of plain polylines (lists of ``(u, v)``) and curve dicts
+ (``{"type": "circle", ...}`` / ``{"type": "arc", ...}``) for
+ circular/arc face edges.
+
Two paths:
* **In-place update** (same topology): when the new projection has
@@ -663,6 +764,22 @@ class OCCSketch(SketchInterface):
Returns True when the underlay was updated and solved OK.
"""
# Flatten the new projection into unique corner positions + segments.
+ # The projection mixes plain polylines with curve dicts; polylines
+ # carry the corner/segment topology used below, curve dicts are
+ # only handled by the rebuild + rebind path.
+ polys: List[List[Tuple[float, float]]] = []
+ circles: List[Dict[str, Any]] = []
+ arcs: List[Dict[str, Any]] = []
+ for entry in polylines:
+ if isinstance(entry, dict):
+ etype = entry.get("type")
+ if etype == "circle":
+ circles.append(entry)
+ elif etype == "arc":
+ arcs.append(entry)
+ elif isinstance(entry, (list, tuple)):
+ polys.append(list(entry))
+
tol = self._EXTERNAL_MERGE_TOL
new_pts: List[Tuple[float, float]] = []
@@ -674,7 +791,7 @@ class OCCSketch(SketchInterface):
return len(new_pts) - 1
new_segs: List[Tuple[int, int]] = []
- for poly in polylines:
+ for poly in polys:
if len(poly) < 2:
continue
idx = [new_index(float(u), float(v)) for (u, v) in poly]
@@ -694,7 +811,13 @@ class OCCSketch(SketchInterface):
lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
]
- same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
+ # Curve entries force the rebuild path: the in-place branch only
+ # moves point/line entities and cannot represent a circle or arc.
+ same_topology = (
+ not (circles or arcs)
+ and len(new_pts) == len(old_ext_points)
+ and len(new_segs) == len(old_ext_lines)
+ )
if same_topology and old_ext_points:
# Greedy one-to-one nearest matching old point -> new position.
@@ -769,7 +892,8 @@ class OCCSketch(SketchInterface):
# points and (per the add_point guard) does not auto-anchor a user
# point — which would conflict with the re-bound coincidents below.
self._drop_external_entities()
- self.add_external_polylines(polylines)
+ self.add_external_polylines(polys)
+ self._import_external_curves(circles, arcs)
self._rebuild_solver()
self._rebuild_labels()
@@ -1125,6 +1249,8 @@ class OCCSketch(SketchInterface):
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
+ if not arc_data.get("in_solver", True):
+ continue # tracked-only (underlay) arc — no solver state
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
@@ -2725,6 +2851,9 @@ class OCCSketch(SketchInterface):
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
+ # Tracked-only underlay arcs must not be
+ # re-registered with the solver on load.
+ "in_solver": bool(arc_data.get("in_solver", True)),
}
entities_payload.append(
{
@@ -2941,12 +3070,30 @@ class OCCSketch(SketchInterface):
if c_id is None or s_id is None or e_id is None:
logger.warning("Skipping arc %s during load: endpoints not found", eid)
return
+ in_solver = bool(geom.get("in_solver", True))
+ if "in_solver" not in geom:
+ # Old files: an arc whose three reference points are
+ # all external is an underlay arc — re-registering it
+ # over-constrains the solver (inconsistent).
+ c_ent_r = entities_by_id.get(c_id)
+ s_ent_r = entities_by_id.get(s_id)
+ e_ent_r = entities_by_id.get(e_id)
+ if (
+ c_ent_r is not None
+ and s_ent_r is not None
+ and e_ent_r is not None
+ and getattr(c_ent_r, "is_external", False)
+ and getattr(s_ent_r, "is_external", False)
+ and getattr(e_ent_r, "is_external", False)
+ ):
+ in_solver = False
ent = self.add_arc(
entities_by_id[c_id],
radius,
entities_by_id[s_id],
entities_by_id[e_id],
sweep=sweep,
+ register=in_solver,
)
else:
logger.warning("Unknown sketch entity type %r; skipping", etype)
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index 25b87e6..84c8c5b 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -495,6 +495,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id,
+ "entity_type": conn.entity_type,
+ "normal_flip": bool(conn.normal_flip),
"partner_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded),
@@ -515,6 +517,8 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""),
+ entity_type=data.get("entity_type", ""),
+ normal_flip=bool(data.get("normal_flip", False)),
)
conn.partner_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_id")
@@ -533,12 +537,22 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
"position": _coerce_listlike(ac.position),
"rotation": _coerce_listlike(ac.rotation),
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
+ # Instance-local sketches + per-body modifier ops (kept apart from
+ # the shared component so save/load never mutates the base model).
+ "sketches": {sid: _sketch_to_dict(sk) for sid, sk in ac.sketches.items()},
+ "modifiers": {
+ bid: [_feature_to_dict(f) for f in mods]
+ for bid, mods in ac.modifiers.items()
+ },
"created_at": ac.created_at.isoformat() if ac.created_at else None,
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
}
-
-def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
+def _assembly_component_from_dict(
+ data: Dict[str, Any],
+ component: Optional[Component] = None,
+ sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
+) -> AssemblyComponent:
ac = AssemblyComponent(
id=_saved_id(data),
component_id=data.get("component_id", ""),
@@ -550,6 +564,36 @@ def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac.modified_at = _parse_iso(data.get("modified_at"))
for cid, c_data in (data.get("connectors") or {}).items():
ac.connectors[cid] = _connector_from_dict(c_data)
+
+ # Instance-local sketches first, so modifier sketch references can
+ # resolve against them (they live in component-local coordinates).
+ for sid, sk_data in (data.get("sketches") or {}).items():
+ try:
+ ac.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
+ except Exception:
+ logger.warning("Skipping corrupt instance sketch %s", sid)
+
+ # Modifiers resolve sketch refs against the owning component's sketches
+ # first, then this instance's own sketches.
+ registry: Dict[str, Sketch] = {}
+ if component is not None:
+ registry.update(component.sketches)
+ registry.update(ac.sketches)
+ for bid, f_list in (data.get("modifiers") or {}).items():
+ kept: List[Feature] = []
+ for f_data in f_list or []:
+ try:
+ feat = _feature_from_dict(f_data, registry)
+ except Exception:
+ logger.warning("Skipping corrupt instance modifier on body %s", bid)
+ continue
+ # A sketch-based op whose sketch failed to load can never
+ # replay — dropping it keeps the rest of the chain usable.
+ if feat.operation in ("extrude", "cut", "union", "revolve") and feat.sketch is None:
+ continue
+ kept.append(feat)
+ if kept:
+ ac.modifiers[bid] = kept
return ac
@@ -587,8 +631,11 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
}
-
-def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
+def _assembly_from_dict(
+ data: Dict[str, Any],
+ components: Optional[Dict[str, Component]] = None,
+ sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
+) -> Assembly:
asm = Assembly(
id=_saved_id(data),
name=data.get("name", "Untitled Assembly"),
@@ -597,7 +644,10 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.created_at = _parse_iso(data.get("created_at"))
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
- asm.components[cid] = _assembly_component_from_dict(ac_data)
+ comp = (components or {}).get(ac_data.get("component_id", ""))
+ asm.components[cid] = _assembly_component_from_dict(
+ ac_data, component=comp, sketch_geometry_loader=sketch_geometry_loader
+ )
for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data))
return asm
@@ -894,6 +944,40 @@ def save_project(
sketch_files.append((arcname, step_bytes))
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
+ # Instance-local sketches (assembly components) get the same sidecar
+ # treatment as component sketches; the manifest nodes are patched in
+ # place under the assembly's component entry.
+ for asm_id, asm in project.assemblies.items():
+ for ac_id, ac in asm.components.items():
+ for sketch_id, sketch in ac.sketches.items():
+ node = manifest["assemblies"][asm_id]["components"][ac_id]["sketches"].get(sketch_id)
+ if node is None:
+ continue
+ occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
+ meta = {
+ "id": sketch.id,
+ "name": sketch.name,
+ "workplane_origin": _coerce_listlike(sketch.workplane_origin),
+ "workplane_normal": _coerce_listlike(sketch.workplane_normal),
+ "workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
+ "is_solved": bool(sketch.is_solved),
+ "is_fully_constrained": bool(sketch.is_fully_constrained),
+ "occ_sketch": occ,
+ }
+ meta_arc = f"sketches/{sketch_id}/meta.json"
+ sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
+ node["occ_sketch"] = None
+ node["occ_sketch_ref"] = meta_arc
+
+ if sketch.geometry is None:
+ continue
+ step_bytes = _write_step_for_body(kernel, sketch.geometry)
+ if step_bytes is None:
+ continue
+ arcname = f"sketches/{sketch_id}/solved.step"
+ sketch_files.append((arcname, step_bytes))
+ node["geometry_ref"] = arcname
+
# Write the ZIP. Use a temp file + rename so a partial write can't
# clobber an existing good file.
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
@@ -959,8 +1043,9 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
# If a sketch's occ_sketch is referenced as a separate file, read
# it in now and patch the manifest so _sketch_from_dict sees it.
- for comp_id, comp_data in (manifest.get("components") or {}).items():
- for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
+ # Applies to both component sketches and instance-local sketches.
+ def _patch_sketch_sidecars(sketches_dict: Dict[str, Any]) -> None:
+ for sk_id, sk_data in (sketches_dict or {}).items():
ref = sk_data.get("occ_sketch_ref")
if not ref:
continue
@@ -987,6 +1072,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
if k in meta:
sk_data[k] = meta[k]
+ for comp_id, comp_data in (manifest.get("components") or {}).items():
+ _patch_sketch_sidecars(comp_data.get("sketches"))
+ for aid, a_data in (manifest.get("assemblies") or {}).items():
+ for ac_id, ac_data in (a_data.get("components") or {}).items():
+ _patch_sketch_sidecars(ac_data.get("sketches"))
+
project = Project(
name=manifest.get("name", "Untitled Project"),
description=manifest.get("description", ""),
@@ -1006,7 +1097,11 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
)
for aid, a_data in (manifest.get("assemblies") or {}).items():
- project.assemblies[aid] = _assembly_from_dict(a_data)
+ project.assemblies[aid] = _assembly_from_dict(
+ a_data,
+ components=project.components,
+ sketch_geometry_loader=sketch_geometry_loader,
+ )
for d_data in manifest.get("drawings") or []:
try:
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index b138d18..a0fdd17 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -487,6 +487,16 @@ class Connector:
assembly_component_id: str = ""
# Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = ""
+ # Entity class the connector was picked on ("planar_face",
+ # "cylindrical_face", "edge", "vertex"). Used to re-locate the
+ # connector on rebuilt geometry: only features of the same class are
+ # considered, so a hole connector can never jump onto a flat face.
+ # Empty for legacy files (all classes are then searched).
+ entity_type: str = ""
+ # Flip chosen in the placement dialog (bolt enters from the opposite
+ # side). Re-applied when a mated pair is re-solved so the original
+ # mate pose is reproduced exactly.
+ normal_flip: bool = False
# --- Rigid-group pairing (set when two connectors are mated) ---
# The id of the partner AssemblyComponent this connector is mated to.
@@ -506,6 +516,16 @@ class Connector:
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
+ def __post_init__(self) -> None:
+ # Legacy files predate the entity_type field: recover it from the
+ # auto-generated connector name ("Conn cylindrical_face anchor") so
+ # relocation can restrict its search to the same feature class.
+ if not self.entity_type:
+ for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
+ if self.name in (f"Conn {t} anchor", f"Conn {t} mover"):
+ self.entity_type = t
+ break
+
@dataclass
class AssemblyComponent:
@@ -529,6 +549,20 @@ class AssemblyComponent:
# Connectors defined on this component instance.
connectors: Dict[str, Connector] = field(default_factory=dict)
+ # Instance-local (per-instantiation) state. Kept separate from the
+ # shared component so per-instance work never leaks back into the base
+ # model. ``sketches`` are instance-local sketches stored in
+ # component-local coordinates (so they stay valid when the instance is
+ # moved / rotated); ``modifiers`` maps body_id to an ordered list of
+ # Feature ops applied ON TOP of the live component body feature history
+ # when the instance geometry is rebuilt.
+ sketches: Dict[str, Sketch] = field(default_factory=dict)
+ modifiers: Dict[str, List[Feature]] = field(default_factory=dict)
+
+ # Runtime-only cache of rebuilt instance geometry (body_id -> geometry).
+ # Never serialized; invalidated on component updates and modifier edits.
+ geom_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
+
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
@@ -539,6 +573,8 @@ class AssemblyComponent:
x_dir: Tuple[float, float, float],
source_obj_id: str = "",
name: Optional[str] = None,
+ entity_type: str = "",
+ normal_flip: bool = False,
) -> Connector:
"""Add a connector to this component instance."""
conn = Connector(
@@ -548,6 +584,8 @@ class AssemblyComponent:
x_dir=x_dir,
assembly_component_id=self.id,
source_obj_id=source_obj_id,
+ entity_type=entity_type,
+ normal_flip=normal_flip,
)
self.connectors[conn.id] = conn
self.modified_at = datetime.now()
@@ -561,6 +599,45 @@ class AssemblyComponent:
return True
return False
+ def add_instance_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
+ """Add an instance-local sketch (component-local coordinates)."""
+ if sketch is None:
+ sketch = Sketch(name=f"Instance Sketch {len(self.sketches) + 1}")
+ self.sketches[sketch.id] = sketch
+ self.modified_at = datetime.now()
+ return sketch
+
+ def remove_instance_sketch(self, sketch_id: str) -> bool:
+ """Remove an instance sketch and every modifier that references it."""
+ if sketch_id not in self.sketches:
+ return False
+ del self.sketches[sketch_id]
+ for body_id in list(self.modifiers.keys()):
+ kept = [
+ f for f in self.modifiers[body_id]
+ if not (f.sketch is not None and f.sketch.id == sketch_id)
+ ]
+ if kept:
+ self.modifiers[body_id] = kept
+ else:
+ del self.modifiers[body_id]
+ self.geom_cache.pop(body_id, None)
+ self.modified_at = datetime.now()
+ return True
+
+ def add_modifier(self, body_id: str, feat: Feature) -> Feature:
+ """Append a modifier op to *body_id*'s instance-local history."""
+ self.modifiers.setdefault(body_id, []).append(feat)
+ self.geom_cache.pop(body_id, None)
+ self.modified_at = datetime.now()
+ return feat
+
+ def invalidate_geom_cache(self, body_id: Optional[str] = None) -> None:
+ """Drop cached rebuilt instance geometry (one body, or all)."""
+ if body_id is None:
+ self.geom_cache.clear()
+ else:
+ self.geom_cache.pop(body_id, None)
@dataclass
class AssemblyConnection:
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 61bc92e..55ecf72 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -164,12 +164,17 @@ class OCCRenderer(Renderer):
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
self._gizmo_objects: Dict[str, Any] = {}
+ # Persistent connector gizmo objects (first pick) – not cleared by hover.
+ self._persistent_gizmo_objects: Dict[str, Any] = {}
# World-anchored sketch reference gizmo (a triad at the sketch
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
self._sketch_gizmo_parts: Dict[str, Any] = {}
# Part kind currently highlighted on hover (for restore-on-leave).
self._sketch_gizmo_highlighted: Optional[str] = None
+ # Cache for shape classification to avoid re-classifying same OCC sub-shapes
+ # during repeated probe/hover calls. Key = (id(shape), owner_obj_id).
+ self._classify_cache: dict = {}
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -335,10 +340,16 @@ class OCCRenderer(Renderer):
shape: Any,
color: Optional[Tuple[float, float, float]] = None,
name: Optional[str] = None,
+ auto_fit: bool = True,
) -> str:
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
Returns a unique object ID (or *name* if provided).
+
+ With *auto_fit* (default), the first object added to an empty
+ scene triggers a camera fit. Pass ``auto_fit=False`` when
+ rebuilding a scene under explicit camera control (e.g. the
+ assembly view), so the rebuild doesn't move the camera.
"""
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
@@ -398,7 +409,7 @@ class OCCRenderer(Renderer):
self._objects[obj_id] = robj
# Fit camera on first shape added.
- if len(self._objects) == 1:
+ if auto_fit and len(self._objects) == 1:
try:
self.fit_camera()
except Exception:
@@ -1514,6 +1525,10 @@ class OCCRenderer(Renderer):
"""
if shape is None:
return []
+ # Cache lookup
+ cache_key = (id(shape), owner_obj_id)
+ if cache_key in self._classify_cache:
+ return self._classify_cache[cache_key]
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
@@ -1575,7 +1590,7 @@ class OCCRenderer(Renderer):
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
- return [
+ res = [
{
"type": "planar_face",
"position": origin,
@@ -1585,6 +1600,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
+ self._classify_cache[cache_key] = res
+ return res
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1692,6 +1709,7 @@ class OCCRenderer(Renderer):
"radius": radius,
}
)
+ self._classify_cache[cache_key] = results
return results
# Try edge.
@@ -1735,7 +1753,7 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
- return [
+ res = [
{
"type": "edge",
"position": position,
@@ -1745,6 +1763,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
+ self._classify_cache[cache_key] = res
+ return res
# Try vertex.
vertex = None
@@ -1752,7 +1772,7 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
- return [
+ res = [
{
"type": "vertex",
"position": position,
@@ -1762,9 +1782,12 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
+ self._classify_cache[cache_key] = res
+ return res
except Exception:
pass
+ self._classify_cache[cache_key] = []
return []
def probe_snap_candidates(
@@ -2467,6 +2490,114 @@ class OCCRenderer(Renderer):
if self._view is not None:
self._view.Update()
+ def show_persistent_entity_gizmo(
+ self,
+ entity_type: str,
+ position: Tuple[float, float, float],
+ normal: Optional[Tuple[float, float, float]] = None,
+ x_dir: Optional[Tuple[float, float, float]] = None,
+ radius: Optional[float] = None,
+ color: Tuple[float, float, float] = (0.0, 1.0, 0.0),
+ ) -> None:
+ """Display a persistent green gizmo for a confirmed first connector pick.
+
+ Unlike show_entity_gizmo, this does not clear the hover gizmo and stores
+ its AIS objects in _persistent_gizmo_objects so they survive hover updates.
+ """
+ if self._context is None:
+ return
+ # Clear previous persistent gizmo
+ self.clear_persistent_entity_gizmo()
+
+ gizmo_scale = self._get_gizmo_scale(position)
+ from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
+
+ def _store(obj, key):
+ self._context.Display(obj, True)
+ self._persistent_gizmo_objects[key] = obj
+
+ def _make_sphere(p, c, size):
+ try:
+ s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape()
+ a = AIS_Shape(s)
+ a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB))
+ a.SetDisplayMode(1)
+ _store(a, f"__pg_sphere_{id(a)}")
+ except Exception as exc:
+ logger.debug(f"persistent gizmo sphere failed: {exc}")
+
+ px, py, pz = position
+ _make_sphere(position, color, 5.6 * gizmo_scale)
+
+ axis_length = 30.0 * gizmo_scale
+
+ def _make_axis_line(origin, direction, length, line_color, label):
+ try:
+ dx, dy, dz = direction
+ norm = (dx*dx + dy*dy + dz*dz) ** 0.5
+ if norm < 1e-9:
+ return
+ ux, uy, uz = dx/norm, dy/norm, dz/norm
+ ex = origin[0] + ux * length
+ ey = origin[1] + uy * length
+ ez = origin[2] + uz * length
+ edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
+ ais = AIS_Shape(edge)
+ ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
+ ais.SetDisplayMode(0)
+ _store(ais, f"__pg_{label}_{id(ais)}")
+ except Exception as exc:
+ logger.debug(f"persistent gizmo axis failed: {exc}")
+
+ if entity_type == "planar_face" and normal is not None:
+ _make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal")
+ if x_dir is not None:
+ _make_axis_line(position, x_dir, axis_length * 0.6, color, "xdir")
+ elif entity_type == "cylindrical_face" and normal is not None:
+ _make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
+ _make_axis_line(position, (-normal[0], -normal[1], -normal[2]), axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub")
+ if x_dir is not None:
+ _make_axis_line(position, x_dir, radius or (axis_length * 0.5), color, "radial")
+ # ring
+ if radius is not None:
+ try:
+ center = gp_Pnt(px, py, pz)
+ ax2 = gp_Ax2(center, gp_Dir(*normal))
+ circ = gp_Circ(ax2, radius)
+ ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge()
+ ring_ais = AIS_Shape(ring_edge)
+ ring_ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
+ ring_ais.SetDisplayMode(0)
+ _store(ring_ais, f"__pg_ring_{id(ring_ais)}")
+ except Exception as exc:
+ logger.debug(f"persistent gizmo ring failed: {exc}")
+ elif entity_type == "edge" and normal is not None:
+ _make_axis_line(position, normal, axis_length, color, "tangent")
+ elif entity_type == "vertex":
+ _make_axis_line(position, (1,0,0), axis_length * 0.5, (1.0,0.3,0.3), "cross_x")
+ _make_axis_line(position, (0,1,0), axis_length * 0.5, (0.3,1.0,0.3), "cross_y")
+ _make_axis_line(position, (0,0,1), axis_length * 0.5, (0.3,0.3,1.0), "cross_z")
+
+ if self._view is not None:
+ self._view.Update()
+
+ def clear_persistent_entity_gizmo(self) -> None:
+ """Remove the persistent first-pick gizmo."""
+ if self._context is None:
+ return
+ for obj in list(self._persistent_gizmo_objects.values()):
+ try:
+ self._context.Erase(obj, True)
+ except Exception:
+ pass
+ self._persistent_gizmo_objects.clear()
+ if self._view is not None:
+ self._view.Update()
+
# ─── Selection mode control ───────────────────────────────────────────
#
# When connector gizmo mode is active, standard OCC face/edge/vertex
@@ -2703,6 +2834,13 @@ class OCCRenderer(Renderer):
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
)
+ # Early exit if we already found a very close candidate — avoids unnecessary work.
+ if results:
+ best = results[0]
+ best_sp = best.get("screen", (x, y))
+ best_dist2 = (best_sp[0] - x) ** 2 + (best_sp[1] - y) ** 2
+ if best_dist2 <= 25: # within 5 px
+ return [best]
return results
def recognize_composite_features(
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 876230f..649227d 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -1218,6 +1218,60 @@ def _resolve_fillet_edges(
return _expand_tangent_chain(shape, seed_edges)
+def _compute_fillet_face_keys(
+ shape: Any,
+ features: List[Feature],
+ face_a: Any,
+ face_b: Any,
+) -> Tuple[Optional[List], Optional[str]]:
+ """Classify the two picked faces into stable FaceKeys for replay.
+
+ Walks *features* backwards for the last sketch-producing op and maps
+ *face_a* / *face_b* through the extruded-face classification of the
+ pre-op *shape*. Returns (face_keys, sketch_id); (None, None) when the
+ faces can't be classified (replay then falls back to fingerprints).
+ """
+ sketch_feat: Optional[Feature] = None
+ for f in reversed(features):
+ if f.operation in ("extrude", "revolve", "cut", "union") and f.sketch is not None:
+ sketch_feat = f
+ break
+ if sketch_feat is None or sketch_feat.sketch is None:
+ return None, None
+ sk = sketch_feat.sketch
+ if sk.occ_sketch is None:
+ return None, None
+ try:
+ face_map = _classify_extruded_faces(
+ shape,
+ sk.occ_sketch,
+ tuple(sk.workplane_origin.tolist()),
+ tuple(sk.workplane_normal.tolist()),
+ )
+ from OCP.TopoDS import TopoDS as _TopoDS
+ from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
+
+ key_a = key_b = None
+ ex = _TopExp_Explorer(shape, _TopAbs_FACE)
+ idx = 0
+ while ex.More():
+ face_obj = _TopoDS.Face_s(ex.Current())
+ fk = face_map.get(idx)
+ if fk is not None:
+ if face_obj.IsSame(face_a):
+ key_a = fk
+ if face_obj.IsSame(face_b):
+ key_b = fk
+ idx += 1
+ ex.Next()
+ if key_a is not None and key_b is not None:
+ return [(key_a, key_b)], sk.id
+ except Exception:
+ logger.debug("FaceKey classification failed", exc_info=True)
+ return None, None
+
+
def _resolve_edges_by_fingerprint(shape: Any, refs: List[str]) -> List[Any]:
"""Find the edges of *shape* matching the stored fingerprints.
@@ -1704,6 +1758,30 @@ def _build_array_preview(
return comp
+# A re-located connector candidate farther than this (mm) from the stored
+# position is NOT applied automatically: the feature likely moved a lot
+# (or the nearest feature is a different one), so the user picks the new
+# position in the 3D view instead.
+_RELOCATE_AUTO_MAX_DIST = 5.0
+
+
+def _relocate_confident(dist: float, second_dist: Optional[float]) -> bool:
+ """Whether a re-located candidate can be applied without asking.
+
+ A candidate is accepted automatically when it is close by (<=
+ ``_RELOCATE_AUTO_MAX_DIST`` mm), the ONLY candidate of its class on
+ the source body, or clearly the nearest one (less than half the
+ distance of the second candidate). Anything else — the feature moved
+ a lot and several same-class features are plausible matches — is left
+ for a manual pick.
+ """
+ if dist <= _RELOCATE_AUTO_MAX_DIST:
+ return True
+ if second_dist is None:
+ return True
+ return dist < 0.5 * second_dist
+
+
class MainWindow(QMainWindow):
"""Main application window."""
@@ -1739,12 +1817,16 @@ class MainWindow(QMainWindow):
self._fillet_face1: Optional[Any] = None
self._fillet_face2: Optional[Any] = None
self._fillet_body: Optional[Body] = None
+ # (AssemblyComponent, Body) when the fillet targets an instance.
+ self._fillet_inst: Optional[Tuple[Any, Body]] = None
# Chamfer tool: same two-face pick flow as fillet.
self._chamfer_pick_active: bool = False
self._chamfer_face1: Optional[Any] = None
self._chamfer_face2: Optional[Any] = None
self._chamfer_body: Optional[Body] = None
+ # (AssemblyComponent, Body) when the chamfer targets an instance.
+ self._chamfer_inst: Optional[Tuple[Any, Body]] = None
# Thread tool: pick cylindrical face flow.
self._thread_pick_active: bool = False
@@ -1760,11 +1842,21 @@ class MainWindow(QMainWindow):
self._assembly_view_active: bool = False
self._render_mode: str = "component"
self._selected_assembly_component_id: Optional[str] = None
+ # Instance-modifier context: when an assembly instance is selected,
+ # CAD operations (face sketch, extrude/cut, fillet, chamfer, mirror,
+ # array) target that instance's per-instantiation modifiers instead
+ # of the shared component. Holds the AssemblyComponent id, or None
+ # in plain component view.
+ self._instance_ac_id: Optional[str] = None
# Connector two-click state
self._connector_first_pick: Optional[Dict[str, Any]] = None
self._connector_second_ac_id: Optional[str] = None
self._connector_align_pos: Any = None
+ # Relocate fallback: connectors whose source feature moved too far
+ # (or disappeared) and need a manual re-pick in the 3D view.
+ # Each entry is (assembly, ac, conn).
+ self._relocate_pending: Optional[List[Tuple[Any, Any, Any]]] = None
# Drag-move state for assembly components
self._asm_move_ac_id: Optional[str] = None
@@ -2113,6 +2205,7 @@ class MainWindow(QMainWindow):
# ── Connection list ──
self._connection_list = ui.connection_list
self._btn_del_connection = ui.pb_del_connection
+ self._btn_update_connection = ui.pb_update_connection
# ── Code tab ──
self._btn_apply_code = ui.pb_apply_code
self._btn_load_code = ui.pushButton_5
@@ -2238,10 +2331,11 @@ class MainWindow(QMainWindow):
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
self._btn_del_connector.clicked.connect(self._on_delete_connector)
self._btn_del_connection.clicked.connect(self._on_delete_connection_from_list)
+ self._btn_update_connection.clicked.connect(self._on_update_connection_from_list)
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
self._viewer_3d.connectorPickCancelled.connect(
- lambda: self._btn_add_connector.setChecked(False)
+ self._on_connector_pick_cancelled
)
self._viewer_3d.assemblyComponentActivated.connect(self._on_assembly_move_activated)
self._viewer_3d.assemblyComponentDragged.connect(self._on_assembly_move_dragged)
@@ -2558,6 +2652,11 @@ class MainWindow(QMainWindow):
self._current_component = self._project.components[comp_ids[idx]]
self._assembly_view_active = False
self._render_mode = "component"
+ # Leaving assembly view: instance modifiers are no longer the
+ # target of CAD operations.
+ self._instance_ac_id = None
+ self._current_sketch = None
+ self._selected_body = None
self._refresh_lists()
self._redraw_bodies()
# Propagate the new selection to the drawing tab.
@@ -2566,8 +2665,12 @@ class MainWindow(QMainWindow):
self._drawing_tab.set_active_component(self._current_component)
except Exception as e:
logger.warning(f"Failed to update drawing tab source: {e}")
- # Re-load the render tab to reflect the new selection.
- self._load_render_tab_shape()
+ # Re-load the render tab to reflect the new selection — only
+ # while the Render tab is visible. Tessellating the component
+ # and scheduling an auto-preview during normal CAD work
+ # freezes the UI (and kept a render thread alive to cancel).
+ if self._render_tab_is_current():
+ self._load_render_tab_shape()
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
@@ -2578,8 +2681,18 @@ class MainWindow(QMainWindow):
self._refresh_connection_list()
if self._current_component:
+ inst_ac = self._get_instance_context()
for sketch_id, sketch in self._current_component.sketches.items():
- self._sketch_list.addItem(sketch.name)
+ item = QListWidgetItem(sketch.name)
+ item.setData(Qt.UserRole, sketch_id)
+ self._sketch_list.addItem(item)
+ # Instance-local sketches (on the selected assembly instance)
+ # appear after the component's own, clearly marked.
+ if inst_ac is not None:
+ for sketch_id, sketch in inst_ac.sketches.items():
+ item = QListWidgetItem(f"{sketch.name} (inst)")
+ item.setData(Qt.UserRole, sketch_id)
+ self._sketch_list.addItem(item)
for body_id, body in self._current_component.bodies.items():
# QListWidgetItem with a data role so the toggle handler can
@@ -2587,6 +2700,10 @@ class MainWindow(QMainWindow):
display_name = body.name
if body.needs_update:
display_name = f"⚠ {body.name}"
+ if inst_ac is not None:
+ n_mods = len(inst_ac.modifiers.get(body_id) or [])
+ if n_mods:
+ display_name = f"{display_name} [+{n_mods} inst]"
item = QListWidgetItem(display_name)
item.setData(Qt.UserRole, body_id)
# Greying out a hidden body's name is a nice UX touch.
@@ -2666,14 +2783,25 @@ class MainWindow(QMainWindow):
self._btn_mirror_op.setEnabled(False)
return
features = _ensure_feature_history(body)
- for index, feat in enumerate(features):
- item = QListWidgetItem(self._describe_feature(feat, index))
+ # Instance context: inherited component ops are shown (all greyed,
+ # non-selectable — they belong to the shared model) followed by this
+ # instance's own modifiers, which are selectable/deletable.
+ inst_ac = self._body_instance_context(body)
+ mods = list(inst_ac.modifiers.get(body.id) or []) if inst_ac is not None else []
+ inherited_n = len(features) if inst_ac is not None else 0
+ entries = list(features) + list(mods)
+ for index, feat in enumerate(entries):
+ label = self._describe_feature(feat, index)
+ is_mod = index >= inherited_n and inst_ac is not None
+ if is_mod:
+ label += " (inst)"
+ item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, index)
- if index == 0:
+ if not is_mod:
item.setForeground(QColor("#6c7086"))
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
self._operations_list.addItem(item)
- self._operations_list.setCurrentRow(min(1, len(features) - 1))
+ self._operations_list.setCurrentRow(min(inherited_n, len(entries) - 1))
# setCurrentRow doesn't fire for non-selectable items (the base);
# ensure buttons reflect the empty selection in that edge case.
if self._operations_list.currentItem() is None:
@@ -2878,19 +3006,34 @@ class MainWindow(QMainWindow):
self._highlight_selected_body_light_blue()
return
index = item.data(Qt.ItemDataRole.UserRole)
- deletable = index is not None and index > 0
+ # Instance context: only the instance's own modifiers are
+ # deletable; mirror-op inserts mid-chain, which only the shared
+ # component history supports.
+ inst_ac = None
+ inherited_n = 0
+ if self._selected_body is not None:
+ inst_ac = self._body_instance_context(self._selected_body)
+ if inst_ac is not None:
+ inherited_n = len(_ensure_feature_history(self._selected_body))
+ if inst_ac is not None:
+ deletable = index is not None and index >= inherited_n
+ else:
+ deletable = index is not None and index > 0
self._btn_del_op.setEnabled(deletable)
- self._btn_mirror_op.setEnabled(index is not None and index >= 0)
- if index is not None and index > 0 and self._selected_body is not None:
+ self._btn_mirror_op.setEnabled(index is not None and index >= 0 and inst_ac is None)
+ if index is not None and deletable and self._selected_body is not None:
body = self._selected_body
features = _ensure_feature_history(body)
- if 0 <= index < len(features):
- feat = features[index]
+ mods = list(inst_ac.modifiers.get(body.id) or []) if inst_ac is not None else []
+ entries = list(features) + list(mods)
+ if 0 <= index < len(entries):
+ feat = entries[index]
try:
- shape = self._compute_operation_highlight_shape(
- body, features, index, feat
- )
+ shape = self._compute_operation_highlight_shape(body, entries, index, feat)
if shape is not None:
+ if inst_ac is not None:
+ # Local shape → world for the assembly scene.
+ shape = self._apply_transform(shape, inst_ac.position, inst_ac.rotation)
# Overlay the operation geometry on the still-visible
# body as a hot-pink flash. The overlay is
# non-selectable, so face/edge/vertex picking keeps
@@ -3020,6 +3163,45 @@ class MainWindow(QMainWindow):
return
index = item.data(Qt.ItemDataRole.UserRole)
features = _ensure_feature_history(body)
+ # Instance context: only the instance's own modifiers can be
+ # deleted; inherited component ops belong to the shared model.
+ inst_ac = self._body_instance_context(body)
+ if inst_ac is not None:
+ mods = list(inst_ac.modifiers.get(body.id) or [])
+ if not isinstance(index, int) or not (0 <= index < len(features) + len(mods)):
+ return
+ if index < len(features):
+ QMessageBox.warning(
+ self,
+ "Cannot Delete",
+ "Inherited component operations can't be deleted on an "
+ "instance — edit the component instead.",
+ )
+ return
+ feat = mods[index - len(features)]
+ answer = QMessageBox.question(
+ self,
+ "Delete Instance Operation",
+ f"Delete '{self._describe_feature(feat, index)}' from this instance?\n\n"
+ "The base component is not affected.",
+ )
+ if answer != QMessageBox.StandardButton.Yes:
+ return
+ orig = inst_ac.modifiers.get(body.id) or []
+ pos = index - len(features)
+ if 0 <= pos < len(orig):
+ del orig[pos]
+ if orig:
+ inst_ac.modifiers[body.id] = orig
+ else:
+ inst_ac.modifiers.pop(body.id, None)
+ inst_ac.invalidate_geom_cache(body.id)
+ self._mark_dirty()
+ self._update_assembly_component_in_viewer(inst_ac.id)
+ self._refresh_lists()
+ logger.info(f"Instance '{inst_ac.name}': deleted modifier at index {index}")
+ self._load_render_tab_shape()
+ return
if not isinstance(index, int) or not (0 <= index < len(features)):
return
if index == 0:
@@ -3117,6 +3299,14 @@ class MainWindow(QMainWindow):
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
+ # Component geometry changed: every instance of this component
+ # caches its rebuilt (live base + modifiers) geometry — drop
+ # those caches so assembly re-renders and instance-sketch
+ # underlays rebuild from the fresh base.
+ for assembly in self._project.assemblies.values():
+ for ac in assembly.components.values():
+ if ac.component_id == self._current_component.id:
+ ac.geom_cache.clear()
def _update_sketches_from_bodies(self) -> int:
"""Re-project underlay construction lines from updated 3D bodies.
@@ -3140,70 +3330,112 @@ class MainWindow(QMainWindow):
kernel = OCGeometryKernel()
updated = 0
for sketch in self._current_component.sketches.values():
- src_body_id = getattr(sketch, "_source_body_id", None)
- src_face = getattr(sketch, "_source_face", None)
- if src_body_id is None or src_face is None:
- continue
- if sketch.occ_sketch is None:
- continue
- body = self._current_component.bodies.get(src_body_id)
- if body is None or body.geometry is None:
- continue
- body_shape = kernel._get_shape(body.geometry)
- if body_shape is None:
- continue
- # Find the face on the updated body that matches the original
- # face's plane (normal parallel, origin coplanar).
- wp = sketch.occ_sketch.get_workplane()
- origin, normal = wp[0], wp[1]
- ref_center = getattr(sketch, "_source_face_center", None)
- match = OCGeometryKernel.find_coplanar_face(
- body_shape,
- origin,
- normal,
- ref_center=ref_center,
- )
- if match is None:
- logger.debug(
- "Sketch '%s': no matching face on body '%s', skipping",
- sketch.name,
- body.name,
- )
- continue
- new_face, new_center = match
- sketch._source_face = new_face
- sketch._source_face_center = new_center
- # Re-project the new face's edges into UV.
- from fluency.ui.sketch_widget import _project_face_to_uv
-
- try:
- polys = _project_face_to_uv(new_face, wp)
- except Exception as exc:
- logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
- continue
- if not polys:
- continue
- # Update external entities in-place (preserves ids + constraints).
- ok = sketch.occ_sketch.update_external_entities(polys)
- if ok:
+ if self._reproject_one_sketch_underlay(kernel, sketch):
updated += 1
- logger.info(
- "Re-projected underlay for sketch '%s' from body '%s'",
- sketch.name,
- body.name,
- )
- # If the sketch is currently loaded in the widget, refresh
- # the underlay data so the view reflects the new projection
- # WITHOUT re-importing (which would break constraints).
- if sketch.occ_sketch is self._sketch_widget._sketch:
- self._sketch_widget._source_face = new_face
- self._sketch_widget._source_underlay_uv = polys
- self._sketch_widget._rebuild_from_sketch()
- self._sketch_widget.update()
+ # Instance-local sketches on instances of this component: their
+ # underlays live against the LOCAL instance geometry (live base +
+ # modifiers), which was just rebuilt fresh above.
+ for assembly in self._project.assemblies.values():
+ for ac in assembly.components.values():
+ if ac.component_id != self._current_component.id:
+ continue
+ for sketch in list(ac.sketches.values()):
+ if self._reproject_one_sketch_underlay(kernel, sketch, instance=ac):
+ updated += 1
if updated > 0:
logger.info("Re-projected underlays for %d sketch(es)", updated)
return updated
+ def _reproject_one_sketch_underlay(
+ self, kernel: Any, sketch: Sketch, instance: Optional[Any] = None
+ ) -> bool:
+ """Re-project one face-sketch's underlay onto its (updated) body.
+
+ *instance* selects the LOCAL instance geometry instead of the
+ component body's geometry — used for instance-local sketches.
+ Returns True when the underlay actually moved.
+ """
+ src_body_id = getattr(sketch, "_source_body_id", None)
+ src_face = getattr(sketch, "_source_face", None)
+ if src_body_id is None or src_face is None or sketch.occ_sketch is None:
+ return False
+ body = self._current_component.bodies.get(src_body_id)
+ if body is None:
+ return False
+ if instance is not None:
+ geom = self._instance_body_geom(instance, body)
+ else:
+ geom = body.geometry
+ if geom is None:
+ return False
+ body_shape = kernel._get_shape(geom)
+ if body_shape is None:
+ return False
+ # Find the face on the updated body that matches the original
+ # face's plane (normal parallel, origin coplanar).
+ wp = sketch.occ_sketch.get_workplane()
+ origin, normal = wp[0], wp[1]
+ ref_center = getattr(sketch, "_source_face_center", None)
+ match = OCGeometryKernel.find_coplanar_face(
+ body_shape,
+ origin,
+ normal,
+ ref_center=ref_center,
+ )
+ if match is None:
+ logger.debug(
+ "Sketch '%s': no matching face on body '%s', skipping",
+ sketch.name,
+ body.name,
+ )
+ return False
+ new_face, new_center = match
+ sketch._source_face = new_face
+ sketch._source_face_center = new_center
+ # Re-project the new face's edges into UV.
+ from fluency.ui.sketch_widget import _project_face_to_uv
+
+ try:
+ polys = _project_face_to_uv(new_face, wp)
+ except Exception as exc:
+ logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
+ return False
+ if not polys:
+ return False
+ # Update external entities in-place (preserves ids + constraints).
+ # A failure here must not abort the whole update pipeline: the
+ # bodies are already rebuilt, so log and keep going.
+ try:
+ ok = sketch.occ_sketch.update_external_entities(polys)
+ except Exception as exc:
+ logger.warning(
+ "Underlay re-projection failed for sketch '%s': %s",
+ sketch.name,
+ exc,
+ )
+ return False
+ if ok:
+ logger.info(
+ "Re-projected underlay for sketch '%s' from body '%s'",
+ sketch.name,
+ body.name,
+ )
+ else:
+ logger.warning(
+ "Underlay re-projection solve failed for sketch '%s': %s",
+ sketch.name,
+ sketch.occ_sketch._last_solve_status,
+ )
+ # If the sketch is currently loaded in the widget, refresh
+ # the underlay data so the view reflects the new projection
+ # WITHOUT re-importing (which would break constraints).
+ if sketch.occ_sketch is self._sketch_widget._sketch:
+ self._sketch_widget._source_face = new_face
+ self._sketch_widget._source_underlay_uv = polys
+ self._sketch_widget._rebuild_from_sketch()
+ self._sketch_widget.update()
+ return ok
+
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
@@ -3217,98 +3449,621 @@ class MainWindow(QMainWindow):
for assembly in self._project.assemblies.values():
for ac_id, ac in assembly.components.items():
if ac.component_id == comp_id:
+ # Drop the per-instance geometry cache first: the base
+ # just changed, so any cached (base + modifiers) result
+ # is stale.
+ ac.geom_cache.clear()
self._update_assembly_component_in_viewer(ac_id)
def _recalculate_connectors(self):
- """Smart recalculation of connector positions after body update.
+ """Re-locate connectors after a body update and re-align their mates.
- For each assembly instance of the current component, probe the
- updated geometry near each connector's world position. If a
- matching snap candidate is found within tolerance, update the
- connector's local position and normal. Otherwise mark it invalid.
+ Every connector on every instance of the current component (across
+ all assemblies) is re-detected on the rebuilt geometry: a direct
+ 3-D search for the nearest feature of the same class on the source
+ body, so connectors follow moved features (e.g. a hole after its
+ sketch circle moved) regardless of view or zoom. Connections with
+ a moved endpoint are then re-solved so the mated parts stay
+ aligned with the new feature positions.
"""
- import numpy as np
-
if not self._current_component:
return
- comp_id = self._current_component.id
- TOLERANCE = 5.0 # mm — max distance to consider a match
- updated = 0
+ comp = self._current_component
+ moved_conns = 0
invalidated = 0
+ realign: List[Tuple[Any, Any]] = []
+ seen_conn_ids: set = set()
+ unresolved: List[Tuple[Any, Any, Any]] = []
for assembly in self._project.assemblies.values():
- for ac_id, ac in assembly.components.items():
- if ac.component_id != comp_id:
+ for ac in assembly.components.values():
+ if ac.component_id != comp.id:
continue
- for conn_id, conn in list(ac.connectors.items()):
- # Compute the connector's current world position.
- local_pos = np.array(conn.position, dtype=float)
- world_pos = ac.position + ac.rotation @ local_pos
-
- # Project to screen and probe nearby geometry.
- screen = self._viewer_3d._renderer._project_to_screen(tuple(world_pos))
- if screen is None:
- continue
-
- try:
- candidates = self._viewer_3d._renderer.probe_snap_candidates(
- screen[0],
- screen[1],
- radius=20,
- )
- except Exception:
- continue
-
- if not candidates:
- # No geometry nearby — connector may be orphaned.
+ for conn in list(ac.connectors.values()):
+ result = self._redetect_connector_on_geometry(conn, ac, comp)
+ if result is None:
if not conn.is_invalid:
conn.is_invalid = True
- invalidated += 1
+ conn.modified_at = datetime.now()
logger.info(
- f"Connector '{conn.name}' on {ac.name}: "
- f"no geometry nearby, marked invalid"
+ f"Connector '{conn.name}': no matching feature found, marked invalid"
)
- continue
-
- # Find the nearest candidate of matching type.
- best = None
- best_dist = float("inf")
- for cand in candidates:
- cand_pos = np.array(cand["position"], dtype=float)
- dist = float(np.linalg.norm(cand_pos - world_pos))
- if dist < best_dist:
- best_dist = dist
- best = cand
-
- if best is not None and best_dist <= TOLERANCE:
- # Update connector to new local coords.
- new_world = np.array(best["position"], dtype=float)
- new_local = ac.rotation.T @ (new_world - ac.position)
- conn.position = tuple(float(v) for v in new_local)
- if best.get("normal") is not None:
- new_normal = ac.rotation.T @ np.array(best["normal"], dtype=float)
- conn.normal = tuple(float(v) for v in new_normal)
- if best.get("x_dir") is not None:
- new_xdir = ac.rotation.T @ np.array(best["x_dir"], dtype=float)
- conn.x_dir = tuple(float(v) for v in new_xdir)
- conn.is_invalid = False
- conn.modified_at = datetime.now()
- updated += 1
- logger.info(
- f"Connector '{conn.name}' on {ac.name}: "
- f"updated ({best_dist:.1f}mm shift)"
- )
- elif not conn.is_invalid:
- conn.is_invalid = True
invalidated += 1
+ unresolved.append((assembly, ac, conn))
+ continue
+ dist, pos, normal, x_dir, second_dist = result
+ if not _relocate_confident(dist, second_dist):
+ # Candidate exists but is far away: the feature likely
+ # moved a lot (or this is a different feature) — let
+ # the user pick the new position instead of guessing.
logger.info(
- f"Connector '{conn.name}' on {ac.name}: "
- f"no close match ({best_dist:.1f}mm), marked invalid"
+ f"Connector '{conn.name}': candidate {dist:.1f}mm away (second: {second_dist}), needs manual pick"
)
+ unresolved.append((assembly, ac, conn))
+ continue
+ if self._apply_connector_relocation(conn, pos, normal, x_dir):
+ moved_conns += 1
+ # Queue the connection(s) this connector belongs to.
+ for aconn in assembly.connections:
+ if aconn.id in seen_conn_ids:
+ continue
+ if aconn.first_ac_id == ac.id or aconn.second_ac_id == ac.id:
+ seen_conn_ids.add(aconn.id)
+ realign.append((assembly, aconn))
- if updated or invalidated:
- logger.info(f"Connector recalc: {updated} updated, {invalidated} invalidated")
+ realigned = 0
+ for assembly, aconn in realign:
+ if self._realign_connection(assembly, aconn) is not None:
+ realigned += 1
+
+ if moved_conns or invalidated or realigned or unresolved:
+ # Full assembly redraw picks up both the new connector marker
+ # positions and the re-solved instance transforms.
+ if self._assembly_view_active:
+ self._show_assembly_in_viewer(fit=False)
+ self._mark_dirty()
+ logger.info(
+ f"Connector recalc: {moved_conns} moved, {invalidated} invalid, "
+ f"{len(unresolved)} need manual pick, {realigned} connections re-aligned"
+ )
+
+ if unresolved:
+ if self._assembly_view_active:
+ self._prompt_relocate_unresolved(unresolved)
+ else:
+ logger.info(
+ f"{len(unresolved)} connector(s) need a manual re-pick — "
+ "open the assembly view and press 'Upd' to fix"
+ )
+
+ def _apply_connector_relocation(
+ self,
+ conn: Any,
+ pos: Any,
+ normal: Any,
+ x_dir: Any,
+ ) -> bool:
+ """Write a re-located frame onto *conn*.
+
+ Returns True when the connector actually moved (or was re-validated
+ from an invalid state) — i.e. its mate needs re-solving.
+ """
+ import numpy as np
+
+ old_pos = np.asarray(conn.position, dtype=float)
+ conn.position = tuple(float(v) for v in pos)
+ conn.normal = tuple(float(v) for v in normal)
+ conn.x_dir = tuple(float(v) for v in x_dir)
+ was_invalid = conn.is_invalid
+ conn.is_invalid = False
+ conn.modified_at = datetime.now()
+ moved = was_invalid or float(np.linalg.norm(np.asarray(conn.position, dtype=float) - old_pos)) > 1e-6
+ if moved:
+ logger.info(
+ f"Connector '{conn.name}': re-located "
+ f"({float(np.linalg.norm(np.asarray(conn.position, dtype=float) - old_pos)):.2f}mm shift)"
+ )
+ return moved
+
+ # ── Manual re-pick fallback (feature moved too far / disappeared) ────
+
+ def _prompt_relocate_unresolved(self, unresolved: List[Tuple[Any, Any, Any]]) -> None:
+ """Offer to re-pick the position of connectors that could not be
+ re-located automatically.
+
+ *unresolved* is a list of ``(assembly, ac, conn)`` triples. On
+ accept the 3D viewer enters connector pick mode and the first entry
+ is framed for clicking.
+ """
+ shown = ", ".join(f"'{c.name}' ({ac.name})" for (_asm, ac, c) in unresolved[:4])
+ if len(unresolved) > 4:
+ shown += f" … and {len(unresolved) - 4} more"
+ reply = QMessageBox.question(
+ self,
+ "Connector Position Needed",
+ f"The source feature could not be located automatically for:\n{shown}\n\n"
+ "Pick the new position on the part in the 3D view?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+ self._relocate_pending = list(unresolved)
+ self._start_relocate_pick_next()
+
+ def _start_relocate_pick_next(self) -> None:
+ """Start picking for the next pending connector (frames its part)."""
+ if not self._relocate_pending:
+ self._finish_relocate_pick()
+ return
+ _assembly, ac, conn = self._relocate_pending[0]
+ self._frame_instance_for_pick(ac)
+ self._viewer_3d.set_connector_pick_mode(True)
+ self._viewer_3d.setFocus()
+ self._viewer_3d.activateWindow()
+ self.setStatusTip(
+ f"Click the new position of '{conn.name}' on {ac.name} (Esc cancels)"
+ )
+
+ def _frame_instance_for_pick(self, ac: Any) -> None:
+ """Zoom the camera to one assembly instance's full bounds."""
+ try:
+ import numpy as np
+
+ from OCP.Bnd import Bnd_Box
+ from OCP.BRepBndLib import BRepBndLib
+ from OCP.gp import gp_Pnt
+
+ comp = self._project.get_component_by_id(ac.component_id)
+ if comp is None:
+ return
+ corners: List[Any] = []
+ for body in comp.bodies.values():
+ geom = self._instance_body_geom(ac, body)
+ if geom is None:
+ continue
+ shape = self._kernel._get_shape(geom)
+ if shape is None:
+ continue
+ b = Bnd_Box()
+ BRepBndLib.Add_s(shape, b)
+ if b.IsVoid():
+ continue
+ x0, y0, z0, x1, y1, z1 = b.Get()
+ for x in (x0, x1):
+ for y in (y0, y1):
+ for z in (z0, z1):
+ corners.append(np.array([x, y, z], dtype=float))
+ if not corners:
+ return
+ rot = np.asarray(ac.rotation, dtype=float)
+ pos = np.asarray(ac.position, dtype=float)
+ world = [rot @ c + pos for c in corners]
+ lo = np.minimum.reduce(world)
+ hi = np.maximum.reduce(world)
+ wbox = Bnd_Box()
+ wbox.Set(gp_Pnt(float(lo[0]), float(lo[1]), float(lo[2])))
+ wbox.Add(gp_Pnt(float(hi[0]), float(hi[1]), float(hi[2])))
+ self._viewer_3d.fit_camera_to_box(wbox, padding=0.1)
+ except Exception as exc:
+ logger.debug(f"Failed to frame instance for pick: {exc}")
+
+ def _on_relocate_picked(self, origin, normal, x_dir, entity_type, owner_obj_id) -> None:
+ """A click during connector relocation: re-home the pending connector.
+
+ The picked feature (world coords) is converted to the instance's
+ component-local frame, written onto the connector, and the mated
+ connection is re-solved. Then the next pending connector (if any)
+ is framed for picking.
+ """
+ import numpy as np
+
+ if not self._relocate_pending:
+ return
+ assembly, ac, conn = self._relocate_pending[0]
+
+ ac_id = self._parse_ac_id(owner_obj_id)
+ if ac_id != ac.id:
+ QMessageBox.warning(
+ self,
+ "Wrong Part",
+ f"Click on '{ac.name}' — the part carrying connector '{conn.name}'.",
+ )
+ return
+
+ pos_world = np.asarray(origin, dtype=float)
+ rot = np.asarray(ac.rotation, dtype=float)
+ pos_local = rot.T @ (pos_world - np.asarray(ac.position, dtype=float))
+ n_world = np.asarray(normal, dtype=float)
+ n_local = rot.T @ n_world
+ n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
+ x_world = (
+ np.asarray(x_dir, dtype=float)
+ if x_dir
+ else np.array([1.0, 0.0, 0.0])
+ )
+ x_local = rot.T @ x_world
+ x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
+
+ conn.position = tuple(float(v) for v in pos_local)
+ conn.normal = tuple(float(v) for v in n_local)
+ conn.x_dir = tuple(float(v) for v in x_local)
+ if entity_type:
+ conn.entity_type = entity_type
+ if owner_obj_id:
+ conn.source_obj_id = owner_obj_id
+ conn.is_invalid = False
+ conn.modified_at = datetime.now()
+ logger.info(
+ f"Connector '{conn.name}': manually re-located to {tuple(pos_local)} ({entity_type})"
+ )
+
+ self._viewer_3d.show_persistent_connector_gizmo(
+ origin, normal, x_dir, entity_type, color=(0.1, 0.9, 0.1)
+ )
+
+ # Re-solve the connection this connector is mated to.
+ partner = conn.partner_ac_id
+ if partner:
+ for aconn in assembly.connections:
+ if (
+ aconn.first_ac_id == ac.id and aconn.second_ac_id == partner
+ ) or (
+ aconn.second_ac_id == ac.id and aconn.first_ac_id == partner
+ ):
+ self._realign_connection(assembly, aconn)
+ break
+
+ self._relocate_pending.pop(0)
+ if self._relocate_pending:
+ self._start_relocate_pick_next()
+ else:
+ self._finish_relocate_pick()
+
+ def _finish_relocate_pick(self) -> None:
+ """Leave relocate pick mode and refresh the assembly view."""
+ self._relocate_pending = None
+ self._viewer_3d.set_connector_pick_mode(False)
+ self._viewer_3d.clear_persistent_connector_gizmo()
+ self._btn_add_connector.setChecked(False)
+ self.setStatusTip("")
+ self._mark_dirty()
+ self._refresh_connection_list()
+ if self._assembly_view_active:
+ self._show_assembly_in_viewer(fit=False)
+
+ def _redetect_connector_on_geometry(
+ self, conn: Any, ac: Any, comp: Any
+ ) -> Optional[Tuple[float, Any, Any, Any, Optional[float]]]:
+ """Find the new position of *conn* on *ac*'s current local geometry.
+
+ The connector's source body (parsed from ``source_obj_id``) is
+ resolved to the instance's rebuilt shape — live component features
+ plus the instance's own modifiers, in component-local coords — and
+ the nearest feature of the connector's entity class is searched
+ around the connector's stored position.
+
+ Pure: never mutates *conn*. Returns
+ ``(distance, position, normal, x_dir, second_distance)`` of the
+ best candidate (``second_distance`` is the next-best candidate's
+ distance, or ``None`` when it is the only candidate), or ``None``
+ when no candidate exists. Callers apply the result only when
+ :func:`_relocate_confident` accepts the candidate and mark the
+ connector invalid on ``None``.
+
+ This is a direct BREP search (not a screen-space probe): it is
+ zoom-independent, works in any view, and only ever matches
+ features of the same class on the source body, so a hole
+ connector follows its hole even after large sketch moves.
+ """
+ import math
+ import numpy as np
+
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import (
+ TopAbs_FACE,
+ TopAbs_EDGE,
+ TopAbs_VERTEX,
+ TopAbs_REVERSED,
+ )
+ from OCP.TopoDS import TopoDS
+ from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
+ from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder
+ from OCP.Bnd import Bnd_Box
+ from OCP.BRepBndLib import BRepBndLib
+ from OCP.BRepExtrema import BRepExtrema_DistShapeShape
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
+ from OCP.BRep import BRep_Tool
+ from OCP.gp import gp_Pnt, gp_Vec
+
+ if conn is None or ac is None or comp is None:
+ return None
+
+ # ── Resolve the source body's instance-local shape ─────────────
+ body_id = ""
+ prefix = f"asm_{ac.id}_"
+ if conn.source_obj_id and conn.source_obj_id.startswith(prefix):
+ body_id = conn.source_obj_id[len(prefix):]
+ geoms: List[Any] = []
+ if body_id:
+ body = comp.bodies.get(body_id)
+ if body is not None:
+ geom = self._instance_body_geom(ac, body)
+ if geom is not None:
+ geoms.append(geom)
+ if not geoms:
+ # Unknown / removed source body — search every body instead.
+ for body in comp.bodies.values():
+ geom = self._instance_body_geom(ac, body)
+ if geom is not None:
+ geoms.append(geom)
+ # Unwrap OCCGeometryObject wrappers to bare TopoDS_Shape — the OCP
+ # API calls below (BRepBndLib, TopExp, …) only accept raw shapes.
+ shapes = [s for s in (self._kernel._get_shape(g) for g in geoms) if s is not None]
+ if not shapes:
+ return None
+
+ et = (conn.entity_type or "").strip()
+ if not et:
+ # Legacy connector without a stored class: recover it from the
+ # auto-generated name ("Conn cylindrical_face anchor").
+ for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
+ if conn.name in (f"Conn {t} anchor", f"Conn {t} mover"):
+ et = t
+ break
+ want_cyl = et in ("cylindrical_face", "")
+ want_plane = et in ("planar_face", "")
+ want_edge = et in ("edge", "")
+ want_vert = et in ("vertex", "")
+
+ old_pos = np.asarray(conn.position, dtype=float)
+ old_n = np.asarray(conn.normal, dtype=float)
+ old_n = old_n / max(np.linalg.norm(old_n), 1e-12)
+ old_x = np.asarray(conn.x_dir, dtype=float)
+ old_x = old_x / max(np.linalg.norm(old_x), 1e-12)
+
+ ref_vert = BRepBuilderAPI_MakeVertex(
+ gp_Pnt(float(old_pos[0]), float(old_pos[1]), float(old_pos[2]))
+ ).Vertex()
+
+ cands: List[Tuple[float, np.ndarray, Optional[np.ndarray]]] = []
+ cap = 0.0 # per-shape acceptance radius, set below
+
+ def consider(dist: float, pos: np.ndarray, normal: Optional[np.ndarray]) -> None:
+ if dist > cap:
+ return
+ cands.append((dist, pos, normal))
+
+ for shape in shapes:
+ bbox = Bnd_Box()
+ BRepBndLib.Add_s(shape, bbox)
+ if bbox.IsVoid():
+ continue
+ x0, y0, z0, x1, y1, z1 = bbox.Get()
+ diag = math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2)
+ # Features farther away than this are more likely a *different*
+ # feature than the moved source one.
+ cap = max(15.0, diag)
+
+ # ── Cylindrical faces (holes / bosses) ─────────────────
+ if want_cyl:
+ expl = TopExp_Explorer(shape, TopAbs_FACE)
+ while expl.More():
+ face = TopoDS.Face_s(expl.Current())
+ try:
+ adaptor = BRepAdaptor_Surface(face)
+ if adaptor.GetType() == GeomAbs_Cylinder:
+ cyl = adaptor.Cylinder()
+ loc = cyl.Location()
+ d = cyl.Axis().Direction()
+ axis_pt = np.array([loc.X(), loc.Y(), loc.Z()])
+ axis_dir = np.array([d.X(), d.Y(), d.Z()])
+ axis_dir = axis_dir / max(np.linalg.norm(axis_dir), 1e-12)
+ t = float(np.dot(old_pos - axis_pt, axis_dir))
+ pos = axis_pt + t * axis_dir
+ consider(
+ float(np.linalg.norm(old_pos - pos)), pos, axis_dir
+ )
+ except Exception:
+ pass
+ finally:
+ # A failed/short-circuited face must still advance
+ # the explorer, or the loop spins forever.
+ expl.Next()
+
+ # ── Planar faces ─────────────────────────────────────
+ if want_plane:
+ expl = TopExp_Explorer(shape, TopAbs_FACE)
+ while expl.More():
+ face = TopoDS.Face_s(expl.Current())
+ try:
+ adaptor = BRepAdaptor_Surface(face)
+ if adaptor.GetType() == GeomAbs_Plane:
+ n = adaptor.Plane().Axis().Direction()
+ if face.Orientation() == TopAbs_REVERSED:
+ n = n.Reversed()
+ # True closest point on the TRIMMED face.
+ dist = BRepExtrema_DistShapeShape(face, ref_vert)
+ if dist.IsDone() and dist.NbSolution() >= 1:
+ p = dist.PointOnShape1(1)
+ pos = np.array([p.X(), p.Y(), p.Z()])
+ normal = np.array([n.X(), n.Y(), n.Z()])
+ normal = normal / max(np.linalg.norm(normal), 1e-12)
+ consider(float(dist.Value()), pos, normal)
+ except Exception:
+ pass
+ finally:
+ expl.Next()
+
+ # ── Edges ──────────────────────────────────────────────────
+ if want_edge:
+ expl = TopExp_Explorer(shape, TopAbs_EDGE)
+ while expl.More():
+ edge = TopoDS.Edge_s(expl.Current())
+ try:
+ curve = BRepAdaptor_Curve(edge)
+ u0, u1 = curve.FirstParameter(), curve.LastParameter()
+ bd: Optional[float] = None
+ bp: Optional[np.ndarray] = None
+ bt: Optional[np.ndarray] = None
+ pt = gp_Pnt()
+ tv = gp_Vec()
+ for i in range(33):
+ u = u0 + (u1 - u0) * i / 32
+ curve.D1(u, pt, tv)
+ p = np.array([pt.X(), pt.Y(), pt.Z()])
+ d2 = float(np.linalg.norm(p - old_pos))
+ if bd is None or d2 < bd:
+ bd = d2
+ bp = p
+ bt = np.array([tv.X(), tv.Y(), tv.Z()])
+ if bd is not None and bp is not None:
+ tn = float(np.linalg.norm(bt)) if bt is not None else 0.0
+ tangent = bt / tn if tn > 1e-12 else None
+ consider(bd, bp, tangent)
+ except Exception:
+ pass
+ finally:
+ expl.Next()
+
+ # ── Vertices ───────────────────────────────────────────────
+ if want_vert:
+ expl = TopExp_Explorer(shape, TopAbs_VERTEX)
+ while expl.More():
+ v = TopoDS.Vertex_s(expl.Current())
+ try:
+ p = BRep_Tool.Pnt_s(v)
+ pos = np.array([p.X(), p.Y(), p.Z()])
+ consider(float(np.linalg.norm(pos - old_pos)), pos, None)
+ except Exception:
+ pass
+ finally:
+ expl.Next()
+
+ if not cands:
+ return None
+ cands.sort(key=lambda c: c[0])
+ dist, new_pos, feat_n = cands[0]
+ second_dist = cands[1][0] if len(cands) > 1 else None
+ new_n = old_n if feat_n is None else feat_n / max(np.linalg.norm(feat_n), 1e-12)
+ # Keep the frame pointing the same way as before.
+ if float(np.dot(new_n, old_n)) < 0.0:
+ new_n = -new_n
+ # Re-orthogonalize the stored x direction against the new normal.
+ new_x = old_x - float(np.dot(old_x, new_n)) * new_n
+ if np.linalg.norm(new_x) < 1e-6:
+ ref = (
+ np.array([1.0, 0.0, 0.0])
+ if abs(new_n[0]) < 0.9
+ else np.array([0.0, 1.0, 0.0])
+ )
+ new_x = ref - float(np.dot(ref, new_n)) * new_n
+ new_x = new_x / max(np.linalg.norm(new_x), 1e-12)
+
+ return (
+ float(dist),
+ tuple(float(v) for v in new_pos),
+ tuple(float(v) for v in new_n),
+ tuple(float(v) for v in new_x),
+ second_dist,
+ )
+
+ def _realign_connection(self, assembly: Any, aconn: Any) -> Optional[str]:
+ """Re-solve a mated pair so both parts follow their (moved) connectors.
+
+ Rebuilds the alignment solve from the connectors' *current* local
+ frames (grounded side = ``first_ac_id``), then re-applies the
+ placement dialog's axis rotation and normal offset so the original
+ mate pose is reproduced on the new geometry. Returns the id of the
+ moved assembly component, or None when nothing changed / no solve.
+ """
+ import numpy as np
+
+ first_ac = assembly.components.get(aconn.first_ac_id)
+ second_ac = assembly.components.get(aconn.second_ac_id)
+ if first_ac is None or second_ac is None:
+ return None
+
+ # Mated connector pair — prefer the recorded ids, fall back to the
+ # partner cross-links (older connections have no recorded ids).
+ first_conn = (
+ first_ac.connectors.get(aconn.first_connector_id)
+ if aconn.first_connector_id
+ else None
+ )
+ if first_conn is None:
+ for c in first_ac.connectors.values():
+ if c.partner_ac_id == second_ac.id:
+ first_conn = c
+ break
+ second_conn = (
+ second_ac.connectors.get(aconn.second_connector_id)
+ if aconn.second_connector_id
+ else None
+ )
+ if second_conn is None:
+ for c in second_ac.connectors.values():
+ if c.partner_ac_id == first_ac.id:
+ second_conn = c
+ break
+ if first_conn is None or second_conn is None:
+ return None
+ if first_conn.is_invalid or second_conn.is_invalid:
+ return None
+
+ first_pick = {
+ "origin_world": first_ac.position
+ + first_ac.rotation @ np.asarray(first_conn.position, dtype=float),
+ "normal_world": first_ac.rotation @ np.asarray(first_conn.normal, dtype=float),
+ }
+ second_pick = {
+ "origin_local": np.asarray(second_conn.position, dtype=float),
+ "normal_local": np.asarray(second_conn.normal, dtype=float),
+ }
+
+ solved = self._solve_assembly_alignment(
+ first_ac,
+ second_ac,
+ first_pick,
+ second_pick,
+ anchor_component_id=first_ac.id,
+ )
+ if solved is None:
+ return None
+
+ moved_ac = assembly.components.get(solved["moved_ac_id"])
+ if moved_ac is None:
+ return None
+
+ old_pos = np.asarray(moved_ac.position, dtype=float)
+ moved_ac.position = solved["position"]
+ moved_ac.rotation = solved["rotation"]
+
+ # Re-apply the placement dialog's adjustments (same as the
+ # placement flow): twist around the grounded normal + gap along it.
+ n = np.asarray(first_pick["normal_world"], dtype=float)
+ n = n / max(np.linalg.norm(n), 1e-12)
+ K = np.array(
+ [
+ [0, -n[2], n[1]],
+ [n[2], 0, -n[0]],
+ [-n[1], n[0], 0],
+ ]
+ )
+ angle = np.radians(float(first_conn.axis_rotation))
+ R_axis = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
+ moved_ac.rotation = R_axis @ moved_ac.rotation
+ flip_sign = -1.0 if first_conn.normal_flip else 1.0
+ moved_ac.position = moved_ac.position + flip_sign * n * float(first_conn.offset)
+
+ if not np.allclose(moved_ac.position, old_pos, atol=1e-9):
+ logger.info(
+ f"Connection {aconn.id}: re-aligned '{moved_ac.name}' "
+ f"after connector move"
+ )
+ return moved_ac.id
+ return None
def _redraw_bodies(self):
self._viewer_3d.clear_scene()
@@ -3526,12 +4281,19 @@ class MainWindow(QMainWindow):
self._selected_assembly_component_id = None
self._assembly_view_active = False
# Fall back to normal component view.
+ self._instance_ac_id = None
+ self._refresh_lists()
self._redraw_bodies()
return
logger.info(f"Removed assembly component instance {active_id}")
self._mark_dirty()
self._show_assembly_in_viewer(fit=True)
+ # The removed instance was the active one: keep the context in
+ # sync with the newly selected first button.
+ if self._instance_ac_id == active_id:
+ self._instance_ac_id = first_id
+ self._refresh_lists()
def _get_active_assembly_component_id(self) -> Optional[str]:
"""Get the assembly component id of the currently checked button."""
@@ -3555,10 +4317,29 @@ class MainWindow(QMainWindow):
self._assembly_view_active = True
self._render_mode = "assembly"
+ # Instance-modifier context: CAD ops now target this instance's
+ # per-instantiation modifiers instead of the shared component.
+ self._instance_ac_id = active_id
+ # Sync the left-hand lists to the instance's component so sketch /
+ # body rows match what is on screen (the instance may belong to a
+ # different component than the last clicked component button).
+ asm = self._get_assembly()
+ ac = asm.components.get(active_id) if asm is not None else None
+ if ac is not None:
+ inst_comp = self._project.get_component_by_id(ac.component_id)
+ if inst_comp is not None:
+ self._current_component = inst_comp
+ self._current_sketch = None
+ self._selected_body = None
+ self._refresh_lists()
+
self._show_assembly_in_viewer(fit=True)
- # Re-load the render tab to show the full assembly.
- self._load_render_tab_shape()
+ # Re-load the render tab to show the full assembly — only while
+ # the Render tab is visible (same reasoning as in
+ # _on_component_button_clicked).
+ if self._render_tab_is_current():
+ self._load_render_tab_shape()
# Scroll to the selected button.
for btn in self._assembly_component_buttons:
@@ -3668,7 +4449,16 @@ class MainWindow(QMainWindow):
for body_id, body in comp.bodies.items():
if body.geometry:
try:
- shape = self._kernel._get_shape(body.geometry)
+ # Instances with modifiers render their rebuilt
+ # (live base + modifiers) geometry; plain instances
+ # render the component body as-is.
+ if ac.modifiers.get(body_id):
+ geom = self._instance_body_geom(ac, body)
+ if geom is None:
+ continue
+ else:
+ geom = body.geometry
+ shape = self._kernel._get_shape(geom)
# Apply component instance transform.
transformed = self._apply_transform(shape, ac.position, ac.rotation)
obj_id = f"asm_{ac_id}_{body_id}"
@@ -3676,6 +4466,7 @@ class MainWindow(QMainWindow):
transformed,
color=color,
name=obj_id,
+ auto_fit=False,
)
render_ids.append(obj_id)
shown_any = True
@@ -3698,10 +4489,10 @@ class MainWindow(QMainWindow):
sphere_shape,
color=conn_color,
name=f"conn_{ac_id}_{conn_id}",
+ auto_fit=False,
)
except Exception as exc:
- logger.debug(f"Failed to show connector {conn_id}: {exc}")
-
+ logger.debug(f"Failed to show connector {conn_id} in assembly: {exc}")
if shown_any and fit:
self._viewer_3d.fit_camera()
@@ -3745,13 +4536,23 @@ class MainWindow(QMainWindow):
for body_id, body in comp.bodies.items():
if body.geometry:
try:
- shape = self._kernel._get_shape(body.geometry)
+ # Instances with modifiers render their rebuilt
+ # (live base + modifiers) geometry; plain instances
+ # render the component body as-is.
+ if ac.modifiers.get(body_id):
+ geom = self._instance_body_geom(ac, body)
+ if geom is None:
+ continue
+ else:
+ geom = body.geometry
+ shape = self._kernel._get_shape(geom)
transformed = self._apply_transform(shape, ac.position, ac.rotation)
obj_id = f"asm_{ac_id}_{body_id}"
self._viewer_3d.show_shape(
transformed,
color=color,
name=obj_id,
+ auto_fit=False,
)
new_ids.append(obj_id)
except Exception as exc:
@@ -3771,6 +4572,7 @@ class MainWindow(QMainWindow):
sphere_shape,
color=conn_color,
name=f"conn_{ac_id}_{conn_id}",
+ auto_fit=False,
)
new_ids.append(f"conn_{ac_id}_{conn_id}")
except Exception as exc:
@@ -3903,6 +4705,199 @@ class MainWindow(QMainWindow):
return parts[1]
return owner_obj_id[4:]
+ # ────────────────────────────────────────────────────────────────────
+ # Instance modifiers — per-instantiation ops on top of the live base
+ # ────────────────────────────────────────────────────────────────────
+
+ def _get_instance_context(self) -> Optional[Any]:
+ """Return the selected AssemblyComponent while instance ops are active."""
+ if self._instance_ac_id is None:
+ return None
+ assembly = self._get_assembly()
+ if assembly is None:
+ return None
+ ac = assembly.components.get(self._instance_ac_id)
+ if ac is None:
+ return None
+ return ac
+
+ def _owner_instance(self, owner_obj_id: Optional[str]) -> Optional[Tuple[Any, Body]]:
+ """Resolve an ``asm_{ac_id}_{body_id}`` owner id to (ac, body).
+
+ Only resolves owners belonging to the currently selected instance;
+ anything else (component view, other instances) returns *None*.
+ """
+ ac = self._get_instance_context()
+ if ac is None or not owner_obj_id:
+ return None
+ ac_id = self._parse_ac_id(owner_obj_id)
+ if ac_id != ac.id:
+ return None
+ comp = self._project.get_component_by_id(ac.component_id)
+ if comp is None:
+ return None
+ body_id = owner_obj_id[len(f"asm_{ac_id}_"):]
+ body = comp.bodies.get(body_id)
+ if body is None:
+ return None
+ return (ac, body)
+
+ def _instance_body_geom(self, ac: Any, body: Body) -> Optional[Any]:
+ """Rebuilt geometry of *body* inside instance *ac*.
+
+ One replay of the LIVE component feature history followed by the
+ instance's own modifiers — so component edits always flow into the
+ instance and only per-instantiation work lives on top. The shared
+ component is never mutated. Results are cached on the instance
+ until the base or the modifiers change.
+ """
+ cached = ac.geom_cache.get(body.id)
+ if cached is not None:
+ return cached
+
+ comp = self._project.get_component_by_id(ac.component_id)
+ features = _ensure_feature_history(body)
+ mods = ac.modifiers.get(body.id) or []
+
+ geom: Optional[Any] = None
+ if mods:
+ try:
+ geom = _replay_body_features(
+ self._kernel, body, list(features) + list(mods),
+ self._through_all_length_for_geometry, component=comp,
+ )
+ except Exception:
+ logger.exception(f"Instance modifier replay failed for '{body.name}'")
+ geom = None
+ if geom is None:
+ try:
+ geom = _replay_body_features(
+ self._kernel, body, features,
+ self._through_all_length_for_geometry, component=comp,
+ )
+ except Exception:
+ geom = None
+ if geom is None:
+ geom = body.geometry
+
+ if geom is not None:
+ ac.geom_cache[body.id] = geom
+ return geom
+
+ def _plane_to_local(self, ac: Any, origin, normal, x_dir):
+ """Map a world-space plane (origin, normal, x_dir) into *ac*'s local coords."""
+ import numpy as np
+
+ pos = np.asarray(ac.position, dtype=float)
+ rotT = np.asarray(ac.rotation, dtype=float).T
+ o = rotT @ (np.asarray(origin, dtype=float) - pos)
+ n = rotT @ np.asarray(normal, dtype=float)
+ x = rotT @ np.asarray(x_dir, dtype=float)
+ return (
+ tuple(float(v) for v in o),
+ tuple(float(v) for v in n),
+ tuple(float(v) for v in x),
+ )
+
+ def _shape_to_local(self, ac: Any, shape: Any) -> Any:
+ """Transform a world-space shape into *ac*'s component-local space."""
+ import numpy as np
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+ from OCP.gp import gp_Trsf
+
+ pos = np.asarray(ac.position, dtype=float)
+ rotT = np.asarray(ac.rotation, dtype=float).T
+ t = rotT @ (-pos)
+ trsf = gp_Trsf()
+ trsf.SetValues(
+ float(rotT[0, 0]), float(rotT[0, 1]), float(rotT[0, 2]), float(t[0]),
+ float(rotT[1, 0]), float(rotT[1, 1]), float(rotT[1, 2]), float(t[1]),
+ float(rotT[2, 0]), float(rotT[2, 1]), float(rotT[2, 2]), float(t[2]),
+ )
+ transformer = BRepBuilderAPI_Transform(shape, trsf, False)
+ transformer.Build()
+ return transformer.Shape()
+
+ def _instance_sketch_context(self, sketch: Sketch) -> Optional[Tuple[Any, Body]]:
+ """If *sketch* is an instance-local sketch, return (ac, target body)."""
+ ac = self._get_instance_context()
+ if ac is None:
+ return None
+ if not any(s is sketch for s in ac.sketches.values()):
+ return None
+ comp = self._project.get_component_by_id(ac.component_id)
+ if comp is None:
+ return None
+ src_id = getattr(sketch, "_source_body_id", None)
+ body = comp.bodies.get(src_id) if src_id else None
+ if body is None and len(comp.bodies) == 1:
+ body = next(iter(comp.bodies.values()))
+ return (ac, body) if body is not None else None
+
+ def _apply_instance_modifier(self, ac: Any, body: Body, feat: Feature) -> None:
+ """Append a modifier to *body* on *ac* and refresh the instance view."""
+ ac.add_modifier(body.id, feat)
+ self._mark_dirty()
+ self._update_assembly_component_in_viewer(ac.id)
+ self._refresh_lists()
+ # Keep the render tab in sync (no-op unless it is the visible tab).
+ self._load_render_tab_shape()
+
+ def _compute_instance_extrude_result(
+ self,
+ ac: Any,
+ body: Body,
+ sketch: Sketch,
+ face_geom: Any,
+ length: float,
+ symmetric: bool,
+ invert: bool,
+ cut: bool,
+ union: bool,
+ through_all: bool,
+ ) -> Optional[Dict[str, Any]]:
+ """Instance-space result of an extrude-family op applied as a modifier.
+
+ Plain extrudes act as a UNION on the instance (they add material —
+ an instance can't gain a new body). Returns local-space geometry;
+ the caller applies the instance transform for display.
+ """
+ base_geom = self._instance_body_geom(ac, body)
+ if base_geom is None or face_geom is None:
+ return None
+ if through_all:
+ try:
+ extrude_length = self._through_all_length_for_geometry(base_geom, sketch)
+ except Exception:
+ return None
+ symmetric = True
+ invert = False
+ else:
+ if cut:
+ # The picked face's outward normal points AWAY from the
+ # solid — force the tool into the body, same as component cut.
+ invert = True
+ extrude_length = -length if invert else length
+ try:
+ tool_geom = self._kernel.extrude(face_geom, extrude_length, symmetric=symmetric)
+ except Exception:
+ return None
+ if tool_geom is None:
+ return None
+ try:
+ if cut:
+ result_geom = self._kernel.boolean_difference(base_geom, tool_geom)
+ else:
+ result_geom = self._kernel.boolean_union(base_geom, tool_geom)
+ except Exception:
+ return None
+ if result_geom is None:
+ return None
+ return {
+ "result_geom": result_geom,
+ "result_shape": self._kernel._get_shape(result_geom),
+ }
+
def _on_start_connector_placement(self, checked: bool):
"""Toggle connector pick mode.
@@ -3963,6 +4958,24 @@ class MainWindow(QMainWindow):
else:
self.statusBar().showMessage(f"Snap target: {name}{comp_name} — click to pick")
+ def _on_connector_pick_cancelled(self):
+ """Reset connector pick state and clear persistent gizmo."""
+ if self._relocate_pending is not None:
+ self._relocate_pending = None
+ self._mark_dirty()
+ self._refresh_connection_list()
+ if self._assembly_view_active:
+ self._show_assembly_in_viewer(fit=False)
+ self._btn_add_connector.setChecked(False)
+ # The viewer usually clears its own pick mode before emitting the
+ # cancel signal; make it explicit so the handler is self-sufficient.
+ self._viewer_3d.set_connector_pick_mode(False)
+ self._viewer_3d.clear_persistent_connector_gizmo()
+ self._viewer_3d.clear_face_highlight()
+ self._connector_first_pick = None
+ self._connector_second_ac_id = None
+ self.setStatusTip("")
+
def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
"""Handle a connector entity pick — first or second click.
@@ -3970,6 +4983,9 @@ class MainWindow(QMainWindow):
Stores connector in component-local coordinates so it stays
valid when the component is moved by the solver.
"""
+ if self._relocate_pending is not None:
+ self._on_relocate_picked(origin, normal, x_dir, entity_type, owner_obj_id)
+ return
import numpy as np
ac_id = self._parse_ac_id(owner_obj_id)
@@ -4016,6 +5032,10 @@ class MainWindow(QMainWindow):
# Highlight the first face if planar.
if entity_type in ("planar_face", "cylindrical_face"):
self._viewer_3d.highlight_face(raw_shape)
+ # Show static green gizmo so user sees first point is accepted
+ self._viewer_3d.show_persistent_connector_gizmo(
+ origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)
+ )
self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
return
@@ -4061,10 +5081,35 @@ class MainWindow(QMainWindow):
second_ac = ac
anchor_ac_id = next(iter(assembly.components.keys()))
+ # Determine which of the two picked components is the *fixed* side
+ # (already grounded in the rigid group containing the global anchor) and
+ # which is the *moving* side.
+ grounded_ids = set(assembly.get_rigid_group(anchor_ac_id))
+ a_grounded = first_ac.id in grounded_ids
+ b_grounded = second_ac.id in grounded_ids
+
+ if a_grounded and not b_grounded:
+ fixed_ac_id, moving_ac_id = first_ac.id, second_ac.id
+ fixed_pick, moving_pick = first, second_pick
+ elif b_grounded and not a_grounded:
+ fixed_ac_id, moving_ac_id = second_ac.id, first_ac.id
+ fixed_pick, moving_pick = second_pick, first
+ else:
+ # Fallback: insertion order (earlier-added = fixed).
+ order = list(assembly.components.keys())
+ a_idx = order.index(first_ac.id) if first_ac.id in order else 0
+ b_idx = order.index(second_ac.id) if second_ac.id in order else 1
+ if a_idx <= b_idx:
+ fixed_ac_id, moving_ac_id = first_ac.id, second_ac.id
+ fixed_pick, moving_pick = first, second_pick
+ else:
+ fixed_ac_id, moving_ac_id = second_ac.id, first_ac.id
+ fixed_pick, moving_pick = second_pick, first
+
# Compute the world target normal (from the anchor's connector).
- anchor_pick_source = first if anchor_ac_id == first["ac_id"] else second_pick
- target_pos = np.array(anchor_pick_source["origin_world"], dtype=float)
- target_normal = np.array(anchor_pick_source["normal_world"], dtype=float)
+ # World target = the fixed side's pick.
+ target_pos = np.array(fixed_pick["origin_world"], dtype=float)
+ target_normal = np.array(fixed_pick["normal_world"], dtype=float)
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
solved = self._solve_assembly_alignment(
@@ -4072,7 +5117,7 @@ class MainWindow(QMainWindow):
second_ac=second_ac,
first_pick=first,
second_pick=second_pick,
- anchor_component_id=anchor_ac_id,
+ anchor_component_id=fixed_ac_id,
)
if solved is None:
@@ -4096,6 +5141,41 @@ class MainWindow(QMainWindow):
auto_offset = 50.0
moved_ac.position = moved_ac.position + target_normal * auto_offset
+ # Frame the new connection point so the user can set the offset/
+ # rotation from a close-up view before confirming the dialog.
+ # FitAll(box) keeps the current view direction and zooms to the box.
+ try:
+ from OCP.Bnd import Bnd_Box
+ from OCP.gp import gp_Pnt
+
+ # Span the box across both mating points: the fixed side's
+ # pick and the moved side's connector after the solved
+ # transform (which includes the auto-offset, so both faces
+ # stay in view even when the parts are separated).
+ p1 = np.array(target_pos, dtype=float)
+ p2 = p1
+ if moved_ac is not None and moved_ac_id == moving_ac_id:
+ p2 = (
+ np.array(moved_ac.rotation, dtype=float)
+ @ np.array(moving_pick["origin_local"], dtype=float)
+ ) + np.array(moved_ac.position, dtype=float)
+ lo = np.minimum(p1, p2) - 15.0
+ hi = np.maximum(p1, p2) + 15.0
+ # Enforce a minimum span so a perfectly mated pair doesn't
+ # zoom in absurdly close.
+ min_half = np.full(3, 30.0)
+ c = (lo + hi) / 2.0
+ half = np.maximum((hi - lo) / 2.0, min_half)
+ lo = c - half
+ hi = c + half
+
+ box = Bnd_Box()
+ box.Set(gp_Pnt(float(lo[0]), float(lo[1]), float(lo[2])))
+ box.Add(gp_Pnt(float(hi[0]), float(hi[1]), float(hi[2])))
+ self._viewer_3d.fit_camera_to_box(box, padding=0.1)
+ except Exception as exc:
+ logger.debug("Failed to frame connection point: %s", exc)
+
# Show dialog with live preview (rotation offset along normal).
moved_comp_before_dialog = assembly.components.get(moved_ac_id)
rotation, offset, flip = self._show_connector_dialog_with_preview(
@@ -4105,6 +5185,7 @@ class MainWindow(QMainWindow):
second_pick=second_pick,
solved=solved,
mover_ac=moved_ac,
+ target_normal=target_normal,
)
if rotation is None:
@@ -4116,6 +5197,8 @@ class MainWindow(QMainWindow):
moved_comp_before_dialog.rotation = np.array(
solved["original_rotation"], dtype=float
)
+ # Clean up gizmo on cancel
+ self._viewer_3d.clear_persistent_connector_gizmo()
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
@@ -4139,10 +5222,10 @@ class MainWindow(QMainWindow):
moved_ac.position = moved_ac.position + flip_sign * target_normal * offset
# Determine which pick is the anchor and which is the mover.
- anchor_pick = first if anchor_ac_id == first["ac_id"] else second_pick
- mover_pick = second_pick if anchor_ac_id == first["ac_id"] else first
- anchor_comp = assembly.components.get(anchor_ac_id)
- mover_comp = assembly.components.get(mover_pick["ac_id"])
+ anchor_comp = assembly.components.get(fixed_ac_id)
+ mover_comp = assembly.components.get(moving_ac_id)
+ anchor_pick = fixed_pick
+ mover_pick = moving_pick
# Create connectors on both sides and link them as a mated pair.
conn_a = None
@@ -4154,6 +5237,8 @@ class MainWindow(QMainWindow):
x_dir=anchor_pick["x_dir_local"],
source_obj_id=anchor_pick["owner_obj_id"],
name=f"Conn {anchor_pick['entity_type']} anchor",
+ entity_type=anchor_pick["entity_type"],
+ normal_flip=flip,
)
conn_a.axis_rotation = rotation
conn_a.offset = offset
@@ -4166,6 +5251,8 @@ class MainWindow(QMainWindow):
x_dir=mover_pick["x_dir_local"],
source_obj_id=mover_pick["owner_obj_id"],
name=f"Conn {mover_pick['entity_type']} mover",
+ entity_type=mover_pick["entity_type"],
+ normal_flip=flip,
)
conn_m.axis_rotation = rotation
conn_m.offset = offset
@@ -4176,12 +5263,14 @@ class MainWindow(QMainWindow):
conn_a.partner_connector_id = conn_m.id
conn_m.partner_ac_id = anchor_comp.id if anchor_comp else ""
conn_m.partner_connector_id = conn_a.id
- assembly.add_connection(anchor_ac_id, moved_ac_id)
+ assembly.add_connection(fixed_ac_id, moving_ac_id)
logger.info(
- f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, "
+ f"Connected: anchor={fixed_ac_id} ↔ moved={moving_ac_id}, "
f"rotation={rotation}°, offset={offset}mm, flip={flip}"
)
+ # Clean up the static first-pick gizmo now that the connection is final.
+ self._viewer_3d.clear_persistent_connector_gizmo()
self._connector_first_pick = None
self._connector_second_ac_id = None
self._mark_dirty()
@@ -4447,6 +5536,7 @@ class MainWindow(QMainWindow):
second_pick: Dict[str, Any],
solved: Dict[str, Any],
mover_ac: Any = None,
+ target_normal: Any = None,
) -> Tuple[Optional[float], Optional[float], bool]:
"""Show connector dialog with live 3D preview of the alignment.
@@ -4518,7 +5608,12 @@ class MainWindow(QMainWindow):
import numpy as np
- target_normal = np.array(first_pick["normal_world"], dtype=float)
+ # Offset/rotation axis: the solved target normal (fixed side's
+ # connector normal). Falls back to the first pick's normal for
+ # callers that don't pass it.
+ if target_normal is None:
+ target_normal = np.array(first_pick["normal_world"], dtype=float)
+ target_normal = np.asarray(target_normal, dtype=float)
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
# ── Live preview callback ──
@@ -4702,6 +5797,88 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True)
logger.info(f"Deleted connection {conn_id}")
+ def _on_update_connection_from_list(self):
+ """'Upd' button: re-locate the selected connection's connectors.
+
+ Both mated connectors are re-detected on their current (rebuilt)
+ geometry — the same 3-D search the body-update pipeline uses — so
+ a connector follows a feature that moved (e.g. a hole after its
+ sketch circle moved). The pair is then re-solved so the mated
+ parts stay aligned with the new feature positions.
+ """
+ selected = self._connection_list.currentItem()
+ if selected is None:
+ QMessageBox.information(self, "No Selection", "Select a connection to update")
+ return
+
+ # Capture the label now: the refresh below destroys the C++ item.
+ selected_text = selected.text()
+
+ conn_id = selected.data(Qt.UserRole)
+ assembly = self._get_assembly()
+ if assembly is None:
+ return
+
+ target = next((c for c in assembly.connections if c.id == conn_id), None)
+ if target is None:
+ return
+
+ first_ac = assembly.components.get(target.first_ac_id)
+ second_ac = assembly.components.get(target.second_ac_id)
+ if first_ac is None or second_ac is None:
+ return
+
+ # Re-detect every mated connector on the two instances.
+ changed = False
+ any_invalid = False
+ unresolved: List[Tuple[Any, Any, Any]] = []
+ for ac in (first_ac, second_ac):
+ comp = self._project.get_component_by_id(ac.component_id)
+ for conn in list(ac.connectors.values()):
+ if conn.partner_ac_id not in (first_ac.id, second_ac.id):
+ continue
+ result = self._redetect_connector_on_geometry(conn, ac, comp)
+ if result is None:
+ conn.is_invalid = True
+ conn.modified_at = datetime.now()
+ any_invalid = True
+ unresolved.append((assembly, ac, conn))
+ continue
+ dist, pos, normal, x_dir, second_dist = result
+ if not _relocate_confident(dist, second_dist):
+ unresolved.append((assembly, ac, conn))
+ continue
+ changed = self._apply_connector_relocation(conn, pos, normal, x_dir) or changed
+
+ if not any_invalid and not unresolved:
+ self._realign_connection(assembly, target)
+ elif changed:
+ # Some endpoints moved; re-solve even if others still need picking
+ # so the mates follow the new positions in the meantime.
+ self._realign_connection(assembly, target)
+
+ self._mark_dirty()
+ self._refresh_connection_list()
+ if self._assembly_view_active:
+ self._show_assembly_in_viewer(fit=False)
+
+ if changed and not any_invalid and not unresolved:
+ logger.info(f"Connection '{selected_text}': connectors updated")
+ elif unresolved:
+ if self._assembly_view_active:
+ self._prompt_relocate_unresolved(unresolved)
+ else:
+ logger.info(
+ f"{len(unresolved)} connector(s) need a manual re-pick"
+ )
+ elif any_invalid:
+ QMessageBox.information(
+ self,
+ "No Matching Feature",
+ "Could not find the source feature on the current geometry.\n"
+ "The connector(s) are marked invalid — re-place them if needed.",
+ )
+
def _new_workplane(self):
"""Open the orientation dialog and create a new independent workplane.
@@ -4992,6 +6169,13 @@ class MainWindow(QMainWindow):
)
return
+ # Instance routing: pattern the LOCAL instance geometry and record
+ # the pattern as a modifier on the selected assembly instance.
+ inst = self._body_instance_context(body)
+ base_geom = self._instance_body_geom(inst, body) if inst is not None else body.geometry
+ if base_geom is None:
+ return
+
dialog = ArrayDialog(self)
# Camera auto-fit: frame the pattern's extent when the dialog
# opens (first preview fires immediately with defaults), then
@@ -5001,7 +6185,7 @@ class MainWindow(QMainWindow):
def _fit_camera(values: Dict[str, Any]) -> None:
try:
- box = _array_copies_box(self._kernel, body.geometry, values)
+ box = _array_copies_box(self._kernel, base_geom, values)
except Exception:
logger.debug("array camera fit compute failed", exc_info=True)
return
@@ -5016,8 +6200,10 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_preview()
return
try:
- shape = _build_array_preview(self._kernel, body.geometry, values)
+ shape = _build_array_preview(self._kernel, base_geom, values)
if shape is not None:
+ if inst is not None:
+ shape = self._apply_transform(shape, inst.position, inst.rotation)
self._viewer_3d.show_preview(shape)
_fit_camera(values)
else:
@@ -5030,13 +6216,23 @@ class MainWindow(QMainWindow):
if dialog.exec():
values = dialog.get_values()
- self._apply_array(body, values)
+ self._apply_array(body, values, inst=inst)
# Preview already cleared by the dialog's hideEvent; clear again
# in case the dialog was rejected programmatically.
self._viewer_3d.clear_preview()
- def _apply_array(self, body: Body, values: Dict[str, Any]) -> None:
+ def _body_instance_context(self, body: Body) -> Optional[Any]:
+ """The selected instance owning *body*, or None in component view."""
+ ac = self._get_instance_context()
+ if ac is None or body is None:
+ return None
+ comp = self._project.get_component_by_id(ac.component_id)
+ if comp is None or not any(b is body for b in comp.bodies.values()):
+ return None
+ return ac
+
+ def _apply_array(self, body: Body, values: Dict[str, Any], inst: Optional[Any] = None) -> None:
"""Apply the array pattern to *body* and record it as a feature.
The body's geometry is replaced by the union (compound) of the
@@ -5044,6 +6240,12 @@ class MainWindow(QMainWindow):
to the feature history so the pattern replays on Update Body and
survives save/load.
"""
+ if inst is not None:
+ base_geom = self._instance_body_geom(inst, body)
+ if base_geom is None:
+ return
+ else:
+ base_geom = body.geometry
pattern_type = values.get("pattern_type", "linear")
count = max(1, int(values.get("count", 2)))
spacing = float(values.get("spacing", 10.0))
@@ -5054,7 +6256,7 @@ class MainWindow(QMainWindow):
try:
result_geom = self._kernel.pattern(
- body.geometry,
+ base_geom,
pattern_type=pattern_type,
count=count,
direction=direction,
@@ -5073,6 +6275,29 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Array Failed", "The array operation produced no geometry.")
return
+ # Instance context: the pattern lands on the instance's modifier
+ # list; the shared component stays untouched.
+ if inst is not None:
+ self._apply_instance_modifier(
+ inst,
+ body,
+ Feature(
+ operation="array",
+ pattern_type=pattern_type,
+ count=count,
+ spacing=spacing,
+ direction=direction,
+ axis=axis,
+ origin=origin,
+ angle=angle,
+ ),
+ )
+ kind = "Circular" if pattern_type == "circular" else "Linear"
+ self.statusBar().showMessage(
+ f"{kind} array applied to instance '{inst.name}' — {count} item(s)", 5000
+ )
+ return
+
# Record the operation in the feature history so it replays on
# Update Body and survives save/load.
features = _ensure_feature_history(body)
@@ -5129,6 +6354,14 @@ class MainWindow(QMainWindow):
)
return
+ # Instance routing: mirror the LOCAL instance geometry; the mirror
+ # plane is interpreted in component-local coordinates so the
+ # modifier survives moving the instance.
+ inst = self._body_instance_context(body)
+ base_geom = self._instance_body_geom(inst, body) if inst is not None else body.geometry
+ if base_geom is None:
+ return
+
# Gather workplane data for the dialog: list of (name, origin, normal).
wp_list: list = []
if self._current_component is not None:
@@ -5147,16 +6380,18 @@ class MainWindow(QMainWindow):
normal = values["mirror_plane_normal"]
origin = values["mirror_plane_origin"]
keep = values["keep_original"]
- mirrored = self._kernel.mirror(body.geometry, normal, origin)
+ mirrored = self._kernel.mirror(base_geom, normal, origin)
if mirrored is None:
self._viewer_3d.clear_preview()
return
if keep:
- result = self._kernel.boolean_union(body.geometry, mirrored)
+ result = self._kernel.boolean_union(base_geom, mirrored)
else:
result = mirrored
if result is not None:
shape = self._kernel._get_shape(result)
+ if inst is not None:
+ shape = self._apply_transform(shape, inst.position, inst.rotation)
self._viewer_3d.show_preview(shape)
else:
self._viewer_3d.clear_preview()
@@ -5173,7 +6408,7 @@ class MainWindow(QMainWindow):
keep_original = values["keep_original"]
try:
- mirrored = self._kernel.mirror(body.geometry, normal, origin)
+ mirrored = self._kernel.mirror(base_geom, normal, origin)
except Exception as exc:
logger.exception(f"Mirror failed: {exc}")
QMessageBox.critical(
@@ -5188,7 +6423,7 @@ class MainWindow(QMainWindow):
if keep_original:
try:
- result_geom = self._kernel.boolean_union(body.geometry, mirrored)
+ result_geom = self._kernel.boolean_union(base_geom, mirrored)
except Exception as exc:
logger.exception(f"Mirror union failed: {exc}")
QMessageBox.critical(
@@ -5204,6 +6439,25 @@ class MainWindow(QMainWindow):
else:
result_geom = mirrored
+ # Instance context: record a mirror modifier on the instance;
+ # the shared component stays untouched.
+ if inst is not None:
+ self._apply_instance_modifier(
+ inst,
+ body,
+ Feature(
+ operation="mirror",
+ mirror_plane_origin=origin,
+ mirror_plane_normal=normal,
+ keep_original=keep_original,
+ ),
+ )
+ keep_msg = " (keep original)" if keep_original else ""
+ self.statusBar().showMessage(
+ f"Mirror applied to instance '{inst.name}'{keep_msg}", 5000
+ )
+ return
+
# Record the operation in the feature history.
features = _ensure_feature_history(body)
if not features and body.geometry is not None:
@@ -5586,11 +6840,20 @@ class MainWindow(QMainWindow):
# ``pick_planar_face`` (the renderer matches DetectedInteractive
# against tracked AIS objects). We extract that owner so the cut
# can target the right body.
- source_body = None
logger.info(f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}")
# Pull the owning obj_id the renderer stashed on this pick pass.
owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
- if owner_obj_id and self._current_component is not None:
+ # Instance path: if the picked face belongs to the selected assembly
+ # instance, the sketch is stored ON THAT INSTANCE in component-local
+ # coordinates — the shared component stays untouched and the sketch
+ # keeps working when the instance is moved or rotated.
+ inst_target = self._owner_instance(owner_obj_id)
+
+ source_body = None
+ if inst_target is not None:
+ source_body = inst_target[1]
+ logger.info(f"Instance sketch source body: {source_body.name}")
+ elif owner_obj_id and self._current_component is not None:
for bid, body in self._current_component.bodies.items():
if body.render_object == owner_obj_id:
source_body = body
@@ -5602,12 +6865,25 @@ class MainWindow(QMainWindow):
self._btn_wp_face.setChecked(False)
self._viewer_3d.set_pick_face_mode(False)
- if not self._current_component:
- self._current_component = self._project.add_component()
+ if inst_target is not None:
+ ac = inst_target[0]
+ # The stored sketch workplane + source face must live in LOCAL
+ # coords (the modifier replays against the local instance
+ # geometry), and the 2D widget underlay must use the same local
+ # face/plane — the instance transform is applied by the viewer
+ # only.
+ face_shape = self._shape_to_local(ac, face_shape)
+ origin, normal, x_dir = self._plane_to_local(ac, origin, normal, x_dir)
+ sketch = ac.add_instance_sketch()
+ self._mark_dirty()
+ sketch.name = f"Instance sketch on face {len(ac.sketches)}"
+ else:
+ if not self._current_component:
+ self._current_component = self._project.add_component()
- sketch = self._current_component.add_sketch()
- self._mark_dirty()
- sketch.name = f"Sketch on face {len(self._current_component.sketches)}"
+ sketch = self._current_component.add_sketch()
+ self._mark_dirty()
+ sketch.name = f"Sketch on face {len(self._current_component.sketches)}"
# Place the sketch on the picked plane (sets fields + syncs occ_sketch).
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
@@ -5647,7 +6923,7 @@ class MainWindow(QMainWindow):
# the row. _on_sketch_selected loads it into the widget for editing.
for row in range(self._sketch_list.count()):
item = self._sketch_list.item(row)
- if item is not None and item.text() == sketch.name:
+ if item is not None and item.data(Qt.UserRole) == sketch.id:
self._sketch_list.setCurrentRow(row)
break
# Switch focus to the sketch panel so the user can draw immediately.
@@ -5759,9 +7035,18 @@ class MainWindow(QMainWindow):
if not selected:
return
- name = selected.text()
- for sketch_id, sketch in self._current_component.sketches.items():
- if sketch.name == name:
+ # Id-based lookup (component sketches first, then instance-local
+ # sketches on the selected assembly instance); fall back to a name
+ # match for legacy list items.
+ item_id = selected.data(Qt.UserRole)
+ sketch = self._sketch_by_id(item_id) if item_id is not None else None
+ if sketch is None and self._current_component is not None:
+ name = selected.text()
+ for s in self._current_component.sketches.values():
+ if s.name == name:
+ sketch = s
+ break
+ if sketch is not None:
self._current_sketch = sketch
if sketch.occ_sketch:
sketch.apply_workplane()
@@ -5797,65 +7082,104 @@ class MainWindow(QMainWindow):
self._btn_to_sketch.setEnabled(False)
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
- logger.info(f"Editing sketch: {name}")
- break
+ logger.info(f"Editing sketch: {sketch.name}")
# Anchor the world triad at the sketch being edited.
self._sync_sketch_gizmo()
def _on_sketch_selected(self, current, previous):
"""When sketch is selected in list, load it for editing."""
if current and self._current_component:
- name = current.text()
- for sketch_id, sketch in self._current_component.sketches.items():
- if sketch.name == name:
- self._current_sketch = sketch
- if (
- sketch.occ_sketch
- and hasattr(sketch.occ_sketch, "get_entity_count")
- and sketch.occ_sketch.get_entity_count() > 0
- ):
- self._sketch_widget.set_sketch(sketch.occ_sketch)
- break
+ sketch_id = current.data(Qt.UserRole)
+ sketch = self._sketch_by_id(sketch_id) if sketch_id is not None else None
+ if sketch is None:
+ name = current.text()
+ for s in self._current_component.sketches.values():
+ if s.name == name:
+ sketch = s
+ break
+ if sketch is not None:
+ self._current_sketch = sketch
+ if (
+ sketch.occ_sketch
+ and hasattr(sketch.occ_sketch, "get_entity_count")
+ and sketch.occ_sketch.get_entity_count() > 0
+ ):
+ self._sketch_widget.set_sketch(sketch.occ_sketch)
# Keep the world triad anchored to the newly selected sketch.
self._sync_sketch_gizmo()
+ def _sketch_by_id(self, sketch_id: str) -> Optional[Sketch]:
+ """Find a sketch by id in the current component, then the instance."""
+ if self._current_component is not None and sketch_id in self._current_component.sketches:
+ return self._current_component.sketches[sketch_id]
+ inst_ac = self._get_instance_context()
+ if inst_ac is not None and sketch_id in inst_ac.sketches:
+ return inst_ac.sketches[sketch_id]
+ return None
+
def _delete_sketch(self):
selected = self._sketch_list.currentItem()
if not selected or not self._current_component:
return
name = selected.text()
- to_delete = None
- for sketch_id, sketch in self._current_component.sketches.items():
- if sketch.name == name:
- to_delete = sketch_id
- break
+ # Identify by id (component first, then instance-local), falling
+ # back to a name match for legacy list items.
+ sketch_id = selected.data(Qt.UserRole)
+ target_sketch = self._sketch_by_id(sketch_id) if sketch_id is not None else None
+ inst_ac = None
+ if target_sketch is not None and sketch_id is not None:
+ ctx_ac = self._get_instance_context()
+ if ctx_ac is not None and sketch_id in ctx_ac.sketches:
+ inst_ac = ctx_ac
+ if target_sketch is None and sketch_id is None:
+ for sid, s in self._current_component.sketches.items():
+ if s.name == name:
+ target_sketch = s
+ sketch_id = sid
+ break
- if to_delete:
- del self._current_component.sketches[to_delete]
+ if sketch_id is not None and target_sketch is not None:
+ if inst_ac is not None:
+ # Instance-local sketch: dropping it also removes the
+ # instance modifiers that reference it.
+ inst_ac.remove_instance_sketch(sketch_id)
+ else:
+ del self._current_component.sketches[sketch_id]
+ if self._current_sketch is target_sketch:
+ self._current_sketch = None
self._mark_dirty()
self._refresh_lists()
logger.info(f"Deleted sketch: {name}")
def _on_sketch_list_changed(self, current, previous):
if current and self._current_component:
- name = current.text()
- for sketch_id, sketch in self._current_component.sketches.items():
- if sketch.name == name:
- self._current_sketch = sketch
- break
+ sketch_id = current.data(Qt.UserRole)
+ if sketch_id is not None:
+ self._current_sketch = self._sketch_by_id(sketch_id)
+ else:
+ name = current.text()
+ for s in self._current_component.sketches.values():
+ if s.name == name:
+ self._current_sketch = s
+ break
# Keep the world triad anchored to the currently selected sketch.
self._sync_sketch_gizmo()
def _on_body_list_changed(self, current, previous):
if current and self._current_component:
- name = current.text()
- for body_id, body in self._current_component.bodies.items():
- if body.name == name:
- self._selected_body = body
- self._refresh_operations_list()
- logger.info(f"Selected: {name}")
- break
+ body_id = current.data(Qt.UserRole)
+ body = self._current_component.bodies.get(body_id) if body_id is not None else None
+ if body is None:
+ name = current.text()
+ for b in self._current_component.bodies.values():
+ if b.name == name:
+ body = b
+ break
+ if body is not None:
+ self._selected_body = body
+ self._refresh_operations_list()
+ logger.info(f"Selected: {body.name}")
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
"""Toggle a body's 3D visibility when the user clicks pb_body_hide.
@@ -6211,15 +7535,56 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
return
+ # Instance routing: a sketch that lives on the selected assembly
+ # instance records a MODIFIER on that instance (in component-local
+ # coordinates) instead of touching the shared component. Plain
+ # extrudes behave as a union there (an instance can't gain a new
+ # body — the base model stays the source of bodies).
+ inst_ctx = self._instance_sketch_context(sketch)
+
dialog = ExtrudeDialog(self)
- # Wire up the live preview: every spinbox/checkbox change rebuilds
- # the result via the shared helper and shows it transparent.
- self._start_extrude_preview(dialog, sketch, face_geom)
+ if inst_ctx is not None:
+ ac, inst_body = inst_ctx
+ inst_obj_id = f"asm_{ac.id}_{inst_body.id}"
+
+ def _inst_preview(values):
+ if values is None:
+ self._viewer_3d.clear_preview()
+ self._viewer_3d.set_transparency(inst_obj_id, 0.0)
+ return
+ length, symmetric, invert, cut, union, through_all, _cab, _r = values
+ try:
+ res = self._compute_instance_extrude_result(
+ ac, inst_body, sketch, face_geom,
+ length, symmetric, invert,
+ bool(cut), bool(union), bool(through_all),
+ )
+ except Exception:
+ logger.exception("Instance extrude preview failed")
+ res = None
+ if res is None or res["result_shape"] is None:
+ self._viewer_3d.clear_preview()
+ return
+ # The result is in local coords; the assembly scene is
+ # world — apply the instance transform before showing.
+ shape = self._apply_transform(res["result_shape"], ac.position, ac.rotation)
+ self._viewer_3d.show_preview(shape)
+ self._viewer_3d.set_transparency(inst_obj_id, 0.6)
+
+ dialog.set_preview_callback(_inst_preview)
+ else:
+ # Wire up the live preview: every spinbox/checkbox change
+ # rebuilds the result via the shared helper and shows it
+ # transparent.
+ self._start_extrude_preview(dialog, sketch, face_geom)
accepted = dialog.exec()
# The dialog's hideEvent already fired the callback with *None* to
# clear the preview and un-dim any body — but be defensive in case
# a subclass swallows the event.
self._viewer_3d.clear_preview()
+ if inst_ctx is not None:
+ # Undo the preview dim on the instance render object.
+ self._viewer_3d.set_transparency(f"asm_{inst_ctx[0].id}_{inst_ctx[1].id}", 0.0)
if not accepted:
logger.info("Extrude dialog cancelled")
return
@@ -6237,6 +7602,41 @@ class MainWindow(QMainWindow):
# face may be cleared during preview cleanup).
face_index = self._sketch_widget.get_selected_face_index()
+ if inst_ctx is not None:
+ ac, inst_body = inst_ctx
+ try:
+ result = self._compute_instance_extrude_result(
+ ac, inst_body, sketch, face_geom,
+ length, symmetric, invert,
+ bool(cut), bool(union), bool(through_all),
+ )
+ except Exception as e:
+ logger.exception(f"Instance extrude failed: {e}")
+ result = None
+ if result is None or result["result_geom"] is None:
+ QMessageBox.warning(self, "No Geometry", "Extrude produced no geometry on this instance")
+ return
+ op = "cut" if bool(cut) else "union"
+ feat = Feature(
+ operation=op,
+ sketch=sketch,
+ length=length,
+ symmetric=symmetric,
+ invert=invert,
+ through_all=bool(through_all),
+ face_index=face_index,
+ )
+ self._apply_instance_modifier(ac, inst_body, feat)
+ if bool(cut):
+ msg = f"Cut applied to instance '{ac.name}'"
+ elif bool(union):
+ msg = f"Union applied to instance '{ac.name}'"
+ else:
+ msg = f"Extrude added material to instance '{ac.name}' (union semantics)"
+ self.statusBar().showMessage(msg, 5000)
+ logger.info(f"=== INSTANCE {op.upper()} COMPLETE ===")
+ return
+
try:
result = self._compute_extrude_result(
sketch,
@@ -6637,6 +8037,8 @@ class MainWindow(QMainWindow):
self._fillet_face1 = None
self._fillet_face2 = None
self._fillet_body = None
+ self._fillet_inst = None
+ self._fillet_inst_ac_id = None
self._btn_fillet.setChecked(True)
self._viewer_3d.set_fillet_pick_mode(True)
# Disarm the other pick flows (sketch-on-surface, chamfer).
@@ -6651,6 +8053,8 @@ class MainWindow(QMainWindow):
self._fillet_face1 = None
self._fillet_face2 = None
self._fillet_body = None
+ self._fillet_inst = None
+ self._fillet_inst_ac_id = None
self._viewer_3d.set_fillet_pick_mode(False)
self._btn_fillet.setChecked(False)
self._viewer_3d.clear_faces_highlight()
@@ -6677,6 +8081,15 @@ class MainWindow(QMainWindow):
self._btn_thread.setChecked(False)
self._thread_pick_active = False
return
+ if self._get_instance_context() is not None:
+ QMessageBox.warning(
+ self,
+ "Not Supported",
+ "Threads are not supported on assembly instances yet.\n"
+ "Select a component button to thread the base body.",
+ )
+ self._btn_thread.setChecked(False)
+ return
self._thread_pick_active = True
self._thread_face = None
self._thread_body = None
@@ -6878,7 +8291,15 @@ class MainWindow(QMainWindow):
if not self._fillet_pick_active:
return
owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
- body = self._fillet_body_for_owner(owner_obj_id)
+ # Instance routing: a face picked on the selected assembly instance
+ # is inverse-transformed to local coords so the fillet modifier
+ # replays against the local instance geometry.
+ inst = self._owner_instance(owner_obj_id)
+ if inst is not None:
+ body = inst[1]
+ face = self._shape_to_local(inst[0], face)
+ else:
+ body = self._fillet_body_for_owner(owner_obj_id)
if body is None or body.geometry is None:
QMessageBox.warning(
self,
@@ -6891,19 +8312,28 @@ class MainWindow(QMainWindow):
if self._fillet_face1 is None:
self._fillet_face1 = face
self._fillet_body = body
+ self._fillet_inst = inst
+ self._fillet_inst_ac_id = inst[0].id if inst is not None else None
self._viewer_3d.highlight_faces([face])
self.statusBar().showMessage("Fillet: pick the SECOND face", 6000)
return
- # Second face: same body, and it must share an edge with face 1.
- if body is not self._fillet_body:
+ # Second face: same body AND same instance, and it must share an
+ # edge with face 1. (Two instances of the same component share the
+ # Body object, so the instance id disambiguates.)
+ cur_ac_id = inst[0].id if inst is not None else None
+ if cur_ac_id != self._fillet_inst_ac_id:
QMessageBox.warning(
self,
"Different Bodies",
"Both faces must belong to the SAME body. Pick the second face again.",
)
return
- shape = self._kernel._get_shape(body.geometry)
+ base_geom = self._instance_body_geom(inst[0], body) if inst is not None else body.geometry
+ if base_geom is None:
+ self._cancel_fillet_pick()
+ return
+ shape = self._kernel._get_shape(base_geom)
seed_edges = _shared_edges_between_faces(shape, self._fillet_face1, face)
if not seed_edges:
QMessageBox.warning(
@@ -6923,7 +8353,12 @@ class MainWindow(QMainWindow):
if body is None or body.geometry is None:
self._cancel_fillet_pick()
return
- shape = self._kernel._get_shape(body.geometry)
+ inst = self._fillet_inst
+ base_geom = self._instance_body_geom(inst[0], body) if inst is not None else body.geometry
+ if base_geom is None:
+ self._cancel_fillet_pick()
+ return
+ shape = self._kernel._get_shape(base_geom)
dialog = FilletDialog(self)
dialog.set_edge_count(len(seed_edges))
@@ -6936,8 +8371,17 @@ class MainWindow(QMainWindow):
radius = size / 2.0 if is_diameter else size
try:
edges = _resolve_fillet_edges(shape, seed_edges, tangent, scope)
- result = self._kernel.fillet(body.geometry, radius, edges=edges)
- self._viewer_3d.show_preview(self._kernel._get_shape(result))
+ result = self._kernel.fillet(base_geom, radius, edges=edges)
+ if result is None:
+ self._viewer_3d.clear_preview()
+ return
+ result_shape = self._kernel._get_shape(result)
+ if inst is not None:
+ # Local result → world for the assembly scene.
+ result_shape = self._apply_transform(
+ result_shape, inst[0].position, inst[0].rotation
+ )
+ self._viewer_3d.show_preview(result_shape)
except Exception:
self._viewer_3d.clear_preview()
@@ -6967,6 +8411,11 @@ class MainWindow(QMainWindow):
body = self._fillet_body
if body is None or body.geometry is None:
return
+ if self._fillet_inst is not None:
+ self._apply_instance_fillet(
+ self._fillet_inst[0], body, shape, seed_edges, radius, tangent_propagation, scope
+ )
+ return
try:
edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope)
result_geom = self._kernel.fillet(body.geometry, radius, edges=edges)
@@ -6980,6 +8429,65 @@ class MainWindow(QMainWindow):
)
return
+ def _apply_instance_fillet(
+ self,
+ ac: Any,
+ body: Body,
+ shape: Any,
+ seed_edges: List[Any],
+ radius: float,
+ tangent_propagation: bool,
+ scope: str,
+ ) -> None:
+ """Fillet the instance geometry (local) and record a fillet modifier.
+
+ The base model is untouched: the fillet is stored on the instance's
+ modifier list and replays on top of the live component features.
+ """
+ base_geom = self._instance_body_geom(ac, body)
+ if base_geom is None:
+ return
+ try:
+ edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope)
+ result_geom = self._kernel.fillet(base_geom, radius, edges=edges)
+ except Exception as exc:
+ logger.exception(f"Instance fillet failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Fillet Failed",
+ f"Could not fillet these edges: {exc}\n\n"
+ "Try a smaller size or a different pair of faces.",
+ )
+ return
+ if result_geom is None:
+ QMessageBox.critical(self, "Fillet Failed", "The fillet produced no result.")
+ return
+
+ combo = list(_ensure_feature_history(body)) + list(ac.modifiers.get(body.id, []))
+ face_keys = face_keys_sketch_id = None
+ if scope != "all" and self._fillet_face1 is not None and self._fillet_face2 is not None:
+ face_keys, face_keys_sketch_id = _compute_fillet_face_keys(
+ shape, combo, self._fillet_face1, self._fillet_face2
+ )
+
+ self._apply_instance_modifier(
+ ac,
+ body,
+ Feature(
+ operation="fillet",
+ radius=radius,
+ tangent_propagation=tangent_propagation,
+ scope=scope,
+ edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
+ face_keys=face_keys,
+ face_keys_sketch_id=face_keys_sketch_id,
+ ),
+ )
+ self.statusBar().showMessage(
+ f"Fillet applied to instance '{ac.name}' (radius {radius:g} mm)", 5000
+ )
+ logger.info(f"Instance fillet on '{body.name}' of '{ac.name}' (radius {radius})")
+
# Record the operation in the feature history so it replays on
# Update Body and survives save/load.
features = _ensure_feature_history(body)
@@ -7087,6 +8595,8 @@ class MainWindow(QMainWindow):
self._chamfer_face1 = None
self._chamfer_face2 = None
self._chamfer_body = None
+ self._chamfer_inst = None
+ self._chamfer_inst_ac_id = None
self._btn_chamfer.setChecked(True)
self._viewer_3d.set_chamfer_pick_mode(True)
# Disarm the other pick flows (sketch-on-surface, fillet, thread).
@@ -7102,6 +8612,8 @@ class MainWindow(QMainWindow):
self._chamfer_face1 = None
self._chamfer_face2 = None
self._chamfer_body = None
+ self._chamfer_inst = None
+ self._chamfer_inst_ac_id = None
self._viewer_3d.set_chamfer_pick_mode(False)
self._btn_chamfer.setChecked(False)
self._viewer_3d.clear_faces_highlight()
@@ -7112,7 +8624,14 @@ class MainWindow(QMainWindow):
if not self._chamfer_pick_active:
return
owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
- body = self._fillet_body_for_owner(owner_obj_id)
+ # Instance routing: inverse-transform the picked face to local
+ # coords so the chamfer modifier replays on the local instance geom.
+ inst = self._owner_instance(owner_obj_id)
+ if inst is not None:
+ body = inst[1]
+ face = self._shape_to_local(inst[0], face)
+ else:
+ body = self._fillet_body_for_owner(owner_obj_id)
if body is None or body.geometry is None:
QMessageBox.warning(
self,
@@ -7125,19 +8644,27 @@ class MainWindow(QMainWindow):
if self._chamfer_face1 is None:
self._chamfer_face1 = face
self._chamfer_body = body
+ self._chamfer_inst = inst
+ self._chamfer_inst_ac_id = inst[0].id if inst is not None else None
self._viewer_3d.highlight_faces([face])
self.statusBar().showMessage("Chamfer: pick the SECOND face", 6000)
return
- # Second face: same body, and it must share an edge with face 1.
- if body is not self._chamfer_body:
+ # Second face: same body AND same instance, and it must share an
+ # edge with face 1.
+ cur_ac_id = inst[0].id if inst is not None else None
+ if cur_ac_id != self._chamfer_inst_ac_id:
QMessageBox.warning(
self,
"Different Bodies",
"Both faces must belong to the SAME body. Pick the second face again.",
)
return
- shape = self._kernel._get_shape(body.geometry)
+ base_geom = self._instance_body_geom(inst[0], body) if inst is not None else body.geometry
+ if base_geom is None:
+ self._cancel_chamfer_pick()
+ return
+ shape = self._kernel._get_shape(base_geom)
seed_edges = _shared_edges_between_faces(shape, self._chamfer_face1, face)
if not seed_edges:
QMessageBox.warning(
@@ -7157,7 +8684,12 @@ class MainWindow(QMainWindow):
if body is None or body.geometry is None:
self._cancel_chamfer_pick()
return
- shape = self._kernel._get_shape(body.geometry)
+ inst = self._chamfer_inst
+ base_geom = self._instance_body_geom(inst[0], body) if inst is not None else body.geometry
+ if base_geom is None:
+ self._cancel_chamfer_pick()
+ return
+ shape = self._kernel._get_shape(base_geom)
dialog = ChamferDialog(self)
dialog.set_edge_count(len(seed_edges))
@@ -7169,8 +8701,17 @@ class MainWindow(QMainWindow):
size, tangent, scope = values
try:
edges = _resolve_fillet_edges(shape, seed_edges, tangent, scope)
- result = self._kernel.chamfer(body.geometry, size, edges=edges)
- self._viewer_3d.show_preview(self._kernel._get_shape(result))
+ result = self._kernel.chamfer(base_geom, size, edges=edges)
+ if result is None:
+ self._viewer_3d.clear_preview()
+ return
+ result_shape = self._kernel._get_shape(result)
+ if inst is not None:
+ # Local result → world for the assembly scene.
+ result_shape = self._apply_transform(
+ result_shape, inst[0].position, inst[0].rotation
+ )
+ self._viewer_3d.show_preview(result_shape)
except Exception:
self._viewer_3d.clear_preview()
@@ -7194,6 +8735,11 @@ class MainWindow(QMainWindow):
body = self._chamfer_body
if body is None or body.geometry is None:
return
+ if self._chamfer_inst is not None:
+ self._apply_instance_chamfer(
+ self._chamfer_inst[0], body, shape, seed_edges, size, tangent_propagation, scope
+ )
+ return
try:
edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope)
result_geom = self._kernel.chamfer(body.geometry, size, edges=edges)
@@ -7207,6 +8753,61 @@ class MainWindow(QMainWindow):
)
return
+ def _apply_instance_chamfer(
+ self,
+ ac: Any,
+ body: Body,
+ shape: Any,
+ seed_edges: List[Any],
+ size: float,
+ tangent_propagation: bool,
+ scope: str,
+ ) -> None:
+ """Chamfer the instance geometry (local) and record a chamfer modifier."""
+ base_geom = self._instance_body_geom(ac, body)
+ if base_geom is None:
+ return
+ try:
+ edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope)
+ result_geom = self._kernel.chamfer(base_geom, size, edges=edges)
+ except Exception as exc:
+ logger.exception(f"Instance chamfer failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Chamfer Failed",
+ f"Could not chamfer these edges: {exc}\n\n"
+ "Try a smaller size or a different pair of faces.",
+ )
+ return
+ if result_geom is None:
+ QMessageBox.critical(self, "Chamfer Failed", "The chamfer produced no result.")
+ return
+
+ combo = list(_ensure_feature_history(body)) + list(ac.modifiers.get(body.id, []))
+ face_keys = face_keys_sketch_id = None
+ if scope != "all" and self._chamfer_face1 is not None and self._chamfer_face2 is not None:
+ face_keys, face_keys_sketch_id = _compute_fillet_face_keys(
+ shape, combo, self._chamfer_face1, self._chamfer_face2
+ )
+
+ self._apply_instance_modifier(
+ ac,
+ body,
+ Feature(
+ operation="chamfer",
+ radius=size, # reuse radius field for chamfer size
+ tangent_propagation=tangent_propagation,
+ scope=scope,
+ edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
+ face_keys=face_keys,
+ face_keys_sketch_id=face_keys_sketch_id,
+ ),
+ )
+ self.statusBar().showMessage(
+ f"Chamfer applied to instance '{ac.name}' (size {size:g} mm)", 5000
+ )
+ logger.info(f"Instance chamfer on '{body.name}' of '{ac.name}' (size {size})")
+
# Record the operation in the feature history so it replays on
# Update Body and survives save/load.
features = _ensure_feature_history(body)
@@ -7289,22 +8890,44 @@ class MainWindow(QMainWindow):
if not selected or not self._current_component:
return
- name = selected.text()
- to_delete = None
- for body_id, body in self._current_component.bodies.items():
- if body.name == name:
- to_delete = body_id
- if body.render_object:
- self._viewer_3d.remove_mesh(body.render_object)
- break
+ # Look up by the body id stored on the list item — the display name
+ # carries ⚠ / [+N inst] decorations that would break name matching.
+ body_id = selected.data(Qt.UserRole)
+ body = self._current_component.bodies.get(body_id) if body_id else None
+ if body is None:
+ name = selected.text()
+ for b in self._current_component.bodies.values():
+ if b.name == name or b.name in name:
+ body = b
+ break
+ if body is None:
+ return
+ name = body.name
+ if body.render_object:
+ self._viewer_3d.remove_mesh(body.render_object)
- if to_delete:
- del self._current_component.bodies[to_delete]
- if self._selected_body is not None and self._selected_body.name == name:
- self._selected_body = None
- self._mark_dirty()
- self._refresh_lists()
- logger.info(f"Deleted body: {name}")
+ del self._current_component.bodies[body.id]
+ if self._selected_body is not None and self._selected_body.id == body.id:
+ self._selected_body = None
+
+ # Purge per-instance state that referenced the deleted body so
+ # save/load and instance rebuilds never hit a dangling body id.
+ for assembly in self._project.assemblies.values():
+ for ac in assembly.components.values():
+ if ac.component_id != self._current_component.id:
+ continue
+ changed = ac.modifiers.pop(body.id, None) is not None
+ for sk in list(ac.sketches.values()):
+ if getattr(sk, "_source_body_id", None) == body.id:
+ ac.remove_instance_sketch(sk.id)
+ changed = True
+ if changed and self._assembly_view_active:
+ ac.invalidate_geom_cache()
+ self._update_assembly_component_in_viewer(ac.id)
+
+ self._mark_dirty()
+ self._refresh_lists()
+ logger.info(f"Deleted body: {name}")
# ── Recent Projects ──────────────────────────────────────────────
@@ -7796,6 +9419,8 @@ class MainWindow(QMainWindow):
if not self._confirm_discard_changes():
event.ignore()
return
+ # Stop the render workbench so no render thread outlives the app.
+ self._render_tab.cleanup()
event.accept()
def _import_file(self):
@@ -7917,7 +9542,20 @@ class MainWindow(QMainWindow):
if not body.visible or not body.geometry:
continue
try:
- occ_shape = self._kernel._get_shape(body.geometry)
+ # Instances with per-instantiation modifiers
+ # render their rebuilt (live base + modifiers)
+ # geometry; plain instances render the shared
+ # component body as-is.
+ if ac.modifiers.get(body_id):
+ geom = self._instance_body_geom(ac, body)
+ if geom is None:
+ continue
+ else:
+ geom = body.geometry
+ occ_shape = self._kernel._get_shape(geom)
+ occ_shape = self._apply_transform(
+ occ_shape, ac.position, ac.rotation
+ )
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
@@ -7981,6 +9619,17 @@ class MainWindow(QMainWindow):
def _load_render_tab_shape(self) -> None:
"""Auto-load the selected body or assembly component into the render tab."""
+ # The render workbench (tessellation + auto-preview thread) only
+ # runs while the Render tab is the visible tab — normal CAD work
+ # must never trigger it.
+ if not self._render_tab_is_current():
+ return
+ self._render_tab_do_load()
+
+ def _render_tab_is_current(self) -> bool:
+ return self._ui.InputTab.currentWidget() is self._render_tab
+
+ def _render_tab_do_load(self) -> None:
# Determine which bodies to render based on selection state.
assembly_parts = []
if self._render_mode == "assembly":
@@ -8074,6 +9723,11 @@ class MainWindow(QMainWindow):
self._load_render_tab_shape()
elif widget is self._drawing_tab:
self._load_drawing_tab_source()
+ else:
+ # Left the Render tab — stop the workbench (cancel any running
+ # preview/render thread, stop the auto-preview timer, drop the
+ # temp mesh) so nothing render-related runs during CAD work.
+ self._render_tab.cleanup()
# ─── Sketch Undo/Redo ─────────────────────────────────────────────────
diff --git a/src/fluency/ui/render_window.py b/src/fluency/ui/render_window.py
index 08865c2..5ff0596 100644
--- a/src/fluency/ui/render_window.py
+++ b/src/fluency/ui/render_window.py
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
+import warnings
from typing import Optional
import numpy as np
@@ -48,6 +49,24 @@ from fluency.rendering.render_backend import (
logger = logging.getLogger(__name__)
+def _unlink_quiet(path: Optional[str]) -> None:
+ """Unlink *path*, ignoring missing files and OS errors."""
+ if not path:
+ return
+ try:
+ if os.path.exists(path):
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+# Threads still running after a cancel could not finish in time. Kept
+# referenced (never terminate()'d, reparented from their widget) at
+# module level so they can safely outlive the widget/window that spawned
+# them — destroying a still-running QThread is a Qt fatal error.
+_RETIRED_THREADS: list = []
+
+
# ── Background render thread ────────────────────────────────────────
@@ -153,6 +172,77 @@ class _AssemblyRenderThread(QThread):
self.error.emit(str(e))
+class _MeshThread(QThread):
+ """Tessellates OCC shapes to PLY files off the GUI thread.
+
+ ``BRepMesh_IncrementalMesh`` is a single blocking C++ call, so the
+ cancel flag is checked between parts (assemblies) and at completion;
+ a cancelled thread discards its result instead of emitting it, so it
+ cannot clobber the UI.
+ """
+
+ mesh_ready = Signal(str) # single-shape: mesh path
+ assembly_ready = Signal(list, object, object) # parts, bounds, first_bounds
+ error = Signal(str)
+
+ def __init__(self, shapes, is_assembly: bool, parent=None):
+ super().__init__(parent)
+ # Single: (TopoDS_Shape,) | Assembly: [(TopoDS_Shape, mat_name), ...]
+ self._shapes = shapes
+ self._is_assembly = is_assembly
+ self._cancelled = False
+
+ def cancel(self):
+ self._cancelled = True
+
+ def run(self):
+ try:
+ if self._is_assembly:
+ self._run_assembly()
+ else:
+ mesh_path = occ_shape_to_ply(
+ self._shapes[0], linear_deflection=0.1, angular_deflection=0.15
+ )
+ if not self._cancelled:
+ self.mesh_ready.emit(mesh_path)
+ except Exception as e:
+ if not self._cancelled:
+ self.error.emit(str(e))
+
+ def _run_assembly(self):
+ from fluency.rendering.material_presets import get_preset
+
+ parts: list = []
+ all_mins: list = []
+ all_maxs: list = []
+ first_bounds = None
+ for shape, mat_name in self._shapes:
+ if self._cancelled:
+ return
+ try:
+ mesh_path = occ_shape_to_ply(
+ shape, linear_deflection=0.1, angular_deflection=0.15
+ )
+ material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
+ parts.append((mesh_path, material))
+ bounds = occ_shape_bounds(shape)
+ all_mins.append(list(bounds[0]))
+ all_maxs.append(list(bounds[1]))
+ if first_bounds is None:
+ first_bounds = bounds
+ except Exception as e:
+ logger.warning(f"Failed to tessellate assembly part: {e}")
+ if self._cancelled:
+ return
+ combined = None
+ if all_mins and all_maxs:
+ combined = (
+ [min(a[i] for a in all_mins) for i in range(3)],
+ [max(a[i] for a in all_maxs) for i in range(3)],
+ )
+ self.assembly_ready.emit(parts, combined, first_bounds)
+
+
# ── Render window ───────────────────────────────────────────────────
@@ -602,6 +692,8 @@ class RenderWindow(QMainWindow):
"""Reset camera parameters to match the 3D viewport."""
if self._camera is None:
return
+ o = self._camera.origin
+ t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -681,6 +773,8 @@ class RenderWindow(QMainWindow):
"""Fill camera spinboxes from the current RenderCamera."""
if self._camera is None:
return
+ o = self._camera.origin
+ t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -773,17 +867,63 @@ class RenderWindow(QMainWindow):
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self):
- """Cancel whichever thread is currently running."""
+ """Cancel whichever thread is currently running.
+
+ Deliberately avoids ``QThread.terminate()``: it kills the thread
+ mid-instruction inside Mitsuba/OCC C++ code and corrupts native
+ state (SIGSEGV). Threads are cancelled cooperatively and, if
+ still running, detached until they exit on their own.
+ """
if self._active_mode == "preview" and self._preview_thread:
- self._preview_thread.cancel()
- self._preview_thread.terminate()
- self._preview_thread.wait(2000)
+ self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
- self._render_thread.cancel()
- self._render_thread.terminate()
- self._render_thread.wait(2000)
+ self._stop_thread(self._render_thread)
self._active_mode = None
+ def _stop_thread(self, thread, block: bool = False):
+ """Cancel *thread*; detach without ever using ``terminate()``.
+
+ ``QThread.terminate()`` kills the thread mid-instruction inside
+ Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
+ the cooperative cancel flag is set and, if the thread is still
+ running, its result signals are disconnected and it is kept
+ referenced (``_retired_threads``) until it exits on its own — a
+ cancelled ``run()`` emits no results, so it cannot clobber the UI.
+
+ ``block=True`` (shutdown paths only) additionally waits up to 3 s
+ so a thread does not outlive the application. Interactive paths
+ keep the default and never stall the GUI thread.
+ """
+ if thread is None:
+ return
+ # Drop retired threads that have exited.
+ for t in list(_RETIRED_THREADS):
+ if not t.isRunning():
+ _RETIRED_THREADS.remove(t)
+ thread.cancel()
+ if thread.isRunning():
+ # Disconnect so a detached thread can't update the UI. Signals
+ # with no receiver only emit a RuntimeWarning on disconnect, so
+ # silence that specific case. Not every thread class defines
+ # every signal, so skip missing attributes.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", RuntimeWarning)
+ for name in ("finished", "error", "progress",
+ "mesh_ready", "assembly_ready"):
+ sig = getattr(thread, name, None)
+ if sig is None:
+ continue
+ try:
+ sig.disconnect()
+ except (RuntimeError, TypeError):
+ pass
+ _RETIRED_THREADS.append(thread)
+ # Reparent so destroying the owning widget can't delete a
+ # still-running QThread (a Qt fatal error).
+ thread.setParent(None)
+ if block:
+ thread.wait(3000)
+
def _set_buttons_rendering(self, mode: str):
"""Disable buttons while rendering."""
self._preview_btn.setEnabled(False)
@@ -970,10 +1110,8 @@ class RenderWindow(QMainWindow):
# Kill both possible threads
for thread in (self._preview_thread, self._render_thread):
- if thread and thread.isRunning():
- thread.cancel()
- thread.terminate()
- thread.wait(2000)
+ # block=True: at window close a thread must not outlive the app.
+ self._stop_thread(thread, block=True)
# Clean up temp mesh file
if self._mesh_path and os.path.exists(self._mesh_path):
@@ -1014,6 +1152,13 @@ class RenderTabContent(QWidget):
# Rendering threads & images
self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None
+ # Background tessellation thread — meshing never blocks the GUI
+ self._mesh_thread: Optional[_MeshThread] = None
+ # Raw (TopoDS_Shape, mat_name) tuples awaiting background tessellation
+ self._assembly_pending: list = []
+ # Bumped on every load/clear/cleanup; mesh results carry the
+ # generation they belong to so stale results are discarded.
+ self._mesh_generation: int = 0
self._last_image: Optional[np.ndarray] = None
self._last_preview: Optional[np.ndarray] = None
self._camera: Optional[RenderCamera] = None
@@ -1034,16 +1179,26 @@ class RenderTabContent(QWidget):
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
"""Load a new OCC TopoDS_Shape for rendering.
+ Returns immediately: tessellation runs in a background thread
+ (``_MeshThread``) so callers (e.g. component switching) never
+ block the GUI thread. The auto-preview is scheduled once the
+ mesh is ready.
+
*camera* — if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
+ self._mesh_generation += 1
# Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread()
+ # Cancel any in-flight tessellation from a previous load.
+ self._stop_thread(self._mesh_thread)
+ self._mesh_thread = None
# Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = []
+ self._assembly_pending = []
self._assembly_bounds = None
- # Reset the mesh path so a failed tessellation below cannot
+ # Reset the mesh path so a stale/failed tessellation cannot
# trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None
self._shape = shape
@@ -1052,13 +1207,11 @@ class RenderTabContent(QWidget):
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
- self._image_label.setText("Click Preview or Render to start")
+ self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
- self._prepare_mesh()
self._populate_camera_controls()
- # Trigger auto-preview when a new shape is loaded
- self._schedule_auto_preview()
+ self._start_meshing()
def get_camera(self) -> Optional[RenderCamera]:
"""Return the current camera (from UI controls or initial)."""
@@ -1073,26 +1226,30 @@ class RenderTabContent(QWidget):
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
+
+ Returns immediately; the parts are tessellated in a background
+ thread and the auto-preview is scheduled once they are ready.
"""
+ self._mesh_generation += 1
# Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread()
+ self._stop_thread(self._mesh_thread)
+ self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
+ self._assembly_pending = list(parts)
self._assembly_bounds = None
- # Tessellate and compute combined bounds first so the framing below
- # is based on this assembly, not a stale one.
- self._prepare_assembly_mesh(parts)
if camera is not None:
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
- self._image_label.setText("Click Preview or Render to start")
+ self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._populate_camera_controls()
- self._schedule_auto_preview()
+ self._start_meshing()
def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport).
@@ -1119,10 +1276,14 @@ class RenderTabContent(QWidget):
def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display."""
+ self._mesh_generation += 1
self._cancel_active_thread()
+ self._stop_thread(self._mesh_thread)
+ self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
+ self._assembly_pending = []
self._assembly_bounds = None
self._last_image = None
self._last_preview = None
@@ -1133,18 +1294,28 @@ class RenderTabContent(QWidget):
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
+ self._mesh_generation += 1
if self._auto_preview_timer and self._auto_preview_timer.isActive():
self._auto_preview_timer.stop()
- for thread in (self._preview_thread, self._render_thread):
- if thread and thread.isRunning():
- thread.cancel()
- thread.terminate()
- thread.wait(2000)
- if self._mesh_path and os.path.exists(self._mesh_path):
- try:
- os.unlink(self._mesh_path)
- except OSError:
- pass
+ # block=True: at tab exit / app shutdown a thread must not outlive
+ # the owning widget.
+ for thread in (self._mesh_thread, self._preview_thread, self._render_thread):
+ self._stop_thread(thread, block=True)
+ self._mesh_thread = None
+ self._preview_thread = None
+ self._render_thread = None
+ self._active_mode = None
+ # Delete temp PLY files (single shape plus all assembly parts).
+ paths = []
+ if self._mesh_path:
+ paths.append(self._mesh_path)
+ paths.extend(p for p, _ in self._assembly_parts)
+ for path in paths:
+ if path and os.path.exists(path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
self._mesh_path = None
# ── UI Setup ───────────────────────────────────────────────────
@@ -1500,56 +1671,72 @@ class RenderTabContent(QWidget):
self._preview_btn.setEnabled(False)
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
- def _prepare_mesh(self):
- if self._shape is None:
- return
- try:
- self._mesh_path = occ_shape_to_ply(
- self._shape, linear_deflection=0.1, angular_deflection=0.15
- )
- if self._camera is None:
- mn, mx = occ_shape_bounds(self._shape)
- self._camera = self._backend.default_camera_from_bounds(mn, mx)
- logger.info(f"Prepared mesh: {self._mesh_path}")
- except Exception as e:
- logger.error(f"Failed to prepare mesh: {e}")
- QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
+ def _start_meshing(self):
+ """Kick off background tessellation of the current shape/assembly.
- def _prepare_assembly_mesh(self, parts: list):
- """Tessellate multiple shapes to separate PLY files.
-
- *parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
- Each material preset name is resolved via ``get_preset``.
+ The GUI thread is never blocked: the tab shows "Tessellating…"
+ until the mesh is ready, then the auto-preview is scheduled.
"""
- from fluency.rendering.material_presets import get_preset
-
- self._assembly_parts = []
- first_bounds = None
- all_mins: list[float] = []
- all_maxs: list[float] = []
- for shape, mat_name in parts:
- try:
- mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
- material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
- self._assembly_parts.append((mesh_path, material))
- bounds = occ_shape_bounds(shape)
- all_mins.append(list(bounds[0]))
- all_maxs.append(list(bounds[1]))
- if first_bounds is None:
- first_bounds = bounds
- except Exception as e:
- logger.warning(f"Failed to tessellate assembly part: {e}")
- # Compute combined bounding box from all parts.
- if all_mins and all_maxs:
- combined_min = [min(a[i] for a in all_mins) for i in range(3)]
- combined_max = [max(a[i] for a in all_maxs) for i in range(3)]
- self._assembly_bounds = (combined_min, combined_max)
+ if self._shape is not None:
+ thread = _MeshThread((self._shape,), is_assembly=False, parent=self)
+ gen = self._mesh_generation
+ thread.mesh_ready.connect(lambda path, g=gen: self._on_mesh_ready(path, g))
+ thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
+ elif self._assembly_pending:
+ thread = _MeshThread(self._assembly_pending, is_assembly=True, parent=self)
+ gen = self._mesh_generation
+ thread.assembly_ready.connect(
+ lambda parts, bounds, first, g=gen: self._on_assembly_ready(parts, bounds, first, g)
+ )
+ thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
else:
- self._assembly_bounds = None
- if first_bounds and self._camera is None:
+ self._image_label.setText("Click Preview or Render to start")
+ return
+ thread.start()
+ self._mesh_thread = thread
+
+ def _on_mesh_ready(self, mesh_path: str, gen: int) -> None:
+ """Background tessellation finished (single shape)."""
+ if gen != self._mesh_generation:
+ # The load was replaced mid-tessellation — discard the stale mesh.
+ _unlink_quiet(mesh_path)
+ return
+ self._mesh_path = mesh_path
+ if self._camera is None and self._backend is not None:
+ mn, mx = occ_shape_bounds(self._shape)
+ self._camera = self._backend.default_camera_from_bounds(mn, mx)
+ self._populate_camera_controls()
+ if self._active_mode is None:
+ self._image_label.setText("Click Preview or Render to start")
+ logger.info(f"Prepared mesh: {self._mesh_path}")
+ # Trigger auto-preview when a new shape is loaded
+ self._schedule_auto_preview()
+
+ def _on_assembly_ready(self, parts: list, bounds, first_bounds, gen: int) -> None:
+ """Background tessellation finished (assembly)."""
+ if gen != self._mesh_generation:
+ for p, _ in parts:
+ _unlink_quiet(p)
+ return
+ self._assembly_parts = parts
+ self._assembly_bounds = bounds
+ if self._camera is None and self._backend is not None and first_bounds is not None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
+ self._populate_camera_controls()
+ if self._active_mode is None:
+ self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared assembly: {len(self._assembly_parts)} parts")
+ # Trigger auto-preview when a new assembly is loaded
+ self._schedule_auto_preview()
+
+ def _on_mesh_error(self, msg: str, gen: int) -> None:
+ if gen != self._mesh_generation:
+ return
+ logger.error(f"Failed to tessellate shape: {msg}")
+ self._image_label.setText("Click Preview or Render to start")
+ self._status_badge.setText("")
+ QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{msg}")
def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self)
@@ -1688,7 +1875,7 @@ class RenderTabContent(QWidget):
return
if self._active_mode is not None:
return
- if self._backend is None or self._mesh_path is None:
+ if self._backend is None or (self._mesh_path is None and not self._assembly_parts):
return
self._auto_preview_timer.start(500)
@@ -1740,15 +1927,55 @@ class RenderTabContent(QWidget):
def _cancel_active_thread(self):
if self._active_mode == "preview" and self._preview_thread:
- self._preview_thread.cancel()
- self._preview_thread.terminate()
- self._preview_thread.wait(2000)
+ self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
- self._render_thread.cancel()
- self._render_thread.terminate()
- self._render_thread.wait(2000)
+ self._stop_thread(self._render_thread)
self._active_mode = None
+ def _stop_thread(self, thread, block: bool = False):
+ """Cancel *thread*; detach without ever using ``terminate()``.
+
+ ``QThread.terminate()`` kills the thread mid-instruction inside
+ Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
+ the cooperative cancel flag is set and, if the thread is still
+ running, its result signals are disconnected and it is kept
+ referenced (``_retired_threads``) until it exits on its own — a
+ cancelled ``run()`` emits no results, so it cannot clobber the UI.
+
+ ``block=True`` (shutdown paths only) additionally waits up to 3 s
+ so a thread does not outlive the application. Interactive paths
+ keep the default and never stall the GUI thread.
+ """
+ if thread is None:
+ return
+ # Drop retired threads that have exited.
+ for t in list(_RETIRED_THREADS):
+ if not t.isRunning():
+ _RETIRED_THREADS.remove(t)
+ thread.cancel()
+ if thread.isRunning():
+ # Disconnect so a detached thread can't update the UI. Signals
+ # with no receiver only emit a RuntimeWarning on disconnect, so
+ # silence that specific case. Not every thread class defines
+ # every signal, so skip missing attributes.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", RuntimeWarning)
+ for name in ("finished", "error", "progress",
+ "mesh_ready", "assembly_ready"):
+ sig = getattr(thread, name, None)
+ if sig is None:
+ continue
+ try:
+ sig.disconnect()
+ except (RuntimeError, TypeError):
+ pass
+ _RETIRED_THREADS.append(thread)
+ # Reparent so destroying the owning widget can't delete a
+ # still-running QThread (a Qt fatal error).
+ thread.setParent(None)
+ if block:
+ thread.wait(3000)
+
def _set_buttons_rendering(self, mode: str):
self._preview_btn.setEnabled(False)
self._render_btn.setEnabled(False)
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index efe463a..72b00d1 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -548,8 +548,12 @@ class Sketch2DWidget(QWidget):
end_uv[0], end_uv[1]
)
# sweep=None → renderer computes shortest-path arc
+ # register=False: all three reference points are external
+ # (fixed) — registering the arc on top would
+ # over-constrain the solver (inconsistent).
self._sketch.add_arc(
- center_pt, radius, start_pt, end_pt, sweep=None
+ center_pt, radius, start_pt, end_pt,
+ sweep=None, register=False,
)
imported += 1
except Exception as exc:
diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py
index 4c25579..4bcd679 100644
--- a/src/fluency/ui/technical_drawing_widget.py
+++ b/src/fluency/ui/technical_drawing_widget.py
@@ -417,19 +417,15 @@ class TechnicalDrawingWidget(QWidget):
self._kernel = kernel
def set_active_component(self, component) -> None:
- """Use the given component as the drawing source and regenerate."""
+ """Use the given component as the drawing source."""
self._active_source_kind = "component"
self._active_source_id = component.id
- self._on_generate()
def set_active_assembly(self, assembly) -> None:
- """Use the given assembly as the drawing source and regenerate.
-
- The assembly is treated as a single fused part (all bodies merged).
- """
+ """Use the given assembly as the drawing source."""
self._active_source_kind = "assembly"
self._active_source_id = assembly.id
- self._on_generate()
+
def generate(self) -> None:
"""Public entry point: generate for the current source."""
diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py
index b876115..2634657 100644
--- a/src/fluency/ui/viewer_widget.py
+++ b/src/fluency/ui/viewer_widget.py
@@ -189,17 +189,27 @@ class Viewer3DWidget(QWidget):
self._ensure_initialized()
return self._renderer
- def show_shape(self, shape: Any, color=None, name=None) -> str:
+ def show_shape(
+ self,
+ shape: Any,
+ color=None,
+ name=None,
+ auto_fit: bool = True,
+ ) -> str:
"""Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer.
+
+ *auto_fit* is forwarded to the renderer: pass ``False`` when
+ rebuilding a scene under explicit camera control so the first
+ shape does not trigger a whole-scene camera fit.
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer):
- oid = self._renderer.add_shape(shape, color, name)
+ oid = self._renderer.add_shape(shape, color, name, auto_fit)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
@@ -930,6 +940,16 @@ class Viewer3DWidget(QWidget):
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
+ def show_persistent_connector_gizmo(self, origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)):
+ fn = getattr(self._renderer, "show_persistent_entity_gizmo", None)
+ if fn is not None:
+ fn(entity_type=entity_type, position=origin, normal=normal, x_dir=x_dir, color=color)
+
+ def clear_persistent_connector_gizmo(self):
+ fn = getattr(self._renderer, "clear_persistent_entity_gizmo", None)
+ if fn is not None:
+ fn()
+
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)