- 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
+163 -16
View File
@@ -327,9 +327,16 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity,
end_point: SketchEntity,
sweep: Optional[float] = None,
register: bool = True,
) -> OCCSketchEntity:
"""Add an arc (added to solver + tracked).
*register* False keeps the arc tracked-only (no solver entity, no
handle) — for reference geometry whose three reference points are
all already fixed, e.g. external underlay arcs: registering the
arc on top of three dragged points over-constrains the solver
(SolveSpace reports the system as inconsistent).
The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
@@ -362,7 +369,11 @@ class OCCSketch(SketchInterface):
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
if register and (
center_entity.handle is None
or start_entity.handle is None
or end_entity.handle is None
):
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry
@@ -386,17 +397,20 @@ class OCCSketch(SketchInterface):
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
if register:
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
else:
arc_handle = None
entity = OCCSketchEntity(
entity_id=entity_id,
@@ -418,6 +432,9 @@ class OCCSketch(SketchInterface):
"end": end_point.id,
"radius": radius,
"sweep": sweep,
# Tracked-only arcs (register=False) have no solver entity and
# are skipped by ``_rebuild_solver``.
"in_solver": register,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
@@ -587,6 +604,70 @@ class OCCSketch(SketchInterface):
pass
return all_points, all_lines
def _import_external_curves(
self,
circles: List[Dict[str, Any]],
arcs: List[Dict[str, Any]],
) -> None:
"""Import projected circle/arc dicts as external underlay entities.
Mirrors the widget's initial underlay import: a circle becomes a
fixed external centre point plus a tracked circle entity; an arc
becomes a fixed external centre point, endpoint entities (shared
with the polyline corners already imported, so the arc connects
to the adjacent lines), and an arc entity. Must be called after
``add_external_polylines`` and before ``_rebuild_solver`` — the
rebuild re-registers arcs in the fresh solver and re-fixes every
external point.
"""
for c in circles:
try:
center_uv = (float(c["center"][0]), float(c["center"][1]))
center_pt = self.add_external_point(center_uv[0], center_uv[1])
self.add_circle(center_pt, float(c["radius"]))
except Exception as exc:
logger.debug("external circle import failed: %s", exc)
# Generous tolerance: arc endpoints come from a fresh projection of
# the face and must land on the existing corner points despite float
# drift (same rule as the widget's initial import).
merge_tol = 1e-3
def find_pt(u: float, v: float) -> Optional[OCCSketchEntity]:
best: Optional[OCCSketchEntity] = None
best_d = merge_tol
for eid in self._external_entity_ids:
ent = self._entities.get(eid)
if ent is None or ent.entity_type != "point" or ent.geometry is None:
continue
d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
if d <= best_d:
best_d = d
best = ent
return best
for a in arcs:
try:
center_uv = (float(a["center"][0]), float(a["center"][1]))
start_uv = (float(a["start"][0]), float(a["start"][1]))
end_uv = (float(a["end"][0]), float(a["end"][1]))
center_pt = self.add_external_point(center_uv[0], center_uv[1])
start_pt = find_pt(start_uv[0], start_uv[1])
if start_pt is None:
start_pt = self.add_external_point(start_uv[0], start_uv[1])
end_pt = find_pt(end_uv[0], end_uv[1])
if end_pt is None:
end_pt = self.add_external_point(end_uv[0], end_uv[1])
# register=False: all three reference points are external
# (dragged/fixed) — registering the arc on top would
# over-constrain the solver (inconsistent).
self.add_arc(
center_pt, float(a["radius"]), start_pt, end_pt,
sweep=None, register=False,
)
except Exception as exc:
logger.debug("external arc import failed: %s", exc)
def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints.
@@ -606,6 +687,21 @@ class OCCSketch(SketchInterface):
self._lines.pop(eid, None)
self._circles.pop(eid, None)
self._arcs.pop(eid, None)
# Underlay circle/arc entities are tracked (not tagged external):
# drop any whose reference points just went away, or a re-import
# would pile stale duplicates on top of the fresh ones.
for cid, (cent_id, _radius) in list(self._circles.items()):
if cent_id in removed:
del self._circles[cid]
self._entities.pop(cid, None)
for aid, arc_data in list(self._arcs.items()):
if (
arc_data.get("center") in removed
or arc_data.get("start") in removed
or arc_data.get("end") in removed
):
del self._arcs[aid]
self._entities.pop(aid, None)
# Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph).
@@ -637,7 +733,7 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids)
def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
def update_external_entities(self, polylines: List[Any]) -> bool:
"""Re-project external (underlay) entities from updated source geometry.
Called when the 3D body the underlay was projected from has been
@@ -646,6 +742,11 @@ class OCCSketch(SketchInterface):
body so user geometry constrained to it propagates through the
solver.
*polylines* is the raw output of ``_project_face_to_uv``: a mixed
list of plain polylines (lists of ``(u, v)``) and curve dicts
(``{"type": "circle", ...}`` / ``{"type": "arc", ...}``) for
circular/arc face edges.
Two paths:
* **In-place update** (same topology): when the new projection has
@@ -663,6 +764,22 @@ class OCCSketch(SketchInterface):
Returns True when the underlay was updated and solved OK.
"""
# Flatten the new projection into unique corner positions + segments.
# The projection mixes plain polylines with curve dicts; polylines
# carry the corner/segment topology used below, curve dicts are
# only handled by the rebuild + rebind path.
polys: List[List[Tuple[float, float]]] = []
circles: List[Dict[str, Any]] = []
arcs: List[Dict[str, Any]] = []
for entry in polylines:
if isinstance(entry, dict):
etype = entry.get("type")
if etype == "circle":
circles.append(entry)
elif etype == "arc":
arcs.append(entry)
elif isinstance(entry, (list, tuple)):
polys.append(list(entry))
tol = self._EXTERNAL_MERGE_TOL
new_pts: List[Tuple[float, float]] = []
@@ -674,7 +791,7 @@ class OCCSketch(SketchInterface):
return len(new_pts) - 1
new_segs: List[Tuple[int, int]] = []
for poly in polylines:
for poly in polys:
if len(poly) < 2:
continue
idx = [new_index(float(u), float(v)) for (u, v) in poly]
@@ -694,7 +811,13 @@ class OCCSketch(SketchInterface):
lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
]
same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
# Curve entries force the rebuild path: the in-place branch only
# moves point/line entities and cannot represent a circle or arc.
same_topology = (
not (circles or arcs)
and len(new_pts) == len(old_ext_points)
and len(new_segs) == len(old_ext_lines)
)
if same_topology and old_ext_points:
# Greedy one-to-one nearest matching old point -> new position.
@@ -769,7 +892,8 @@ class OCCSketch(SketchInterface):
# points and (per the add_point guard) does not auto-anchor a user
# point — which would conflict with the re-bound coincidents below.
self._drop_external_entities()
self.add_external_polylines(polylines)
self.add_external_polylines(polys)
self._import_external_curves(circles, arcs)
self._rebuild_solver()
self._rebuild_labels()
@@ -1125,6 +1249,8 @@ class OCCSketch(SketchInterface):
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
if not arc_data.get("in_solver", True):
continue # tracked-only (underlay) arc — no solver state
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
@@ -2725,6 +2851,9 @@ class OCCSketch(SketchInterface):
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
# Tracked-only underlay arcs must not be
# re-registered with the solver on load.
"in_solver": bool(arc_data.get("in_solver", True)),
}
entities_payload.append(
{
@@ -2941,12 +3070,30 @@ class OCCSketch(SketchInterface):
if c_id is None or s_id is None or e_id is None:
logger.warning("Skipping arc %s during load: endpoints not found", eid)
return
in_solver = bool(geom.get("in_solver", True))
if "in_solver" not in geom:
# Old files: an arc whose three reference points are
# all external is an underlay arc — re-registering it
# over-constrains the solver (inconsistent).
c_ent_r = entities_by_id.get(c_id)
s_ent_r = entities_by_id.get(s_id)
e_ent_r = entities_by_id.get(e_id)
if (
c_ent_r is not None
and s_ent_r is not None
and e_ent_r is not None
and getattr(c_ent_r, "is_external", False)
and getattr(s_ent_r, "is_external", False)
and getattr(e_ent_r, "is_external", False)
):
in_solver = False
ent = self.add_arc(
entities_by_id[c_id],
radius,
entities_by_id[s_id],
entities_by_id[e_id],
sweep=sweep,
register=in_solver,
)
else:
logger.warning("Unknown sketch entity type %r; skipping", etype)
+103 -8
View File
@@ -495,6 +495,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id,
"entity_type": conn.entity_type,
"normal_flip": bool(conn.normal_flip),
"partner_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded),
@@ -515,6 +517,8 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""),
entity_type=data.get("entity_type", ""),
normal_flip=bool(data.get("normal_flip", False)),
)
conn.partner_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_id")
@@ -533,12 +537,22 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
"position": _coerce_listlike(ac.position),
"rotation": _coerce_listlike(ac.rotation),
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
# Instance-local sketches + per-body modifier ops (kept apart from
# the shared component so save/load never mutates the base model).
"sketches": {sid: _sketch_to_dict(sk) for sid, sk in ac.sketches.items()},
"modifiers": {
bid: [_feature_to_dict(f) for f in mods]
for bid, mods in ac.modifiers.items()
},
"created_at": ac.created_at.isoformat() if ac.created_at else None,
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
}
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
def _assembly_component_from_dict(
data: Dict[str, Any],
component: Optional[Component] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> AssemblyComponent:
ac = AssemblyComponent(
id=_saved_id(data),
component_id=data.get("component_id", ""),
@@ -550,6 +564,36 @@ def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac.modified_at = _parse_iso(data.get("modified_at"))
for cid, c_data in (data.get("connectors") or {}).items():
ac.connectors[cid] = _connector_from_dict(c_data)
# Instance-local sketches first, so modifier sketch references can
# resolve against them (they live in component-local coordinates).
for sid, sk_data in (data.get("sketches") or {}).items():
try:
ac.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
except Exception:
logger.warning("Skipping corrupt instance sketch %s", sid)
# Modifiers resolve sketch refs against the owning component's sketches
# first, then this instance's own sketches.
registry: Dict[str, Sketch] = {}
if component is not None:
registry.update(component.sketches)
registry.update(ac.sketches)
for bid, f_list in (data.get("modifiers") or {}).items():
kept: List[Feature] = []
for f_data in f_list or []:
try:
feat = _feature_from_dict(f_data, registry)
except Exception:
logger.warning("Skipping corrupt instance modifier on body %s", bid)
continue
# A sketch-based op whose sketch failed to load can never
# replay — dropping it keeps the rest of the chain usable.
if feat.operation in ("extrude", "cut", "union", "revolve") and feat.sketch is None:
continue
kept.append(feat)
if kept:
ac.modifiers[bid] = kept
return ac
@@ -587,8 +631,11 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
}
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
def _assembly_from_dict(
data: Dict[str, Any],
components: Optional[Dict[str, Component]] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Assembly:
asm = Assembly(
id=_saved_id(data),
name=data.get("name", "Untitled Assembly"),
@@ -597,7 +644,10 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.created_at = _parse_iso(data.get("created_at"))
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data)
comp = (components or {}).get(ac_data.get("component_id", ""))
asm.components[cid] = _assembly_component_from_dict(
ac_data, component=comp, sketch_geometry_loader=sketch_geometry_loader
)
for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data))
return asm
@@ -894,6 +944,40 @@ def save_project(
sketch_files.append((arcname, step_bytes))
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
# Instance-local sketches (assembly components) get the same sidecar
# treatment as component sketches; the manifest nodes are patched in
# place under the assembly's component entry.
for asm_id, asm in project.assemblies.items():
for ac_id, ac in asm.components.items():
for sketch_id, sketch in ac.sketches.items():
node = manifest["assemblies"][asm_id]["components"][ac_id]["sketches"].get(sketch_id)
if node is None:
continue
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
meta = {
"id": sketch.id,
"name": sketch.name,
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
"is_solved": bool(sketch.is_solved),
"is_fully_constrained": bool(sketch.is_fully_constrained),
"occ_sketch": occ,
}
meta_arc = f"sketches/{sketch_id}/meta.json"
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
node["occ_sketch"] = None
node["occ_sketch_ref"] = meta_arc
if sketch.geometry is None:
continue
step_bytes = _write_step_for_body(kernel, sketch.geometry)
if step_bytes is None:
continue
arcname = f"sketches/{sketch_id}/solved.step"
sketch_files.append((arcname, step_bytes))
node["geometry_ref"] = arcname
# Write the ZIP. Use a temp file + rename so a partial write can't
# clobber an existing good file.
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
@@ -959,8 +1043,9 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
# If a sketch's occ_sketch is referenced as a separate file, read
# it in now and patch the manifest so _sketch_from_dict sees it.
for comp_id, comp_data in (manifest.get("components") or {}).items():
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
# Applies to both component sketches and instance-local sketches.
def _patch_sketch_sidecars(sketches_dict: Dict[str, Any]) -> None:
for sk_id, sk_data in (sketches_dict or {}).items():
ref = sk_data.get("occ_sketch_ref")
if not ref:
continue
@@ -987,6 +1072,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
if k in meta:
sk_data[k] = meta[k]
for comp_id, comp_data in (manifest.get("components") or {}).items():
_patch_sketch_sidecars(comp_data.get("sketches"))
for aid, a_data in (manifest.get("assemblies") or {}).items():
for ac_id, ac_data in (a_data.get("components") or {}).items():
_patch_sketch_sidecars(ac_data.get("sketches"))
project = Project(
name=manifest.get("name", "Untitled Project"),
description=manifest.get("description", ""),
@@ -1006,7 +1097,11 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
)
for aid, a_data in (manifest.get("assemblies") or {}).items():
project.assemblies[aid] = _assembly_from_dict(a_data)
project.assemblies[aid] = _assembly_from_dict(
a_data,
components=project.components,
sketch_geometry_loader=sketch_geometry_loader,
)
for d_data in manifest.get("drawings") or []:
try:
+77
View File
@@ -487,6 +487,16 @@ class Connector:
assembly_component_id: str = ""
# Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = ""
# Entity class the connector was picked on ("planar_face",
# "cylindrical_face", "edge", "vertex"). Used to re-locate the
# connector on rebuilt geometry: only features of the same class are
# considered, so a hole connector can never jump onto a flat face.
# Empty for legacy files (all classes are then searched).
entity_type: str = ""
# Flip chosen in the placement dialog (bolt enters from the opposite
# side). Re-applied when a mated pair is re-solved so the original
# mate pose is reproduced exactly.
normal_flip: bool = False
# --- Rigid-group pairing (set when two connectors are mated) ---
# The id of the partner AssemblyComponent this connector is mated to.
@@ -506,6 +516,16 @@ class Connector:
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def __post_init__(self) -> None:
# Legacy files predate the entity_type field: recover it from the
# auto-generated connector name ("Conn cylindrical_face anchor") so
# relocation can restrict its search to the same feature class.
if not self.entity_type:
for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
if self.name in (f"Conn {t} anchor", f"Conn {t} mover"):
self.entity_type = t
break
@dataclass
class AssemblyComponent:
@@ -529,6 +549,20 @@ class AssemblyComponent:
# Connectors defined on this component instance.
connectors: Dict[str, Connector] = field(default_factory=dict)
# Instance-local (per-instantiation) state. Kept separate from the
# shared component so per-instance work never leaks back into the base
# model. ``sketches`` are instance-local sketches stored in
# component-local coordinates (so they stay valid when the instance is
# moved / rotated); ``modifiers`` maps body_id to an ordered list of
# Feature ops applied ON TOP of the live component body feature history
# when the instance geometry is rebuilt.
sketches: Dict[str, Sketch] = field(default_factory=dict)
modifiers: Dict[str, List[Feature]] = field(default_factory=dict)
# Runtime-only cache of rebuilt instance geometry (body_id -> geometry).
# Never serialized; invalidated on component updates and modifier edits.
geom_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
@@ -539,6 +573,8 @@ class AssemblyComponent:
x_dir: Tuple[float, float, float],
source_obj_id: str = "",
name: Optional[str] = None,
entity_type: str = "",
normal_flip: bool = False,
) -> Connector:
"""Add a connector to this component instance."""
conn = Connector(
@@ -548,6 +584,8 @@ class AssemblyComponent:
x_dir=x_dir,
assembly_component_id=self.id,
source_obj_id=source_obj_id,
entity_type=entity_type,
normal_flip=normal_flip,
)
self.connectors[conn.id] = conn
self.modified_at = datetime.now()
@@ -561,6 +599,45 @@ class AssemblyComponent:
return True
return False
def add_instance_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add an instance-local sketch (component-local coordinates)."""
if sketch is None:
sketch = Sketch(name=f"Instance Sketch {len(self.sketches) + 1}")
self.sketches[sketch.id] = sketch
self.modified_at = datetime.now()
return sketch
def remove_instance_sketch(self, sketch_id: str) -> bool:
"""Remove an instance sketch and every modifier that references it."""
if sketch_id not in self.sketches:
return False
del self.sketches[sketch_id]
for body_id in list(self.modifiers.keys()):
kept = [
f for f in self.modifiers[body_id]
if not (f.sketch is not None and f.sketch.id == sketch_id)
]
if kept:
self.modifiers[body_id] = kept
else:
del self.modifiers[body_id]
self.geom_cache.pop(body_id, None)
self.modified_at = datetime.now()
return True
def add_modifier(self, body_id: str, feat: Feature) -> Feature:
"""Append a modifier op to *body_id*'s instance-local history."""
self.modifiers.setdefault(body_id, []).append(feat)
self.geom_cache.pop(body_id, None)
self.modified_at = datetime.now()
return feat
def invalidate_geom_cache(self, body_id: Optional[str] = None) -> None:
"""Drop cached rebuilt instance geometry (one body, or all)."""
if body_id is None:
self.geom_cache.clear()
else:
self.geom_cache.pop(body_id, None)
@dataclass
class AssemblyConnection:
+142 -4
View File
@@ -164,12 +164,17 @@ class OCCRenderer(Renderer):
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
self._gizmo_objects: Dict[str, Any] = {}
# Persistent connector gizmo objects (first pick) not cleared by hover.
self._persistent_gizmo_objects: Dict[str, Any] = {}
# World-anchored sketch reference gizmo (a triad at the sketch
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
self._sketch_gizmo_parts: Dict[str, Any] = {}
# Part kind currently highlighted on hover (for restore-on-leave).
self._sketch_gizmo_highlighted: Optional[str] = None
# Cache for shape classification to avoid re-classifying same OCC sub-shapes
# during repeated probe/hover calls. Key = (id(shape), owner_obj_id).
self._classify_cache: dict = {}
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -335,10 +340,16 @@ class OCCRenderer(Renderer):
shape: Any,
color: Optional[Tuple[float, float, float]] = None,
name: Optional[str] = None,
auto_fit: bool = True,
) -> str:
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
Returns a unique object ID (or *name* if provided).
With *auto_fit* (default), the first object added to an empty
scene triggers a camera fit. Pass ``auto_fit=False`` when
rebuilding a scene under explicit camera control (e.g. the
assembly view), so the rebuild doesn't move the camera.
"""
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
@@ -398,7 +409,7 @@ class OCCRenderer(Renderer):
self._objects[obj_id] = robj
# Fit camera on first shape added.
if len(self._objects) == 1:
if auto_fit and len(self._objects) == 1:
try:
self.fit_camera()
except Exception:
@@ -1514,6 +1525,10 @@ class OCCRenderer(Renderer):
"""
if shape is None:
return []
# Cache lookup
cache_key = (id(shape), owner_obj_id)
if cache_key in self._classify_cache:
return self._classify_cache[cache_key]
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
@@ -1575,7 +1590,7 @@ class OCCRenderer(Renderer):
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [
res = [
{
"type": "planar_face",
"position": origin,
@@ -1585,6 +1600,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1692,6 +1709,7 @@ class OCCRenderer(Renderer):
"radius": radius,
}
)
self._classify_cache[cache_key] = results
return results
# Try edge.
@@ -1735,7 +1753,7 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [
res = [
{
"type": "edge",
"position": position,
@@ -1745,6 +1763,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
# Try vertex.
vertex = None
@@ -1752,7 +1772,7 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
return [
res = [
{
"type": "vertex",
"position": position,
@@ -1762,9 +1782,12 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
except Exception:
pass
self._classify_cache[cache_key] = []
return []
def probe_snap_candidates(
@@ -2467,6 +2490,114 @@ class OCCRenderer(Renderer):
if self._view is not None:
self._view.Update()
def show_persistent_entity_gizmo(
self,
entity_type: str,
position: Tuple[float, float, float],
normal: Optional[Tuple[float, float, float]] = None,
x_dir: Optional[Tuple[float, float, float]] = None,
radius: Optional[float] = None,
color: Tuple[float, float, float] = (0.0, 1.0, 0.0),
) -> None:
"""Display a persistent green gizmo for a confirmed first connector pick.
Unlike show_entity_gizmo, this does not clear the hover gizmo and stores
its AIS objects in _persistent_gizmo_objects so they survive hover updates.
"""
if self._context is None:
return
# Clear previous persistent gizmo
self.clear_persistent_entity_gizmo()
gizmo_scale = self._get_gizmo_scale(position)
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
def _store(obj, key):
self._context.Display(obj, True)
self._persistent_gizmo_objects[key] = obj
def _make_sphere(p, c, size):
try:
s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape()
a = AIS_Shape(s)
a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB))
a.SetDisplayMode(1)
_store(a, f"__pg_sphere_{id(a)}")
except Exception as exc:
logger.debug(f"persistent gizmo sphere failed: {exc}")
px, py, pz = position
_make_sphere(position, color, 5.6 * gizmo_scale)
axis_length = 30.0 * gizmo_scale
def _make_axis_line(origin, direction, length, line_color, label):
try:
dx, dy, dz = direction
norm = (dx*dx + dy*dy + dz*dz) ** 0.5
if norm < 1e-9:
return
ux, uy, uz = dx/norm, dy/norm, dz/norm
ex = origin[0] + ux * length
ey = origin[1] + uy * length
ez = origin[2] + uz * length
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
ais = AIS_Shape(edge)
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
ais.SetDisplayMode(0)
_store(ais, f"__pg_{label}_{id(ais)}")
except Exception as exc:
logger.debug(f"persistent gizmo axis failed: {exc}")
if entity_type == "planar_face" and normal is not None:
_make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal")
if x_dir is not None:
_make_axis_line(position, x_dir, axis_length * 0.6, color, "xdir")
elif entity_type == "cylindrical_face" and normal is not None:
_make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
_make_axis_line(position, (-normal[0], -normal[1], -normal[2]), axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub")
if x_dir is not None:
_make_axis_line(position, x_dir, radius or (axis_length * 0.5), color, "radial")
# ring
if radius is not None:
try:
center = gp_Pnt(px, py, pz)
ax2 = gp_Ax2(center, gp_Dir(*normal))
circ = gp_Circ(ax2, radius)
ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge()
ring_ais = AIS_Shape(ring_edge)
ring_ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
ring_ais.SetDisplayMode(0)
_store(ring_ais, f"__pg_ring_{id(ring_ais)}")
except Exception as exc:
logger.debug(f"persistent gizmo ring failed: {exc}")
elif entity_type == "edge" and normal is not None:
_make_axis_line(position, normal, axis_length, color, "tangent")
elif entity_type == "vertex":
_make_axis_line(position, (1,0,0), axis_length * 0.5, (1.0,0.3,0.3), "cross_x")
_make_axis_line(position, (0,1,0), axis_length * 0.5, (0.3,1.0,0.3), "cross_y")
_make_axis_line(position, (0,0,1), axis_length * 0.5, (0.3,0.3,1.0), "cross_z")
if self._view is not None:
self._view.Update()
def clear_persistent_entity_gizmo(self) -> None:
"""Remove the persistent first-pick gizmo."""
if self._context is None:
return
for obj in list(self._persistent_gizmo_objects.values()):
try:
self._context.Erase(obj, True)
except Exception:
pass
self._persistent_gizmo_objects.clear()
if self._view is not None:
self._view.Update()
# ─── Selection mode control ───────────────────────────────────────────
#
# When connector gizmo mode is active, standard OCC face/edge/vertex
@@ -2703,6 +2834,13 @@ class OCCRenderer(Renderer):
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
)
# Early exit if we already found a very close candidate — avoids unnecessary work.
if results:
best = results[0]
best_sp = best.get("screen", (x, y))
best_dist2 = (best_sp[0] - x) ** 2 + (best_sp[1] - y) ** 2
if best_dist2 <= 25: # within 5 px
return [best]
return results
def recognize_composite_features(
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 os
import warnings
from typing import Optional
import numpy as np
@@ -48,6 +49,24 @@ from fluency.rendering.render_backend import (
logger = logging.getLogger(__name__)
def _unlink_quiet(path: Optional[str]) -> None:
"""Unlink *path*, ignoring missing files and OS errors."""
if not path:
return
try:
if os.path.exists(path):
os.unlink(path)
except OSError:
pass
# Threads still running after a cancel could not finish in time. Kept
# referenced (never terminate()'d, reparented from their widget) at
# module level so they can safely outlive the widget/window that spawned
# them — destroying a still-running QThread is a Qt fatal error.
_RETIRED_THREADS: list = []
# ── Background render thread ────────────────────────────────────────
@@ -153,6 +172,77 @@ class _AssemblyRenderThread(QThread):
self.error.emit(str(e))
class _MeshThread(QThread):
"""Tessellates OCC shapes to PLY files off the GUI thread.
``BRepMesh_IncrementalMesh`` is a single blocking C++ call, so the
cancel flag is checked between parts (assemblies) and at completion;
a cancelled thread discards its result instead of emitting it, so it
cannot clobber the UI.
"""
mesh_ready = Signal(str) # single-shape: mesh path
assembly_ready = Signal(list, object, object) # parts, bounds, first_bounds
error = Signal(str)
def __init__(self, shapes, is_assembly: bool, parent=None):
super().__init__(parent)
# Single: (TopoDS_Shape,) | Assembly: [(TopoDS_Shape, mat_name), ...]
self._shapes = shapes
self._is_assembly = is_assembly
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
try:
if self._is_assembly:
self._run_assembly()
else:
mesh_path = occ_shape_to_ply(
self._shapes[0], linear_deflection=0.1, angular_deflection=0.15
)
if not self._cancelled:
self.mesh_ready.emit(mesh_path)
except Exception as e:
if not self._cancelled:
self.error.emit(str(e))
def _run_assembly(self):
from fluency.rendering.material_presets import get_preset
parts: list = []
all_mins: list = []
all_maxs: list = []
first_bounds = None
for shape, mat_name in self._shapes:
if self._cancelled:
return
try:
mesh_path = occ_shape_to_ply(
shape, linear_deflection=0.1, angular_deflection=0.15
)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
parts.append((mesh_path, material))
bounds = occ_shape_bounds(shape)
all_mins.append(list(bounds[0]))
all_maxs.append(list(bounds[1]))
if first_bounds is None:
first_bounds = bounds
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
if self._cancelled:
return
combined = None
if all_mins and all_maxs:
combined = (
[min(a[i] for a in all_mins) for i in range(3)],
[max(a[i] for a in all_maxs) for i in range(3)],
)
self.assembly_ready.emit(parts, combined, first_bounds)
# ── Render window ───────────────────────────────────────────────────
@@ -602,6 +692,8 @@ class RenderWindow(QMainWindow):
"""Reset camera parameters to match the 3D viewport."""
if self._camera is None:
return
o = self._camera.origin
t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -681,6 +773,8 @@ class RenderWindow(QMainWindow):
"""Fill camera spinboxes from the current RenderCamera."""
if self._camera is None:
return
o = self._camera.origin
t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -773,17 +867,63 @@ class RenderWindow(QMainWindow):
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self):
"""Cancel whichever thread is currently running."""
"""Cancel whichever thread is currently running.
Deliberately avoids ``QThread.terminate()``: it kills the thread
mid-instruction inside Mitsuba/OCC C++ code and corrupts native
state (SIGSEGV). Threads are cancelled cooperatively and, if
still running, detached until they exit on their own.
"""
if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel()
self._preview_thread.terminate()
self._preview_thread.wait(2000)
self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel()
self._render_thread.terminate()
self._render_thread.wait(2000)
self._stop_thread(self._render_thread)
self._active_mode = None
def _stop_thread(self, thread, block: bool = False):
"""Cancel *thread*; detach without ever using ``terminate()``.
``QThread.terminate()`` kills the thread mid-instruction inside
Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
the cooperative cancel flag is set and, if the thread is still
running, its result signals are disconnected and it is kept
referenced (``_retired_threads``) until it exits on its own a
cancelled ``run()`` emits no results, so it cannot clobber the UI.
``block=True`` (shutdown paths only) additionally waits up to 3 s
so a thread does not outlive the application. Interactive paths
keep the default and never stall the GUI thread.
"""
if thread is None:
return
# Drop retired threads that have exited.
for t in list(_RETIRED_THREADS):
if not t.isRunning():
_RETIRED_THREADS.remove(t)
thread.cancel()
if thread.isRunning():
# Disconnect so a detached thread can't update the UI. Signals
# with no receiver only emit a RuntimeWarning on disconnect, so
# silence that specific case. Not every thread class defines
# every signal, so skip missing attributes.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in ("finished", "error", "progress",
"mesh_ready", "assembly_ready"):
sig = getattr(thread, name, None)
if sig is None:
continue
try:
sig.disconnect()
except (RuntimeError, TypeError):
pass
_RETIRED_THREADS.append(thread)
# Reparent so destroying the owning widget can't delete a
# still-running QThread (a Qt fatal error).
thread.setParent(None)
if block:
thread.wait(3000)
def _set_buttons_rendering(self, mode: str):
"""Disable buttons while rendering."""
self._preview_btn.setEnabled(False)
@@ -970,10 +1110,8 @@ class RenderWindow(QMainWindow):
# Kill both possible threads
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
# block=True: at window close a thread must not outlive the app.
self._stop_thread(thread, block=True)
# Clean up temp mesh file
if self._mesh_path and os.path.exists(self._mesh_path):
@@ -1014,6 +1152,13 @@ class RenderTabContent(QWidget):
# Rendering threads & images
self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None
# Background tessellation thread — meshing never blocks the GUI
self._mesh_thread: Optional[_MeshThread] = None
# Raw (TopoDS_Shape, mat_name) tuples awaiting background tessellation
self._assembly_pending: list = []
# Bumped on every load/clear/cleanup; mesh results carry the
# generation they belong to so stale results are discarded.
self._mesh_generation: int = 0
self._last_image: Optional[np.ndarray] = None
self._last_preview: Optional[np.ndarray] = None
self._camera: Optional[RenderCamera] = None
@@ -1034,16 +1179,26 @@ class RenderTabContent(QWidget):
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
"""Load a new OCC TopoDS_Shape for rendering.
Returns immediately: tessellation runs in a background thread
(``_MeshThread``) so callers (e.g. component switching) never
block the GUI thread. The auto-preview is scheduled once the
mesh is ready.
*camera* if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
self._mesh_generation += 1
# Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread()
# Cancel any in-flight tessellation from a previous load.
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
# Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None
# Reset the mesh path so a failed tessellation below cannot
# Reset the mesh path so a stale/failed tessellation cannot
# trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None
self._shape = shape
@@ -1052,13 +1207,11 @@ class RenderTabContent(QWidget):
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_mesh()
self._populate_camera_controls()
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
self._start_meshing()
def get_camera(self) -> Optional[RenderCamera]:
"""Return the current camera (from UI controls or initial)."""
@@ -1073,26 +1226,30 @@ class RenderTabContent(QWidget):
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
Returns immediately; the parts are tessellated in a background
thread and the auto-preview is scheduled once they are ready.
"""
self._mesh_generation += 1
# Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_pending = list(parts)
self._assembly_bounds = None
# Tessellate and compute combined bounds first so the framing below
# is based on this assembly, not a stale one.
self._prepare_assembly_mesh(parts)
if camera is not None:
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._populate_camera_controls()
self._schedule_auto_preview()
self._start_meshing()
def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport).
@@ -1119,10 +1276,14 @@ class RenderTabContent(QWidget):
def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display."""
self._mesh_generation += 1
self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None
self._last_image = None
self._last_preview = None
@@ -1133,18 +1294,28 @@ class RenderTabContent(QWidget):
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
self._mesh_generation += 1
if self._auto_preview_timer and self._auto_preview_timer.isActive():
self._auto_preview_timer.stop()
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
if self._mesh_path and os.path.exists(self._mesh_path):
try:
os.unlink(self._mesh_path)
except OSError:
pass
# block=True: at tab exit / app shutdown a thread must not outlive
# the owning widget.
for thread in (self._mesh_thread, self._preview_thread, self._render_thread):
self._stop_thread(thread, block=True)
self._mesh_thread = None
self._preview_thread = None
self._render_thread = None
self._active_mode = None
# Delete temp PLY files (single shape plus all assembly parts).
paths = []
if self._mesh_path:
paths.append(self._mesh_path)
paths.extend(p for p, _ in self._assembly_parts)
for path in paths:
if path and os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
self._mesh_path = None
# ── UI Setup ───────────────────────────────────────────────────
@@ -1500,56 +1671,72 @@ class RenderTabContent(QWidget):
self._preview_btn.setEnabled(False)
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
def _prepare_mesh(self):
if self._shape is None:
return
try:
self._mesh_path = occ_shape_to_ply(
self._shape, linear_deflection=0.1, angular_deflection=0.15
)
if self._camera is None:
mn, mx = occ_shape_bounds(self._shape)
self._camera = self._backend.default_camera_from_bounds(mn, mx)
logger.info(f"Prepared mesh: {self._mesh_path}")
except Exception as e:
logger.error(f"Failed to prepare mesh: {e}")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
def _start_meshing(self):
"""Kick off background tessellation of the current shape/assembly.
def _prepare_assembly_mesh(self, parts: list):
"""Tessellate multiple shapes to separate PLY files.
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
Each material preset name is resolved via ``get_preset``.
The GUI thread is never blocked: the tab shows "Tessellating…"
until the mesh is ready, then the auto-preview is scheduled.
"""
from fluency.rendering.material_presets import get_preset
self._assembly_parts = []
first_bounds = None
all_mins: list[float] = []
all_maxs: list[float] = []
for shape, mat_name in parts:
try:
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
self._assembly_parts.append((mesh_path, material))
bounds = occ_shape_bounds(shape)
all_mins.append(list(bounds[0]))
all_maxs.append(list(bounds[1]))
if first_bounds is None:
first_bounds = bounds
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
# Compute combined bounding box from all parts.
if all_mins and all_maxs:
combined_min = [min(a[i] for a in all_mins) for i in range(3)]
combined_max = [max(a[i] for a in all_maxs) for i in range(3)]
self._assembly_bounds = (combined_min, combined_max)
if self._shape is not None:
thread = _MeshThread((self._shape,), is_assembly=False, parent=self)
gen = self._mesh_generation
thread.mesh_ready.connect(lambda path, g=gen: self._on_mesh_ready(path, g))
thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
elif self._assembly_pending:
thread = _MeshThread(self._assembly_pending, is_assembly=True, parent=self)
gen = self._mesh_generation
thread.assembly_ready.connect(
lambda parts, bounds, first, g=gen: self._on_assembly_ready(parts, bounds, first, g)
)
thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
else:
self._assembly_bounds = None
if first_bounds and self._camera is None:
self._image_label.setText("Click Preview or Render to start")
return
thread.start()
self._mesh_thread = thread
def _on_mesh_ready(self, mesh_path: str, gen: int) -> None:
"""Background tessellation finished (single shape)."""
if gen != self._mesh_generation:
# The load was replaced mid-tessellation — discard the stale mesh.
_unlink_quiet(mesh_path)
return
self._mesh_path = mesh_path
if self._camera is None and self._backend is not None:
mn, mx = occ_shape_bounds(self._shape)
self._camera = self._backend.default_camera_from_bounds(mn, mx)
self._populate_camera_controls()
if self._active_mode is None:
self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared mesh: {self._mesh_path}")
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
def _on_assembly_ready(self, parts: list, bounds, first_bounds, gen: int) -> None:
"""Background tessellation finished (assembly)."""
if gen != self._mesh_generation:
for p, _ in parts:
_unlink_quiet(p)
return
self._assembly_parts = parts
self._assembly_bounds = bounds
if self._camera is None and self._backend is not None and first_bounds is not None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
self._populate_camera_controls()
if self._active_mode is None:
self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared assembly: {len(self._assembly_parts)} parts")
# Trigger auto-preview when a new assembly is loaded
self._schedule_auto_preview()
def _on_mesh_error(self, msg: str, gen: int) -> None:
if gen != self._mesh_generation:
return
logger.error(f"Failed to tessellate shape: {msg}")
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{msg}")
def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self)
@@ -1688,7 +1875,7 @@ class RenderTabContent(QWidget):
return
if self._active_mode is not None:
return
if self._backend is None or self._mesh_path is None:
if self._backend is None or (self._mesh_path is None and not self._assembly_parts):
return
self._auto_preview_timer.start(500)
@@ -1740,15 +1927,55 @@ class RenderTabContent(QWidget):
def _cancel_active_thread(self):
if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel()
self._preview_thread.terminate()
self._preview_thread.wait(2000)
self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel()
self._render_thread.terminate()
self._render_thread.wait(2000)
self._stop_thread(self._render_thread)
self._active_mode = None
def _stop_thread(self, thread, block: bool = False):
"""Cancel *thread*; detach without ever using ``terminate()``.
``QThread.terminate()`` kills the thread mid-instruction inside
Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
the cooperative cancel flag is set and, if the thread is still
running, its result signals are disconnected and it is kept
referenced (``_retired_threads``) until it exits on its own a
cancelled ``run()`` emits no results, so it cannot clobber the UI.
``block=True`` (shutdown paths only) additionally waits up to 3 s
so a thread does not outlive the application. Interactive paths
keep the default and never stall the GUI thread.
"""
if thread is None:
return
# Drop retired threads that have exited.
for t in list(_RETIRED_THREADS):
if not t.isRunning():
_RETIRED_THREADS.remove(t)
thread.cancel()
if thread.isRunning():
# Disconnect so a detached thread can't update the UI. Signals
# with no receiver only emit a RuntimeWarning on disconnect, so
# silence that specific case. Not every thread class defines
# every signal, so skip missing attributes.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in ("finished", "error", "progress",
"mesh_ready", "assembly_ready"):
sig = getattr(thread, name, None)
if sig is None:
continue
try:
sig.disconnect()
except (RuntimeError, TypeError):
pass
_RETIRED_THREADS.append(thread)
# Reparent so destroying the owning widget can't delete a
# still-running QThread (a Qt fatal error).
thread.setParent(None)
if block:
thread.wait(3000)
def _set_buttons_rendering(self, mode: str):
self._preview_btn.setEnabled(False)
self._render_btn.setEnabled(False)
+5 -1
View File
@@ -548,8 +548,12 @@ class Sketch2DWidget(QWidget):
end_uv[0], end_uv[1]
)
# sweep=None → renderer computes shortest-path arc
# register=False: all three reference points are external
# (fixed) — registering the arc on top would
# over-constrain the solver (inconsistent).
self._sketch.add_arc(
center_pt, radius, start_pt, end_pt, sweep=None
center_pt, radius, start_pt, end_pt,
sweep=None, register=False,
)
imported += 1
except Exception as exc:
+3 -7
View File
@@ -417,19 +417,15 @@ class TechnicalDrawingWidget(QWidget):
self._kernel = kernel
def set_active_component(self, component) -> None:
"""Use the given component as the drawing source and regenerate."""
"""Use the given component as the drawing source."""
self._active_source_kind = "component"
self._active_source_id = component.id
self._on_generate()
def set_active_assembly(self, assembly) -> None:
"""Use the given assembly as the drawing source and regenerate.
The assembly is treated as a single fused part (all bodies merged).
"""
"""Use the given assembly as the drawing source."""
self._active_source_kind = "assembly"
self._active_source_id = assembly.id
self._on_generate()
def generate(self) -> None:
"""Public entry point: generate for the current source."""
+22 -2
View File
@@ -189,17 +189,27 @@ class Viewer3DWidget(QWidget):
self._ensure_initialized()
return self._renderer
def show_shape(self, shape: Any, color=None, name=None) -> str:
def show_shape(
self,
shape: Any,
color=None,
name=None,
auto_fit: bool = True,
) -> str:
"""Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer.
*auto_fit* is forwarded to the renderer: pass ``False`` when
rebuilding a scene under explicit camera control so the first
shape does not trigger a whole-scene camera fit.
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name)
oid = self._renderer.add_shape(shape, color, name, auto_fit)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
@@ -930,6 +940,16 @@ class Viewer3DWidget(QWidget):
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
def show_persistent_connector_gizmo(self, origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)):
fn = getattr(self._renderer, "show_persistent_entity_gizmo", None)
if fn is not None:
fn(entity_type=entity_type, position=origin, normal=normal, x_dir=x_dir, color=color)
def clear_persistent_connector_gizmo(self):
fn = getattr(self._renderer, "clear_persistent_entity_gizmo", None)
if fn is not None:
fn()
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)