- Assembly instaiated operations
- assembly forward proagation
This commit is contained in:
Generated
+19
-13
@@ -4,11 +4,17 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- tech drawing and render improv">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="Fiexed highlighting of operations">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/WARP.md" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -120,14 +126,6 @@
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1703867682707</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00006" summary="- Added new componnt controls">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735601786207</created>
|
||||
<option name="number" value="00006" />
|
||||
<option name="presentableId" value="LOCAL-00006" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1735601786207</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00007" summary="- changing compos for sketches works">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735652081552</created>
|
||||
@@ -512,7 +510,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1787058413911</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="55" />
|
||||
<task id="LOCAL-00055" summary="Fiexed highlighting of operations">
|
||||
<option name="closed" value="true" />
|
||||
<created>1787091089061</created>
|
||||
<option name="number" value="00055" />
|
||||
<option name="presentableId" value="LOCAL-00055" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1787091089061</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="56" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -533,7 +539,6 @@
|
||||
<ignored-roots>
|
||||
<path value="$PROJECT_DIR$/pythonProject" />
|
||||
</ignored-roots>
|
||||
<MESSAGE value="- Added new buttons and settings" />
|
||||
<MESSAGE value="- Added construction lines switching - Moved callbacks into sketchwidget from main. - Changed reset on right click" />
|
||||
<MESSAGE value="- Added contrain displayed next to line - Slight change to point check from solver." />
|
||||
<MESSAGE value="- Added enabling of midpsnap and prepared others - Show dimesnion on hover" />
|
||||
@@ -558,6 +563,7 @@
|
||||
<MESSAGE value="- tech draw draft" />
|
||||
<MESSAGE value="- tech draw draft v2" />
|
||||
<MESSAGE value="- tech drawing and render improv" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- tech drawing and render improv" />
|
||||
<MESSAGE value="Fiexed highlighting of operations" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Fiexed highlighting of operations" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1903
-249
File diff suppressed because it is too large
Load Diff
+309
-82
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user