- Assembly instaiated operations

- assembly forward proagation
This commit is contained in:
bklronin
2026-08-19 20:14:14 +02:00
parent 6b6f7de5ab
commit 9abeb6266a
16 changed files with 3463 additions and 382 deletions
+19 -13
View File
@@ -4,11 +4,17 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <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$/.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/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/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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -120,14 +126,6 @@
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1703867682707</updated> <updated>1703867682707</updated>
</task> </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"> <task id="LOCAL-00007" summary="- changing compos for sketches works">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1735652081552</created> <created>1735652081552</created>
@@ -512,7 +510,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1787058413911</updated> <updated>1787058413911</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -533,7 +539,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- Added new buttons and settings" />
<MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" /> <MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" />
<MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." /> <MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." />
<MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" /> <MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" />
@@ -558,6 +563,7 @@
<MESSAGE value="- tech draw draft" /> <MESSAGE value="- tech draw draft" />
<MESSAGE value="- tech draw draft v2" /> <MESSAGE value="- tech draw draft v2" />
<MESSAGE value="- tech drawing and render improv" /> <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> </component>
</project> </project>
+238
View File
@@ -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")
+158
View File
@@ -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")
+89
View File
@@ -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()
+83
View File
@@ -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")
+149
View File
@@ -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.
+163 -16
View File
@@ -327,9 +327,16 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity, start_point: SketchEntity,
end_point: SketchEntity, end_point: SketchEntity,
sweep: Optional[float] = None, sweep: Optional[float] = None,
register: bool = True,
) -> OCCSketchEntity: ) -> OCCSketchEntity:
"""Add an arc (added to solver + tracked). """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 The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``, 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: if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch") 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") raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry cx, cy = center_entity.geometry
@@ -386,17 +397,20 @@ class OCCSketch(SketchInterface):
# whenever the workplane orientation changes. The normal is # whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` / # invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes). # ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None: if register:
self._wp_normal_handle = self._make_arc_normal_3d() if self._wp_normal_handle is None:
nm: Any = self._wp_normal_handle self._wp_normal_handle = self._make_arc_normal_3d()
assert nm is not None # _make_arc_normal_3d always returns a handle nm: Any = self._wp_normal_handle
arc_handle = self._solver.add_arc( assert nm is not None # _make_arc_normal_3d always returns a handle
nm, arc_handle = self._solver.add_arc(
center_entity.handle, nm,
start_entity.handle, center_entity.handle,
end_entity.handle, start_entity.handle,
self._wp, end_entity.handle,
) self._wp,
)
else:
arc_handle = None
entity = OCCSketchEntity( entity = OCCSketchEntity(
entity_id=entity_id, entity_id=entity_id,
@@ -418,6 +432,9 @@ class OCCSketch(SketchInterface):
"end": end_point.id, "end": end_point.id,
"radius": radius, "radius": radius,
"sweep": sweep, "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 # ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle # the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions`` # the arc is attached to) resizes, ``_sync_solved_positions``
@@ -587,6 +604,70 @@ class OCCSketch(SketchInterface):
pass pass
return all_points, all_lines 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: def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints. """Remove external entities from local tracking + prune their constraints.
@@ -606,6 +687,21 @@ class OCCSketch(SketchInterface):
self._lines.pop(eid, None) self._lines.pop(eid, None)
self._circles.pop(eid, None) self._circles.pop(eid, None)
self._arcs.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 # Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen # somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph). # 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 the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids) 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. """Re-project external (underlay) entities from updated source geometry.
Called when the 3D body the underlay was projected from has been 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 body so user geometry constrained to it propagates through the
solver. 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: Two paths:
* **In-place update** (same topology): when the new projection has * **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. Returns True when the underlay was updated and solved OK.
""" """
# Flatten the new projection into unique corner positions + segments. # 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 tol = self._EXTERNAL_MERGE_TOL
new_pts: List[Tuple[float, float]] = [] new_pts: List[Tuple[float, float]] = []
@@ -674,7 +791,7 @@ class OCCSketch(SketchInterface):
return len(new_pts) - 1 return len(new_pts) - 1
new_segs: List[Tuple[int, int]] = [] new_segs: List[Tuple[int, int]] = []
for poly in polylines: for poly in polys:
if len(poly) < 2: if len(poly) < 2:
continue continue
idx = [new_index(float(u), float(v)) for (u, v) in poly] 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 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: if same_topology and old_ext_points:
# Greedy one-to-one nearest matching old point -> new position. # 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 # points and (per the add_point guard) does not auto-anchor a user
# point — which would conflict with the re-bound coincidents below. # point — which would conflict with the re-bound coincidents below.
self._drop_external_entities() 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_solver()
self._rebuild_labels() self._rebuild_labels()
@@ -1125,6 +1249,8 @@ class OCCSketch(SketchInterface):
assert nm is not None assert nm is not None
for aid in sorted(self._arcs.keys()): for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid] 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") c_id = arc_data.get("center")
s_id = arc_data.get("start") s_id = arc_data.get("start")
e_id = arc_data.get("end") e_id = arc_data.get("end")
@@ -2725,6 +2851,9 @@ class OCCSketch(SketchInterface):
"end": tuple(e_ent.geometry), "end": tuple(e_ent.geometry),
"radius": radius_val, "radius": radius_val,
"sweep": sweep_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( 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: 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) logger.warning("Skipping arc %s during load: endpoints not found", eid)
return 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( ent = self.add_arc(
entities_by_id[c_id], entities_by_id[c_id],
radius, radius,
entities_by_id[s_id], entities_by_id[s_id],
entities_by_id[e_id], entities_by_id[e_id],
sweep=sweep, sweep=sweep,
register=in_solver,
) )
else: else:
logger.warning("Unknown sketch entity type %r; skipping", etype) logger.warning("Unknown sketch entity type %r; skipping", etype)
+103 -8
View File
@@ -495,6 +495,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"offset": _to_float(conn.offset, 0.0), "offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id, "assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_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_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id, "partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded), "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), offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""), assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_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_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_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), "position": _coerce_listlike(ac.position),
"rotation": _coerce_listlike(ac.rotation), "rotation": _coerce_listlike(ac.rotation),
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()}, "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, "created_at": ac.created_at.isoformat() if ac.created_at else None,
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None, "modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
} }
def _assembly_component_from_dict(
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent: data: Dict[str, Any],
component: Optional[Component] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> AssemblyComponent:
ac = AssemblyComponent( ac = AssemblyComponent(
id=_saved_id(data), id=_saved_id(data),
component_id=data.get("component_id", ""), 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")) ac.modified_at = _parse_iso(data.get("modified_at"))
for cid, c_data in (data.get("connectors") or {}).items(): for cid, c_data in (data.get("connectors") or {}).items():
ac.connectors[cid] = _connector_from_dict(c_data) 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 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, "modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
} }
def _assembly_from_dict(
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly: data: Dict[str, Any],
components: Optional[Dict[str, Component]] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Assembly:
asm = Assembly( asm = Assembly(
id=_saved_id(data), id=_saved_id(data),
name=data.get("name", "Untitled Assembly"), 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.created_at = _parse_iso(data.get("created_at"))
asm.modified_at = _parse_iso(data.get("modified_at")) asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items(): 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 []: for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data)) asm.connections.append(_assembly_connection_from_dict(c_data))
return asm return asm
@@ -894,6 +944,40 @@ def save_project(
sketch_files.append((arcname, step_bytes)) sketch_files.append((arcname, step_bytes))
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname 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 # Write the ZIP. Use a temp file + rename so a partial write can't
# clobber an existing good file. # clobber an existing good file.
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency") 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 # 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. # it in now and patch the manifest so _sketch_from_dict sees it.
for comp_id, comp_data in (manifest.get("components") or {}).items(): # Applies to both component sketches and instance-local sketches.
for sk_id, sk_data in (comp_data.get("sketches") or {}).items(): 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") ref = sk_data.get("occ_sketch_ref")
if not ref: if not ref:
continue continue
@@ -987,6 +1072,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
if k in meta: if k in meta:
sk_data[k] = meta[k] 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( project = Project(
name=manifest.get("name", "Untitled Project"), name=manifest.get("name", "Untitled Project"),
description=manifest.get("description", ""), 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(): 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 []: for d_data in manifest.get("drawings") or []:
try: try:
+77
View File
@@ -487,6 +487,16 @@ class Connector:
assembly_component_id: str = "" assembly_component_id: str = ""
# Which body/face this connector was placed on (renderer obj_id). # Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = "" 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) --- # --- Rigid-group pairing (set when two connectors are mated) ---
# The id of the partner AssemblyComponent this connector is mated to. # 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) created_at: datetime = field(default_factory=datetime.now)
modified_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 @dataclass
class AssemblyComponent: class AssemblyComponent:
@@ -529,6 +549,20 @@ class AssemblyComponent:
# Connectors defined on this component instance. # Connectors defined on this component instance.
connectors: Dict[str, Connector] = field(default_factory=dict) 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) created_at: datetime = field(default_factory=datetime.now)
modified_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], x_dir: Tuple[float, float, float],
source_obj_id: str = "", source_obj_id: str = "",
name: Optional[str] = None, name: Optional[str] = None,
entity_type: str = "",
normal_flip: bool = False,
) -> Connector: ) -> Connector:
"""Add a connector to this component instance.""" """Add a connector to this component instance."""
conn = Connector( conn = Connector(
@@ -548,6 +584,8 @@ class AssemblyComponent:
x_dir=x_dir, x_dir=x_dir,
assembly_component_id=self.id, assembly_component_id=self.id,
source_obj_id=source_obj_id, source_obj_id=source_obj_id,
entity_type=entity_type,
normal_flip=normal_flip,
) )
self.connectors[conn.id] = conn self.connectors[conn.id] = conn
self.modified_at = datetime.now() self.modified_at = datetime.now()
@@ -561,6 +599,45 @@ class AssemblyComponent:
return True return True
return False 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 @dataclass
class AssemblyConnection: class AssemblyConnection:
+142 -4
View File
@@ -164,12 +164,17 @@ class OCCRenderer(Renderer):
# Smart entity picker gizmo objects (snap markers, axis lines, rings). # Smart entity picker gizmo objects (snap markers, axis lines, rings).
# Keyed by a synthetic id; values are raw AIS_InteractiveObject. # Keyed by a synthetic id; values are raw AIS_InteractiveObject.
self._gizmo_objects: Dict[str, Any] = {} 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 # World-anchored sketch reference gizmo (a triad at the sketch
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) → # midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}. # dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
self._sketch_gizmo_parts: Dict[str, Any] = {} self._sketch_gizmo_parts: Dict[str, Any] = {}
# Part kind currently highlighted on hover (for restore-on-leave). # Part kind currently highlighted on hover (for restore-on-leave).
self._sketch_gizmo_highlighted: Optional[str] = None 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: def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget).""" """Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -335,10 +340,16 @@ class OCCRenderer(Renderer):
shape: Any, shape: Any,
color: Optional[Tuple[float, float, float]] = None, color: Optional[Tuple[float, float, float]] = None,
name: Optional[str] = None, name: Optional[str] = None,
auto_fit: bool = True,
) -> str: ) -> str:
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``. """Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
Returns a unique object ID (or *name* if provided). 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.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
@@ -398,7 +409,7 @@ class OCCRenderer(Renderer):
self._objects[obj_id] = robj self._objects[obj_id] = robj
# Fit camera on first shape added. # Fit camera on first shape added.
if len(self._objects) == 1: if auto_fit and len(self._objects) == 1:
try: try:
self.fit_camera() self.fit_camera()
except Exception: except Exception:
@@ -1514,6 +1525,10 @@ class OCCRenderer(Renderer):
""" """
if shape is None: if shape is None:
return [] 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.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve 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: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view) x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [ res = [
{ {
"type": "planar_face", "type": "planar_face",
"position": origin, "position": origin,
@@ -1585,6 +1600,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }
] ]
self._classify_cache[cache_key] = res
return res
elif stype == GeomAbs_Cylinder: elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder() cyl = adaptor.Cylinder()
@@ -1692,6 +1709,7 @@ class OCCRenderer(Renderer):
"radius": radius, "radius": radius,
} }
) )
self._classify_cache[cache_key] = results
return results return results
# Try edge. # Try edge.
@@ -1735,7 +1753,7 @@ class OCCRenderer(Renderer):
x = x / xlen x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2])) x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [ res = [
{ {
"type": "edge", "type": "edge",
"position": position, "position": position,
@@ -1745,6 +1763,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }
] ]
self._classify_cache[cache_key] = res
return res
# Try vertex. # Try vertex.
vertex = None vertex = None
@@ -1752,7 +1772,7 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape) vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex) p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z()) position = (p.X(), p.Y(), p.Z())
return [ res = [
{ {
"type": "vertex", "type": "vertex",
"position": position, "position": position,
@@ -1762,9 +1782,12 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }
] ]
self._classify_cache[cache_key] = res
return res
except Exception: except Exception:
pass pass
self._classify_cache[cache_key] = []
return [] return []
def probe_snap_candidates( def probe_snap_candidates(
@@ -2467,6 +2490,114 @@ class OCCRenderer(Renderer):
if self._view is not None: if self._view is not None:
self._view.Update() 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 ─────────────────────────────────────────── # ─── Selection mode control ───────────────────────────────────────────
# #
# When connector gizmo mode is active, standard OCC face/edge/vertex # 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 (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 return results
def recognize_composite_features( def recognize_composite_features(
File diff suppressed because it is too large Load Diff
+309 -82
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging import logging
import os import os
import warnings
from typing import Optional from typing import Optional
import numpy as np import numpy as np
@@ -48,6 +49,24 @@ from fluency.rendering.render_backend import (
logger = logging.getLogger(__name__) 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 ──────────────────────────────────────── # ── Background render thread ────────────────────────────────────────
@@ -153,6 +172,77 @@ class _AssemblyRenderThread(QThread):
self.error.emit(str(e)) 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 ─────────────────────────────────────────────────── # ── Render window ───────────────────────────────────────────────────
@@ -602,6 +692,8 @@ class RenderWindow(QMainWindow):
"""Reset camera parameters to match the 3D viewport.""" """Reset camera parameters to match the 3D viewport."""
if self._camera is None: if self._camera is None:
return return
o = self._camera.origin
t = self._camera.target
u = self._camera.up u = self._camera.up
self._cam_origin_x.setValue(o[0]) self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1]) self._cam_origin_y.setValue(o[1])
@@ -681,6 +773,8 @@ class RenderWindow(QMainWindow):
"""Fill camera spinboxes from the current RenderCamera.""" """Fill camera spinboxes from the current RenderCamera."""
if self._camera is None: if self._camera is None:
return return
o = self._camera.origin
t = self._camera.target
u = self._camera.up u = self._camera.up
self._cam_origin_x.setValue(o[0]) self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1]) 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;") self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self): 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: if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel() self._stop_thread(self._preview_thread)
self._preview_thread.terminate()
self._preview_thread.wait(2000)
elif self._active_mode == "render" and self._render_thread: elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel() self._stop_thread(self._render_thread)
self._render_thread.terminate()
self._render_thread.wait(2000)
self._active_mode = None 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): def _set_buttons_rendering(self, mode: str):
"""Disable buttons while rendering.""" """Disable buttons while rendering."""
self._preview_btn.setEnabled(False) self._preview_btn.setEnabled(False)
@@ -970,10 +1110,8 @@ class RenderWindow(QMainWindow):
# Kill both possible threads # Kill both possible threads
for thread in (self._preview_thread, self._render_thread): for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning(): # block=True: at window close a thread must not outlive the app.
thread.cancel() self._stop_thread(thread, block=True)
thread.terminate()
thread.wait(2000)
# Clean up temp mesh file # Clean up temp mesh file
if self._mesh_path and os.path.exists(self._mesh_path): if self._mesh_path and os.path.exists(self._mesh_path):
@@ -1014,6 +1152,13 @@ class RenderTabContent(QWidget):
# Rendering threads & images # Rendering threads & images
self._render_thread: Optional[_RenderThread] = None self._render_thread: Optional[_RenderThread] = None
self._preview_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_image: Optional[np.ndarray] = None
self._last_preview: Optional[np.ndarray] = None self._last_preview: Optional[np.ndarray] = None
self._camera: Optional[RenderCamera] = None self._camera: Optional[RenderCamera] = None
@@ -1034,16 +1179,26 @@ class RenderTabContent(QWidget):
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None: def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
"""Load a new OCC TopoDS_Shape for rendering. """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 *camera* if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing. 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. # Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread() 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 # Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly). # render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = [] self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None 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. # trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None self._mesh_path = None
self._shape = shape self._shape = shape
@@ -1052,13 +1207,11 @@ class RenderTabContent(QWidget):
self._last_image = None self._last_image = None
self._last_preview = None self._last_preview = None
self._image_label.setPixmap(QPixmap()) 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._status_badge.setText("")
self._export_btn.setEnabled(False) self._export_btn.setEnabled(False)
self._prepare_mesh()
self._populate_camera_controls() self._populate_camera_controls()
# Trigger auto-preview when a new shape is loaded self._start_meshing()
self._schedule_auto_preview()
def get_camera(self) -> Optional[RenderCamera]: def get_camera(self) -> Optional[RenderCamera]:
"""Return the current camera (from UI controls or initial).""" """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 *parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name. 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. # Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread() self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None self._shape = None
self._mesh_path = None self._mesh_path = None
self._assembly_parts = [] self._assembly_parts = []
self._assembly_pending = list(parts)
self._assembly_bounds = None 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: if camera is not None:
self._camera = self._apply_framing(camera) self._camera = self._apply_framing(camera)
self._last_image = None self._last_image = None
self._last_preview = None self._last_preview = None
self._image_label.setPixmap(QPixmap()) 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._status_badge.setText("")
self._export_btn.setEnabled(False) self._export_btn.setEnabled(False)
self._populate_camera_controls() self._populate_camera_controls()
self._schedule_auto_preview() self._start_meshing()
def set_camera(self, camera: RenderCamera) -> None: def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport). """Update the render camera from an external source (e.g. 3D viewport).
@@ -1119,10 +1276,14 @@ class RenderTabContent(QWidget):
def clear(self) -> None: def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display.""" """Remove any loaded shape/assembly and reset the display."""
self._mesh_generation += 1
self._cancel_active_thread() self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None self._shape = None
self._mesh_path = None self._mesh_path = None
self._assembly_parts = [] self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None self._assembly_bounds = None
self._last_image = None self._last_image = None
self._last_preview = None self._last_preview = None
@@ -1133,18 +1294,28 @@ class RenderTabContent(QWidget):
def cleanup(self) -> None: def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed.""" """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(): if self._auto_preview_timer and self._auto_preview_timer.isActive():
self._auto_preview_timer.stop() self._auto_preview_timer.stop()
for thread in (self._preview_thread, self._render_thread): # block=True: at tab exit / app shutdown a thread must not outlive
if thread and thread.isRunning(): # the owning widget.
thread.cancel() for thread in (self._mesh_thread, self._preview_thread, self._render_thread):
thread.terminate() self._stop_thread(thread, block=True)
thread.wait(2000) self._mesh_thread = None
if self._mesh_path and os.path.exists(self._mesh_path): self._preview_thread = None
try: self._render_thread = None
os.unlink(self._mesh_path) self._active_mode = None
except OSError: # Delete temp PLY files (single shape plus all assembly parts).
pass 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 self._mesh_path = None
# ── UI Setup ─────────────────────────────────────────────────── # ── UI Setup ───────────────────────────────────────────────────
@@ -1500,56 +1671,72 @@ class RenderTabContent(QWidget):
self._preview_btn.setEnabled(False) self._preview_btn.setEnabled(False)
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)") self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
def _prepare_mesh(self): def _start_meshing(self):
if self._shape is None: """Kick off background tessellation of the current shape/assembly.
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 _prepare_assembly_mesh(self, parts: list): The GUI thread is never blocked: the tab shows "Tessellating…"
"""Tessellate multiple shapes to separate PLY files. until the mesh is ready, then the auto-preview is scheduled.
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
Each material preset name is resolved via ``get_preset``.
""" """
from fluency.rendering.material_presets import get_preset if self._shape is not None:
thread = _MeshThread((self._shape,), is_assembly=False, parent=self)
self._assembly_parts = [] gen = self._mesh_generation
first_bounds = None thread.mesh_ready.connect(lambda path, g=gen: self._on_mesh_ready(path, g))
all_mins: list[float] = [] thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
all_maxs: list[float] = [] elif self._assembly_pending:
for shape, mat_name in parts: thread = _MeshThread(self._assembly_pending, is_assembly=True, parent=self)
try: gen = self._mesh_generation
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15) thread.assembly_ready.connect(
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel") lambda parts, bounds, first, g=gen: self._on_assembly_ready(parts, bounds, first, g)
self._assembly_parts.append((mesh_path, material)) )
bounds = occ_shape_bounds(shape) thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
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)
else: else:
self._assembly_bounds = None self._image_label.setText("Click Preview or Render to start")
if first_bounds and self._camera is None: 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 mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx) 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") 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): def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self) self._auto_preview_timer = QTimer(self)
@@ -1688,7 +1875,7 @@ class RenderTabContent(QWidget):
return return
if self._active_mode is not None: if self._active_mode is not None:
return 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 return
self._auto_preview_timer.start(500) self._auto_preview_timer.start(500)
@@ -1740,15 +1927,55 @@ class RenderTabContent(QWidget):
def _cancel_active_thread(self): def _cancel_active_thread(self):
if self._active_mode == "preview" and self._preview_thread: if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel() self._stop_thread(self._preview_thread)
self._preview_thread.terminate()
self._preview_thread.wait(2000)
elif self._active_mode == "render" and self._render_thread: elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel() self._stop_thread(self._render_thread)
self._render_thread.terminate()
self._render_thread.wait(2000)
self._active_mode = None 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): def _set_buttons_rendering(self, mode: str):
self._preview_btn.setEnabled(False) self._preview_btn.setEnabled(False)
self._render_btn.setEnabled(False) self._render_btn.setEnabled(False)
+5 -1
View File
@@ -548,8 +548,12 @@ class Sketch2DWidget(QWidget):
end_uv[0], end_uv[1] end_uv[0], end_uv[1]
) )
# sweep=None → renderer computes shortest-path arc # 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( 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 imported += 1
except Exception as exc: except Exception as exc:
+3 -7
View File
@@ -417,19 +417,15 @@ class TechnicalDrawingWidget(QWidget):
self._kernel = kernel self._kernel = kernel
def set_active_component(self, component) -> None: 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_kind = "component"
self._active_source_id = component.id self._active_source_id = component.id
self._on_generate()
def set_active_assembly(self, assembly) -> None: def set_active_assembly(self, assembly) -> None:
"""Use the given assembly as the drawing source and regenerate. """Use the given assembly as the drawing source."""
The assembly is treated as a single fused part (all bodies merged).
"""
self._active_source_kind = "assembly" self._active_source_kind = "assembly"
self._active_source_id = assembly.id self._active_source_id = assembly.id
self._on_generate()
def generate(self) -> None: def generate(self) -> None:
"""Public entry point: generate for the current source.""" """Public entry point: generate for the current source."""
+22 -2
View File
@@ -189,17 +189,27 @@ class Viewer3DWidget(QWidget):
self._ensure_initialized() self._ensure_initialized()
return self._renderer 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. """Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer. 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() self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, 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() self._renderer.render()
return oid return oid
# Fallback: tessellate and use the mesh pipeline. # Fallback: tessellate and use the mesh pipeline.
@@ -930,6 +940,16 @@ class Viewer3DWidget(QWidget):
def is_connector_pick_mode(self) -> bool: def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode 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: def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo.""" """Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None) fn = getattr(self._renderer, "clear_entity_gizmo", None)