diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 66ac723..df37c50 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -9,7 +9,6 @@
-
@@ -48,39 +47,39 @@
- {
+ "keyToString": {
+ "Python.2dtest.executor": "Run",
+ "Python.3d_windows.executor": "Run",
+ "Python.Unnamed.executor": "Run",
+ "Python.draw_widget2d.executor": "Run",
+ "Python.draw_widget_solve.executor": "Run",
+ "Python.fluency.executor": "Run",
+ "Python.fluencyb.executor": "Run",
+ "Python.gl_widget.executor": "Run",
+ "Python.kernel.executor": "Run",
+ "Python.main.executor": "Run",
+ "Python.meshtest.executor": "Run",
+ "Python.occ_renderer.executor": "Run",
+ "Python.side_fluency.executor": "Run",
+ "Python.simple_mesh.executor": "Run",
+ "Python.vtk_widget.executor": "Run",
+ "Python.vulkan.executor": "Run",
+ "RunOnceActivity.OpenProjectViewOnStart": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "RunOnceActivity.typescript.service.memoryLimit.init": "true",
+ "codeWithMe.voiceChat.enabledByDefault": "false",
+ "git-widget-placeholder": "feature/occ-migration",
+ "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
+ "node.js.detected.package.eslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_package_manager_path": "npm",
+ "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
-}]]>
+}
1782673954850
-
+
+
+ 1782679912834
+
+
+
+ 1782679912834
+
+
diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py
index 6954486..84f1167 100644
--- a/src/fluency/geometry_occ/kernel.py
+++ b/src/fluency/geometry_occ/kernel.py
@@ -180,12 +180,31 @@ class OCGeometryKernel(GeometryKernel):
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopoDS import TopoDS_Shape
- face = self._get_shape(sketch)
+ # Defensive: figure out the actual shape from whatever the caller
+ # hands us, and surface a clear error if we can't get one.
+ # - If it's an OCCGeometryObject wrapper, unwrap via _get_shape.
+ # - If it's already a TopoDS_Shape (raw face/wire/etc.), use it.
+ # - If it's a cadquery Workplane, unwrap that too.
+ # - If it's a cadquery Shape (cq.Shape), unwrap to TopoDS_Shape.
+ if isinstance(sketch, OCCGeometryObject):
+ face = self._get_shape(sketch)
+ elif isinstance(sketch, TopoDS_Shape):
+ face = sketch
+ else:
+ face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
)
+ # If the wrapper class itself leaked through somehow, surface a
+ # clear error instead of letting BRepPrimAPI_MakePrism raise an
+ # opaque TypeError.
+ if isinstance(face, OCCGeometryObject):
+ raise ValueError(
+ "Cannot extrude: sketch geometry is a wrapper, not a shape. "
+ "This is a bug — please report it."
+ )
# ``face`` may be a TopoDS_Face (new path) or a compound/wire from
# legacy cadquery objects. If it's not already a face, build one.
face = self._ensure_face(face)
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index 036b43b..1348174 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -34,6 +34,12 @@ class OCCSketchEntity(SketchEntity):
self.handle = handle # SolveSpace solver entity handle
self.is_construction: bool = False
self.constraints: List[str] = [] # Track applied constraint names for UI
+ # External / underlay entities are reference geometry projected from
+ # a 3D face (or otherwise supplied from outside the sketch). They live
+ # in the solver so user constraints can reference them, but they are
+ # *not* user-drawn, *not* deletable, *not* moveable, and never
+ # contribute to the sketch profile (detect_faces / get_geometry).
+ self.is_external: bool = False
class OCCSketch(SketchInterface):
@@ -60,6 +66,13 @@ class OCCSketch(SketchInterface):
# after deleting an entity (python_solvespace has no per-entity delete).
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
self._constraint_log: List[Dict[str, Any]] = []
+ # External / underlay entity ids (face-projected reference geometry).
+ # Kept in their own set so we can:
+ # • render them with a distinct style
+ # • filter them out of get_closed_loops / detect_faces
+ # • refuse to delete / move them
+ # • clear them as a group when the source face is removed
+ self._external_entity_ids: set = set()
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
@@ -161,12 +174,21 @@ class OCCSketch(SketchInterface):
return int(match.group(1)) if match else 0
def add_point(self, x: float, y: float) -> OCCSketchEntity:
- """Add a point to the sketch (added to solver + tracked)."""
+ """Add a point to the sketch (added to solver + tracked).
+
+ The very first point added to an empty solver is auto-anchored via
+ ``dragged`` to give the solver a stable reference frame. If the
+ sketch already carries external / underlay points (those are
+ always dragged at creation), we skip this auto-anchor — the
+ external point is the natural reference, and a second dragged
+ point would over-constrain the system and make any
+ user-to-external distance constraint unsolvable.
+ """
entity_id = self._next_id()
# Add to solver
solver_handle = self._solver.add_point_2d(x, y, self._wp)
- if self._first_point_id is None:
+ if self._first_point_id is None and not self._external_entity_ids:
self._first_point_id = entity_id
# Fix first point so solver has a reference
self._solver.dragged(solver_handle, self._wp)
@@ -268,6 +290,148 @@ class OCCSketch(SketchInterface):
return entity
+ # ─── External / underlay entities (face-projected reference geometry) ───
+
+ def add_external_point(self, x: float, y: float) -> OCCSketchEntity:
+ """Add a point that participates in the solver but is *not* user-drawn.
+
+ External points are used to anchor projected face edges (sketch-on-
+ surface underlay) so the user can snap to them and add constraints
+ like "hole center 50mm from the body's top edge". The point is
+ immediately marked *fixed* in the solver (via ``dragged``) so it never
+ moves when other entities are dragged or re-solved.
+
+ External entities are skipped by ``get_closed_loops`` /
+ ``detect_faces`` / ``get_geometry`` so they never contribute to the
+ extruded profile — they're reference geometry only.
+ """
+ entity_id = self._next_id()
+ solver_handle = self._solver.add_point_2d(x, y, self._wp)
+ # Always fix external points — they MUST NOT move when the solver
+ # adjusts other entities. We bypass the first-point auto-fix in
+ # ``add_point`` (which would also fix the very first one and leave
+ # the rest free), and we apply dragged() unconditionally here.
+ self._solver.dragged(solver_handle, self._wp)
+
+ entity = OCCSketchEntity(
+ entity_id=entity_id, entity_type="point",
+ geometry=(x, y), handle=solver_handle,
+ )
+ entity.is_external = True
+ entity.is_construction = True # external points are reference / dashed
+ self._entities[entity_id] = entity
+ self._points[entity_id] = (x, y)
+ self._external_entity_ids.add(entity_id)
+ return entity
+
+ def add_external_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
+ """Add a line between two existing external points.
+
+ Both endpoints must already be external points (created via
+ :meth:`add_external_point`). External lines are tagged ``is_external``
+ and are excluded from the sketch's profile-detect path so they don't
+ pollute the extruded face. Constraints applied to external lines
+ (horizontal, vertical, parallel, perpendicular, midpoint) work
+ normally — the line handle is real — but the line itself never moves.
+ """
+ entity_id = self._next_id()
+ s_ent = self._entities.get(start.id)
+ e_ent = self._entities.get(end.id)
+ if s_ent is None or e_ent is None:
+ raise ValueError("Start or end point not found in sketch")
+ if s_ent.handle is None or e_ent.handle is None:
+ raise ValueError("External endpoints must have solver handles")
+
+ solver_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
+ x1, y1 = s_ent.geometry
+ x2, y2 = e_ent.geometry
+ entity = OCCSketchEntity(
+ entity_id=entity_id, entity_type="line",
+ geometry=((x1, y1), (x2, y2)),
+ handle=solver_handle,
+ )
+ entity.is_external = True
+ entity.is_construction = True
+ self._entities[entity_id] = entity
+ self._lines[entity_id] = (start.id, end.id)
+ self._external_entity_ids.add(entity_id)
+ return entity
+
+ def add_external_polyline(
+ self, uv_points: List[Tuple[float, float]]
+ ) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
+ """Bulk-import a polyline of UV points as external (underlay) entities.
+
+ Creates one external point per unique UV position and one external
+ line segment between consecutive points. Returns
+ ``(points, lines)`` in the order they were created so the caller can
+ keep references (e.g. for rendering or for toggling).
+
+ Points very close to each other (within 1e-6 UV units) are merged
+ into a single shared point, so a closed rectangle becomes 4 unique
+ points and 4 line segments (not 4 points and 4 lines + 4 duplicates
+ at the corners).
+ """
+ if len(uv_points) < 2:
+ return [], []
+ # Deduplicate nearby points so shared corners (e.g. a rectangle's
+ # four vertices) are *one* point entity reused by two line segments.
+ eps = 1e-6
+ points: List[OCCSketchEntity] = []
+ coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {}
+ for (u, v) in uv_points:
+ key = (int(round(u / eps)), int(round(v / eps)))
+ ent = coord_to_entity.get(key)
+ if ent is None:
+ ent = self.add_external_point(float(u), float(v))
+ coord_to_entity[key] = ent
+ points.append(ent)
+ lines: List[OCCSketchEntity] = []
+ for i in range(len(points) - 1):
+ try:
+ ln = self.add_external_line(points[i], points[i + 1])
+ lines.append(ln)
+ except ValueError:
+ pass
+ return points, lines
+
+ def remove_external_entities(self) -> None:
+ """Remove every external / underlay entity and prune related constraints.
+
+ Used when the source face is removed (or rebinded). External
+ entities are *never* user-deletable; this is the only way to clear
+ them. Any constraint that references a removed external id is
+ pruned from the constraint log and the solver is rebuilt from the
+ surviving user geometry so the next solve is consistent.
+ """
+ if not self._external_entity_ids:
+ return
+ # Wipe external entities from local tracking.
+ for eid in list(self._external_entity_ids):
+ if eid in self._entities:
+ del self._entities[eid]
+ self._points.pop(eid, None)
+ self._lines.pop(eid, None)
+ self._circles.pop(eid, None)
+ self._arcs.pop(eid, 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).
+ for lid, (sid, eid2) in list(self._lines.items()):
+ if sid in self._external_entity_ids or eid2 in self._external_entity_ids:
+ del self._lines[lid]
+ if lid in self._entities:
+ del self._entities[lid]
+ removed = set(self._external_entity_ids)
+ self._external_entity_ids.clear()
+ self._prune_log_for(removed)
+ self._rebuild_solver()
+ self._rebuild_labels()
+
+ def get_external_entity_ids(self) -> set:
+ """Return the set of external (underlay) entity ids currently in the sketch."""
+ return set(self._external_entity_ids)
+
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
@@ -666,11 +830,15 @@ class OCCSketch(SketchInterface):
if len(faces) == 1:
return self.build_face_geometry(faces[0])
- # Fallback: wrap the first circle, or the polygon, as a single-loop face.
+ # Fallback: wrap the first non-external circle, or the polygon, as a
+ # single-loop face. External (underlay) circles are reference geometry
+ # and must not be returned as the extruded profile.
if self._circles:
for entity_id, (center_id, radius) in self._circles.items():
+ if entity_id in self._external_entity_ids:
+ continue
center_entity = self._entities.get(center_id)
- if center_entity and center_entity.geometry:
+ if center_entity and center_entity.geometry and not center_entity.is_external:
cx, cy = center_entity.geometry
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
@@ -710,11 +878,15 @@ class OCCSketch(SketchInterface):
return points
def get_polygon_points(self) -> List[Point2D]:
- """Get ordered polygon points from connected lines (uses solved positions)."""
+ """Get ordered polygon points from connected lines (uses solved positions).
+
+ External (underlay) lines are skipped — they are reference geometry
+ only, not part of the sketch profile.
+ """
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values():
- if entity.entity_type == "line" and entity.geometry:
+ if entity.entity_type == "line" and entity.geometry and not entity.is_external:
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
@@ -752,9 +924,18 @@ class OCCSketch(SketchInterface):
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
- """Current line segments as world-coordinate tuples (uses solved positions)."""
+ """Current line segments as world-coordinate tuples (uses solved positions).
+
+ External (underlay) lines are excluded: they're reference geometry
+ projected from a 3D face, not user-drawn sketch profile, and must
+ not contribute to detect_faces / get_geometry / extrusion.
+ """
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
for line_id, (sid, eid2) in self._lines.items():
+ # Skip external/underlay lines — they live in the solver for
+ # constraint referencing, but are not part of the sketch profile.
+ if line_id in self._external_entity_ids:
+ continue
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
@@ -1058,6 +1239,7 @@ class OCCSketch(SketchInterface):
self._entity_counter = 0
self._constraint_count = 0
self._constraint_log.clear()
+ self._external_entity_ids.clear()
self._first_point_id = None
def _prune_log_for(self, removed_ids: set) -> None:
diff --git a/src/fluency/main.py b/src/fluency/main.py
index 2e5c3bd..8ee25b9 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -134,12 +134,20 @@ def _project_face_to_uv(
class ExtrudeDialog(QDialog):
- """Dialog for extrude options."""
+ """Dialog for extrude options.
+
+ Carries an optional ``preview_callback`` that is invoked whenever the
+ user changes any option; the host uses it to render a live transparent
+ preview of the operation result in the 3D view. Passing *False* (or
+ *None*) to the callback tells the host to clear the preview.
+ """
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Extrude Options")
- self.setMinimumWidth(300)
+ self.setMinimumWidth(320)
+
+ self._preview_callback = None
layout = QVBoxLayout(self)
@@ -164,6 +172,14 @@ class ExtrudeDialog(QDialog):
self.union_checkbox = QCheckBox("Combine (Union)")
layout.addWidget(self.union_checkbox)
+ self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
+ self.through_all_checkbox.setToolTip(
+ "Ignore the typed length and extrude far enough to fully pass "
+ "through the cut/union target body. Applies when Perform Cut or "
+ "Combine (Union) is checked."
+ )
+ layout.addWidget(self.through_all_checkbox)
+
self.rounded_checkbox = QCheckBox("Round Edges")
layout.addWidget(self.rounded_checkbox)
@@ -181,13 +197,65 @@ class ExtrudeDialog(QDialog):
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
- def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool]:
+ # Live preview: recompute on every option change. Use a light-
+ # weight guard so we don't emit before the host has wired up the
+ # callback.
+ for w in (
+ self.length_input,
+ self.symmetric_checkbox,
+ self.invert_checkbox,
+ self.cut_checkbox,
+ self.union_checkbox,
+ self.through_all_checkbox,
+ self.rounded_checkbox,
+ ):
+ # The spinbox has valueChanged; the checkboxes have stateChanged.
+ # Each must be wired in its own try/except so that a missing
+ # signal on one widget type doesn't skip the OTHER signal's
+ # connection (the prior single-try version accidentally
+ # left checkboxes un-connected when valueChanged raised first).
+ try:
+ w.valueChanged.connect(self._emit_preview)
+ except AttributeError:
+ pass
+ try:
+ w.stateChanged.connect(self._emit_preview)
+ except AttributeError:
+ pass
+
+ def set_preview_callback(self, callback) -> None:
+ """Install the live-preview callback (or *None* to disable)."""
+ self._preview_callback = callback
+ # Emit once so the initial state shows a preview right away.
+ self._emit_preview()
+
+ def _emit_preview(self, *args) -> None:
+ if self._preview_callback is None:
+ return
+ try:
+ self._preview_callback(self.get_values())
+ except Exception as exc: # preview must never break the dialog
+ logger.debug("extrude preview callback raised: %s", exc)
+
+ def hideEvent(self, event):
+ # Tell the host to clear the preview when the dialog goes away
+ # (accept, reject, or close). The host is responsible for the
+ # actual viewer cleanup.
+ if self._preview_callback is not None:
+ try:
+ self._preview_callback(None)
+ except Exception:
+ pass
+ super().hideEvent(event)
+
+ def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
return (
self.length_input.value(),
self.symmetric_checkbox.isChecked(),
self.invert_checkbox.isChecked(),
self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(),
+ self.through_all_checkbox.isChecked(),
self.rounded_checkbox.isChecked(),
)
@@ -263,6 +331,11 @@ class Sketch2DWidget(QWidget):
self._source_face: Any = None
self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None
self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent
+ # Underlay visibility: the dashed construction lines projected from
+ # the source face can be hidden/shown without losing the source
+ # face reference (useful when the underlay is too busy). Toggled
+ # from the "Underlay" button in the MainWindow UI.
+ self._underlay_visible: bool = True
self._snap_mode: Dict[str, bool] = {
"point": True,
@@ -307,6 +380,34 @@ class Sketch2DWidget(QWidget):
self._clear_face_state()
# If the new sketch carries a workplane, refresh the source underlay.
self._refresh_source_underlay()
+ # A brand new sketch has no external entities — strip the old
+ # underlay from the previous sketch so the construction lines
+ # don't bleed into the new sketch. (set_source_face will reimport
+ # them if the new sketch is on a face too.)
+ if self._sketch is not None and self._sketch is not sketch:
+ self._sketch.remove_external_entities()
+ self.update()
+
+ def clear_source_face(self) -> None:
+ """Forget the picked source face and remove the underlay entities.
+
+ Use this when the user wants to drop the face reference (e.g. they
+ want to draw a free-standing sketch without the body's outline
+ showing through). Removes the underlay entities from the solver,
+ clears the cached polyline data, and resets the view to whatever
+ zoom the user had before the face was set.
+ """
+ if self._sketch is not None:
+ self._sketch.remove_external_entities()
+ self._source_face = None
+ self._source_workplane = None
+ self._source_underlay_uv = []
+ self._rebuild_from_sketch()
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._selected_entities = []
self.update()
def set_source_face(
@@ -319,8 +420,15 @@ class Sketch2DWidget(QWidget):
"""Store the picked 3D face and reorient the 2D view to its plane.
Called by MainWindow after a face pick. Projects the face's boundary
- edges into the sketch's UV frame and caches them for the paintEvent
- underlay. Also re-centres/scales the 2D view to look down the plane.
+ edges into the sketch's UV frame, caches them for the underlay fill,
+ *and* imports them as construction-line entities in the underlying
+ OCCSketch. Those entities are fixed in the solver, so the user can
+ snap to them and add distance / horizontal / vertical / parallel /
+ perpendicular / midpoint / coincident constraints against them —
+ e.g. place a hole "50 mm from the body's top edge" by clicking the
+ underay corner, the hole centre, and entering 50.
+
+ Also re-centres/scales the 2D view to look down the plane.
"""
self._source_face = face
self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir))
@@ -328,6 +436,7 @@ class Sketch2DWidget(QWidget):
if self._sketch is not None:
self._sketch.set_workplane(origin, normal, x_dir)
self._refresh_source_underlay()
+ self._import_underlay_as_construction_lines()
self._orient_view_to_plane()
self.update()
@@ -345,6 +454,62 @@ class Sketch2DWidget(QWidget):
except Exception:
logger.debug("source underlay projection failed", exc_info=True)
+ def set_underlay_visible(self, visible: bool) -> None:
+ """Show or hide the underlay (face-projected construction lines).
+
+ When hidden, the external entities stay in the OCCSketch solver
+ (constraints referencing them keep working) but they're not
+ rendered, snapped to, or hit-tested in the 2D view.
+ """
+ self._underlay_visible = bool(visible)
+ # Re-render and drop any in-flight hover that pointed at an underlay
+ # entity (otherwise the cursor would freeze on a no-longer-drawn
+ # underlay element after the user hides it).
+ if not self._underlay_visible:
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self.update()
+
+ def _import_underlay_as_construction_lines(self) -> None:
+ """Convert the projected face edges into real construction-line entities.
+
+ Each polyline in ``_source_underlay_uv`` becomes a chain of external
+ point entities and external line segment entities in the underlying
+ OCCSketch. External points/lines are tagged ``is_external`` and
+ ``is_construction`` so the paintEvent renders them as dashed
+ construction lines, and so the sketch profile path (detect_faces /
+ get_geometry) skips them. The solver marks every external point
+ fixed via ``dragged``, so a user drag of a related entity never moves
+ the underlay.
+
+ If a previous underlay was already imported it is cleared first so
+ we don't accumulate duplicates on a re-pick of the same face.
+ """
+ if self._sketch is None or not self._source_underlay_uv:
+ return
+ # Clear any prior external entities before importing fresh ones so a
+ # repeated face pick doesn't pile up duplicate construction lines.
+ self._sketch.remove_external_entities()
+ imported = 0
+ for poly in self._source_underlay_uv:
+ if len(poly) < 2:
+ continue
+ try:
+ _, lines = self._sketch.add_external_polyline(
+ [(float(u), float(v)) for (u, v) in poly]
+ )
+ imported += len(lines)
+ except Exception as exc:
+ logger.debug("underlay polyline import failed: %s", exc)
+ logger.info(
+ "Imported %d construction-line segments from source face", imported
+ )
+ # Pull the new external entities into the UI lists so they're
+ # snap/hover/paint targets.
+ self._rebuild_from_sketch()
+
def _orient_view_to_plane(self) -> None:
"""Centre & scale the 2D view to fit the source face's UV bounds."""
if not self._source_underlay_uv:
@@ -401,17 +566,21 @@ class Sketch2DWidget(QWidget):
return a["center"] == b["center"] and a["radius"] == b["radius"]
def _rebuild_from_sketch(self):
- """Rebuild UI point/line lists from the OCCSketch entity data."""
+ """Rebuild UI point/line lists from the OCCSketch entity data.
+
+ External (underlay) entities are included in the UI lists so they
+ are valid pick targets for constraints and snap. The hit-test /
+ move / delete paths all check ``is_external`` and either skip them
+ (delete) or refuse to start a drag on them (move).
+ """
self._points = []
self._lines = []
self._circles = []
self._selected_entities = []
if self._sketch:
- # Collect points in creation order
- point_map: Dict[int, OCCSketchEntity] = {}
+ # Collect points in creation order (user + external).
for eid, entity in self._sketch._entities.items():
if entity.entity_type == "point":
- point_map[eid] = entity
self._points.append(entity)
for eid, entity in self._sketch._entities.items():
if entity.entity_type == "line" and eid in self._sketch._lines:
@@ -425,6 +594,16 @@ class Sketch2DWidget(QWidget):
if c_ent:
self._circles.append((c_ent, r))
+ @staticmethod
+ def _is_external(entity: Any) -> bool:
+ """True if an entity is a face-projected underlay / reference entity.
+
+ External entities live in the solver so constraints can reference
+ them, but they're protected from user deletion, dragging, and
+ profile extrusion.
+ """
+ return bool(entity is not None and getattr(entity, "is_external", False))
+
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
@@ -515,12 +694,20 @@ class Sketch2DWidget(QWidget):
return nearest
def _find_nearest_point_entity(self, pos: QPoint, max_distance: int = 15) -> Optional[OCCSketchEntity]:
- """Find the nearest point entity to a screen position."""
+ """Find the nearest point entity to a screen position.
+
+ External (underlay) points are pickable when the underlay is visible
+ so the user can use them as constraint anchors; they are skipped
+ otherwise. The function still respects the ``point`` snap mode toggle
+ so the user can disable snapping entirely.
+ """
if not self._snap_mode.get("point", False):
return None
nearest = None
min_dist = max_distance
for entity in self._points:
+ if self._is_external(entity) and not self._underlay_visible:
+ continue
if entity.geometry:
x, y = entity.geometry
point = QPoint(int(round(x)), int(round(y)))
@@ -679,8 +866,15 @@ class Sketch2DWidget(QWidget):
# ─── Solver helpers ───────────────────────────────────────────────────
def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
- """Find nearest point entity to world position."""
+ """Find nearest point entity to world position.
+
+ External (underlay) points are pickable when the underlay is visible
+ so the user can use them as constraint anchors (e.g. the corner of a
+ projected face); they're skipped when the underlay is hidden.
+ """
for entity in self._points:
+ if self._is_external(entity) and not self._underlay_visible:
+ continue
if entity.geometry:
x, y = entity.geometry
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
@@ -689,8 +883,18 @@ class Sketch2DWidget(QWidget):
return None
def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
- """Find a line near the given world position."""
+ """Find a line near the given world position.
+
+ External (underlay) lines are pickable when the underlay is visible
+ (so the user can add a distance / horizontal / vertical / parallel /
+ perpendicular / midpoint constraint against them) and skipped when
+ the underlay is hidden.
+ """
for p1_ent, p2_ent in self._lines:
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ is_ext = bool(line_ent is not None and self._is_external(line_ent))
+ if is_ext and not self._underlay_visible:
+ continue
if p1_ent.geometry and p2_ent.geometry:
x1, y1 = p1_ent.geometry
x2, y2 = p2_ent.geometry
@@ -777,14 +981,65 @@ class Sketch2DWidget(QWidget):
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
def _point_world(self, pid: int) -> Optional[QPoint]:
- """World-space position of the point entity with the given id."""
+ """World-space position of the point entity with the given id.
+
+ Defensive: returns *None* if the entity is missing, has no
+ geometry, or its geometry is not a 2-tuple of numbers. The last
+ check matters because the solver log also contains line ids
+ (e.g. for point-on-line coincident or distance to a line), and a
+ line's geometry is ``((x1,y1), (x2,y2))`` — naively unpacking
+ that as ``(x, y)`` and calling ``round()`` on the inner tuples
+ raises ``TypeError: type tuple doesn't define __round__ method``.
+
+ The final ``try/except`` is a last-resort safety net for exotic
+ cases (numpy scalars, complex numbers, badly-typed solver
+ output) that the explicit type checks above might miss. Better
+ to drop a constraint tag than to take down the entire paint
+ event.
+ """
if not self._sketch:
return None
ent = self._sketch._entities.get(pid)
if not ent or not ent.geometry:
return None
- x, y = ent.geometry
- return QPoint(int(round(x)), int(round(y)))
+ geom = ent.geometry
+ # A point's geometry is a flat 2-tuple of numbers; a line's is
+ # ((x1, y1), (x2, y2)). Reject anything that doesn't look like
+ # a point so callers don't crash on line/circle/arc ids.
+ if not isinstance(geom, tuple) or len(geom) != 2:
+ return None
+ x, y = geom
+ if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
+ return None
+ try:
+ return QPoint(int(round(x)), int(round(y)))
+ except (TypeError, ValueError):
+ # Last-resort guard: exotic x/y types (numpy scalars, NaN,
+ # etc.) can still slip through. Returning None means the
+ # tag is dropped rather than the paint event crashing.
+ logger.debug(
+ "_point_world: could not round geometry for entity %s (%r, %r)",
+ pid, x, y,
+ )
+ return None
+
+ def _entity_anchor(self, eid: int) -> Optional[QPoint]:
+ """Return a sensible world-space anchor for *any* entity id.
+
+ Points → their position. Lines → the midpoint. Anything else
+ (missing / unrecognised) → None. Used by constraint tag rendering
+ so a coincident or distance constraint that involves a line (e.g.
+ point-on-line, distance to a line) can be labelled without
+ crashing the paint event.
+ """
+ if not self._sketch:
+ return None
+ ent = self._sketch._entities.get(eid)
+ if ent is None:
+ return None
+ if ent.entity_type == "line":
+ return self._line_world_mid(eid)
+ return self._point_world(eid)
def _compute_constraint_tags(self) -> List[Dict[str, Any]]:
"""Build the list of constraint-tag descriptors from the constraint log.
@@ -803,62 +1058,87 @@ class Sketch2DWidget(QWidget):
stack_count: Dict[Tuple[int, int], int] = {}
for idx, entry in enumerate(self._sketch._constraint_log):
- ctype = entry["type"]
- ids = entry["ids"]
- params = entry["params"]
- anchor: Optional[QPoint] = None
- label = ""
+ # One bad log entry (e.g. a dangling id after a delete, an
+ # exotic geometry type, a numpy scalar that didn't round) must
+ # not take down the entire paint event. Catch any failure
+ # here, log it at debug, skip the tag, and keep painting.
+ try:
+ ctype = entry["type"]
+ ids = entry["ids"]
+ params = entry["params"]
+ anchor: Optional[QPoint] = None
+ label = ""
- if ctype == "horizontal":
- anchor = self._line_world_mid(ids[0]); label = "hrz"
- elif ctype == "vertical":
- anchor = self._line_world_mid(ids[0]); label = "vrt"
- elif ctype == "midpoint":
- anchor = self._line_world_mid(ids[1]); label = "mid"
- elif ctype == "distance":
- a = self._point_world(ids[0]); b = self._point_world(ids[1])
- # NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
- if a is not None and b is not None:
- anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
- label = f"dst {params[0]:.1f}" if params else "dst"
- elif ctype == "parallel":
- anchor = self._line_world_mid(ids[0]); label = "par"
- elif ctype == "perpendicular":
- anchor = self._line_world_mid(ids[0]); label = "perp"
- elif ctype == "angle":
- anchor = self._line_world_mid(ids[0])
- label = f"ang {params[0]:.0f}" if params else "ang"
- elif ctype == "equal":
- anchor = self._line_world_mid(ids[0]); label = "eql"
- elif ctype == "coincident":
- a = self._point_world(ids[0]); b = self._point_world(ids[1])
- if a is not None and b is not None:
- anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
- label = "coin"
- elif ctype == "symmetric":
- anchor = self._line_world_mid(ids[2]); label = "sym"
- elif ctype == "fixed":
- anchor = self._point_world(ids[0]); label = "fix"
- elif ctype == "equal_radius":
- anchor = self._point_world(ids[0]); label = "eqr"
- else:
+ if ctype == "horizontal":
+ anchor = self._line_world_mid(ids[0]); label = "hrz"
+ elif ctype == "vertical":
+ anchor = self._line_world_mid(ids[0]); label = "vrt"
+ elif ctype == "midpoint":
+ anchor = self._line_world_mid(ids[1]); label = "mid"
+ elif ctype == "distance":
+ # Distance may be point-to-point OR point-to-line (e.g.
+ # point-on-line coincident surfaces as a coincident entry;
+ # a future point-to-line distance would do the same).
+ # Use _entity_anchor so a line id routes to the line
+ # midpoint instead of crashing on round().
+ a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
+ # NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
+ if a is not None and b is not None:
+ anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
+ label = f"dst {params[0]:.1f}" if params else "dst"
+ elif ctype == "parallel":
+ anchor = self._line_world_mid(ids[0]); label = "par"
+ elif ctype == "perpendicular":
+ anchor = self._line_world_mid(ids[0]); label = "perp"
+ elif ctype == "angle":
+ anchor = self._line_world_mid(ids[0])
+ label = f"ang {params[0]:.0f}" if params else "ang"
+ elif ctype == "equal":
+ anchor = self._line_world_mid(ids[0]); label = "eql"
+ elif ctype == "coincident":
+ # Coincident can be point-to-point OR point-on-line (when
+ # a line is one of the targets). Use _entity_anchor so
+ # the line's midpoint is used as a fallback anchor when
+ # one of the ids is a line.
+ a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
+ if a is not None and b is not None:
+ anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
+ label = "coin"
+ elif ctype == "symmetric":
+ anchor = self._line_world_mid(ids[2]); label = "sym"
+ elif ctype == "fixed":
+ anchor = self._point_world(ids[0]); label = "fix"
+ elif ctype == "equal_radius":
+ anchor = self._point_world(ids[0]); label = "eqr"
+ else:
+ continue
+
+ if anchor is None:
+ continue
+ sc = self._world_to_screen(anchor)
+ key = (sc.x(), sc.y())
+ slot = stack_count.get(key, 0)
+ stack_count[key] = slot + 1
+
+ text = f"> {label} <"
+ w = fm.horizontalAdvance(text) + 10
+ h = 16
+ # Stack successive tags above the anchor so they don't overlap.
+ cx = sc.x()
+ cy = sc.y() - 14 - slot * (h + 2)
+ rect = QRect(cx - w // 2, cy - h // 2, w, h)
+ tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
+ except Exception as exc:
+ # Catch any failure while building this one tag (bad
+ # geometry, missing entity, numpy round weirdness, etc.)
+ # so a single bad entry can't take down the whole paint
+ # event. Drop the tag and move on; the user sees the
+ # other tags as normal.
+ logger.debug(
+ "Skipped constraint tag #%s (%s) due to %s: %s",
+ idx, entry.get("type"), type(exc).__name__, exc,
+ )
continue
-
- if anchor is None:
- continue
- sc = self._world_to_screen(anchor)
- key = (sc.x(), sc.y())
- slot = stack_count.get(key, 0)
- stack_count[key] = slot + 1
-
- text = f"> {label} <"
- w = fm.horizontalAdvance(text) + 10
- h = 16
- # Stack successive tags above the anchor so they don't overlap.
- cx = sc.x()
- cy = sc.y() - 14 - slot * (h + 2)
- rect = QRect(cx - w // 2, cy - h // 2, w, h)
- tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
return tags
# ─── Element move helpers ─────────────────────────────────────────────
@@ -974,7 +1254,8 @@ class Sketch2DWidget(QWidget):
if self._mode in ("select", None) and self._sketch:
# ① Tight point check (4 px) — a deliberate grab on a point.
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
- if tight_ent is not None and tight_ent.geometry:
+ if (tight_ent is not None and tight_ent.geometry
+ and not self._is_external(tight_ent)):
x, y = tight_ent.geometry
moving = self._collect_connected_points(tight_ent)
orig: Dict[int, Tuple[float, float]] = {}
@@ -1001,9 +1282,14 @@ class Sketch2DWidget(QWidget):
self.update()
return
- # ③ Wider element-move check (lines and circles).
+ # ③ Wider element-move check (lines and circles). External
+ # (underlay) entities are fixed references and can't be
+ # dragged — fall through to the constraint / draw handlers
+ # so a click on an underlay edge is treated as a constraint
+ # pick (the desired behavior) rather than a no-op.
target = self._find_move_target(event.pos())
- if target is not None:
+ if (target is not None
+ and not self._is_external(target[0])):
anchor_ent, anchor_world = target
moving = self._collect_connected_points(anchor_ent)
orig: Dict[int, Tuple[float, float]] = {}
@@ -1245,6 +1531,13 @@ class Sketch2DWidget(QWidget):
line_ent = self._hovered_line_entity
if line_ent is None or self._sketch is None:
return
+ # External (underlay) lines are reference geometry from the source
+ # face and can't be deleted one at a time. See delete_point for the
+ # same guard — the whole underlay is cleared in one shot via
+ # remove_external_entities when the source face is unset.
+ if getattr(line_ent, "is_external", False):
+ logger.debug("Refusing to delete external (underlay) line")
+ return
ok = self._sketch.delete_line(line_ent)
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
# Refresh widget tracking from the pruned sketch and sync solved positions.
@@ -1263,6 +1556,14 @@ class Sketch2DWidget(QWidget):
point_ent = self._hovered_point_entity
if point_ent is None or self._sketch is None:
return
+ # External (underlay) points are reference geometry from the source
+ # face — they can't be deleted individually. The whole underlay
+ # is cleared via remove_external_entities when the source face is
+ # removed; silently refuse here so the user gets no surprise
+ # cascading deletion of every other underlay element.
+ if getattr(point_ent, "is_external", False):
+ logger.debug("Refusing to delete external (underlay) point")
+ return
ok = self._sketch.delete_point(point_ent)
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
self._rebuild_from_sketch()
@@ -1667,27 +1968,15 @@ class Sketch2DWidget(QWidget):
painter.drawLine(origin.x() - 20, origin.y(), origin.x() + 20, origin.y())
painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20)
- # ── Source-face underlay (sketch-on-surface) ──
- # Draw the picked face's boundary edges in UV as a dashed guide so the
- # user sees the face they're drawing on, aligned to the 2D frame.
- if self._source_underlay_uv:
- painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
- for poly in self._source_underlay_uv:
- if len(poly) < 2:
- continue
- sp0 = self._world_to_screen(
- QPoint(int(round(poly[0][0])), int(round(poly[0][1])))
- )
- prev = sp0
- for (u, v) in poly[1:]:
- sp = self._world_to_screen(
- QPoint(int(round(u)), int(round(v)))
- )
- painter.drawLine(prev, sp)
- prev = sp
- # Close the polyline back to its start for a clean loop.
- painter.drawLine(prev, sp0)
- # Subtle fill hint over the first (outer) loop for readability.
+ # ── Source-face underlay fill (sketch-on-surface) ──
+ # The underlay is now drawn as real construction-line entities
+ # (rendered below in the Points / Lines sections with an orange
+ # dashed style) so the user can pick them for constraints. We
+ # still draw a faint fill over the *outer* loop of the projected
+ # face here for visual context, but the lines themselves are NOT
+ # drawn from this cache any more — that would double-paint the
+ # underlay on top of the entity-based lines.
+ if self._source_underlay_uv and self._underlay_visible:
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
fill_poly = QPolygonF([
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
@@ -1699,6 +1988,11 @@ class Sketch2DWidget(QWidget):
# ── Points ──
for entity in self._points:
+ # External / underlay points are rendered in the dedicated
+ # underlay block below (with an orange style) and skipped when
+ # the underlay is hidden. Skip them here to avoid double draw.
+ if self._is_external(entity):
+ continue
if entity.geometry:
x, y = entity.geometry
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
@@ -1711,8 +2005,40 @@ class Sketch2DWidget(QWidget):
size = 4 if entity.is_construction else 6
painter.drawEllipse(screen_pos, size, size)
+ # ── Underlay (face-projected) construction lines & points ──
+ # Drawn with an orange dashed style so the user can visually tell
+ # them apart from user-drawn construction lines (grey). Gated by
+ # the underlay visibility toggle.
+ if self._underlay_visible:
+ for p1_ent, p2_ent in self._lines:
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if not self._is_external(line_ent):
+ continue
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
+ sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
+ painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
+ painter.drawLine(sp1, sp2)
+ for entity in self._points:
+ if not self._is_external(entity):
+ continue
+ if entity.geometry:
+ x, y = entity.geometry
+ screen_pos = self._world_to_screen(
+ QPoint(int(round(x)), int(round(y)))
+ )
+ painter.setPen(QPen(QColor("#fab387"), 1))
+ painter.setBrush(QBrush(QColor("#fab387")))
+ painter.drawEllipse(screen_pos, 4, 4)
+
# ── Lines ──
for p1_ent, p2_ent in self._lines:
+ # External lines are drawn above; skip here to avoid double draw.
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if self._is_external(line_ent):
+ continue
if p1_ent.geometry and p2_ent.geometry:
x1, y1 = p1_ent.geometry
x2, y2 = p2_ent.geometry
@@ -1906,6 +2232,12 @@ class Viewer3DWidget(QWidget):
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
+ # Most recently recorded owning obj_id for the face returned by
+ # ``pick_planar_face``. Stashed on each pick pass so the host can
+ # pair the picked face with the body it belongs to (used to auto-
+ # target a cut/union extrude against the body the sketch was
+ # projected onto).
+ self._last_pick_owner_obj_id: Optional[str] = None
def _init_renderer(self) -> None:
"""Create the best available renderer."""
@@ -2022,6 +2354,59 @@ class Viewer3DWidget(QWidget):
del self._meshes[mesh_id]
self._renderer.render()
+ def set_visibility(self, mesh_id: str, visible: bool) -> bool:
+ """Show or hide a previously-added mesh without removing it.
+
+ Used by the per-body visibility toggle on the body list so the
+ user can quickly hide a body (e.g. to verify a cut worked on
+ another body). Returns True on success, False if the mesh is
+ unknown to the renderer or the renderer doesn't support it
+ (e.g. the Pygfx fallback ABI).
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "set_visibility", None)
+ if fn is None:
+ return False
+ ok = fn(mesh_id, visible)
+ if ok:
+ self._renderer.render()
+ return ok
+
+ def set_transparency(self, mesh_id: str, transparency: float) -> bool:
+ """Set a previously-added mesh's transparency (0..1).
+
+ Used by the live extrude preview to dim the target body so the
+ previewed result reads on top of it.
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "set_object_transparency", None)
+ if fn is None:
+ return False
+ return fn(mesh_id, transparency)
+
+ def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
+ """Display a temporary transparent preview of *shape* in the 3D view.
+
+ Used by the ExtrudeDialog live preview: as the user drags the
+ length spinner or toggles Cut/Through-All, the host recomputes
+ the operation result and shows it here. Call clear_preview()
+ when the dialog closes.
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "preview_shape", None)
+ if fn is None:
+ return
+ fn(shape, color, transparency)
+
+ def clear_preview(self) -> None:
+ """Remove the live extrude preview shape, if any."""
+ if not self._initialized or self._renderer is None:
+ return
+ fn = getattr(self._renderer, "clear_preview", None)
+ if fn is None:
+ return
+ fn()
+
def clear_scene(self):
self._ensure_initialized()
self._renderer.clear_scene()
@@ -2088,7 +2473,7 @@ class Viewer3DWidget(QWidget):
"""Toggle face-pick mode.
When enabled, the cursor selects planar faces for sketch placement
- instead of orbiting the camera. Middle/right buttons still pan/zoom.
+ instead of orbiting the camera. Middle button still pans; wheel zooms.
"""
self._pick_face_mode = bool(enabled)
if enabled:
@@ -2099,6 +2484,22 @@ class Viewer3DWidget(QWidget):
def is_pick_face_mode(self) -> bool:
return self._pick_face_mode
+ def highlight_face(self, face: Any) -> None:
+ """Tint the picked face light-blue/transparent in the 3D viewer."""
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "highlight_face", None)
+ if fn is not None:
+ fn(face)
+ self._renderer.render()
+
+ def clear_face_highlight(self) -> None:
+ """Remove the persistent face-selection tint."""
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "clear_face_highlight", None)
+ if fn is not None:
+ fn()
+ self._renderer.render()
+
def _handle_face_pick(self, event) -> None:
"""Detect a planar face under the click and emit facePicked."""
self._ensure_initialized()
@@ -2112,6 +2513,9 @@ class Viewer3DWidget(QWidget):
if info is None:
logger.info("Face pick: no planar face under cursor")
return
+ # Stash the owning obj_id so MainWindow._on_face_picked can pair the
+ # picked face with the body it belongs to (for auto-targeted cut).
+ self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.facePicked.emit(
tuple(info["origin"]),
tuple(info["normal"]),
@@ -2319,6 +2723,7 @@ class MainWindow(QMainWindow):
self._btn_wp_face = QPushButton("WP Face")
self._btn_wp_face.setToolTip("Working Plane from selected face")
self._btn_wp_face.setShortcut("P")
+ self._btn_wp_face.setCheckable(True)
wp_layout.addWidget(self._btn_wp_face, 0, 1)
self._btn_wp_flip = QPushButton("WP Flip")
self._btn_wp_flip.setToolTip("Flip normal direction")
@@ -2328,6 +2733,24 @@ class MainWindow(QMainWindow):
self._btn_wp_move.setToolTip("Move workplane")
self._btn_wp_move.setShortcut("M")
wp_layout.addWidget(self._btn_wp_move, 1, 1)
+ # Underlay: show / hide the construction lines projected from the
+ # source face. Off by default is meaningless (no face picked yet)
+ # so it auto-unchecks when the user clears the source face.
+ self._btn_underlay = QPushButton("Underlay")
+ self._btn_underlay.setToolTip(
+ "Show / hide the construction lines projected from the source face"
+ )
+ self._btn_underlay.setCheckable(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_underlay.setEnabled(False) # becomes enabled after WP Face
+ wp_layout.addWidget(self._btn_underlay, 2, 0)
+ # ClrFace: forget the picked source face. Removes the underlay
+ # construction-line entities and leaves the workplane + sketch
+ # intact, so the user can keep drawing on a free-standing sketch.
+ self._btn_clr_face = QPushButton("ClrFace")
+ self._btn_clr_face.setToolTip("Forget the picked source face (keep the workplane)")
+ self._btn_clr_face.setEnabled(False) # becomes enabled after WP Face
+ wp_layout.addWidget(self._btn_clr_face, 2, 1)
grid.addWidget(wp_group, 0, 0)
# ---- Row 1, Col 0: Drawing ----
@@ -2478,12 +2901,6 @@ class MainWindow(QMainWindow):
sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
self._btn_del_sketch = QPushButton("Del")
sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2)
- self._btn_face_sketch = QPushButton("Face")
- self._btn_face_sketch.setToolTip(
- "Pick a planar face in the 3D viewer to sketch on"
- )
- self._btn_face_sketch.setCheckable(True)
- sk_tools_grid.addWidget(self._btn_face_sketch, 1, 0, 1, 3)
sk_list_layout.addWidget(sk_tools)
grid.addWidget(sk_list_group, 6, 0, 3, 1)
@@ -2548,10 +2965,6 @@ class MainWindow(QMainWindow):
self._btn_array = QPushButton("Arry")
self._btn_array.setToolTip("Pattern array")
modify_layout.addWidget(self._btn_array, 2, 1)
- self._btn_layout = QPushButton("Layout")
- self._btn_layout.setToolTip("Layout: equal (Space to cycle)")
- self._btn_layout.setCheckable(False)
- modify_layout.addWidget(self._btn_layout, 3, 0, 1, 2)
grid.addWidget(modify_group, 0, 3, 1, 1, Qt.AlignTop)
# ---- Row 6, Col 3: Export ----
@@ -2650,14 +3063,8 @@ class MainWindow(QMainWindow):
else: # equal
self._grid.setColumnStretch(1, 1)
self._grid.setColumnStretch(2, 1)
- # Reflect current mode on the Layout button tooltip.
- if hasattr(self, "_btn_layout") and self._btn_layout is not None:
- self._btn_layout.setToolTip(self._panel_tooltip())
logger.info(f"Panel focus -> {self._panel_focus}")
- def _panel_tooltip(self) -> str:
- return f"Layout: {self._panel_focus} (Space to cycle)"
-
def keyPressEvent(self, event):
# Spacebar cycles the sketch/viewer split so you can grow the side you're
# working in without leaving the keyboard.
@@ -2711,10 +3118,10 @@ class MainWindow(QMainWindow):
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
self._btn_del_sketch.clicked.connect(self._delete_sketch)
- self._btn_face_sketch.toggled.connect(self._on_face_sketch_toggled)
+ self._btn_wp_face.toggled.connect(self._on_face_sketch_toggled)
self._viewer_3d.facePicked.connect(self._on_face_picked)
self._viewer_3d.pickFaceCancelled.connect(
- lambda: self._btn_face_sketch.setChecked(False)
+ lambda: self._btn_wp_face.setChecked(False)
)
self._btn_new_compo.clicked.connect(self._new_component)
@@ -2732,16 +3139,24 @@ class MainWindow(QMainWindow):
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
+ # Per-body visibility toggle: the user clicks the checkbox next
+ # to a body name in the right-hand list. We update the body's
+ # ``visible`` flag and ask the viewer to show/hide the mesh.
+ # (itemChanged also fires for selection changes; the handler
+ # filters on the check-state role.)
+ self._body_list.itemChanged.connect(self._on_body_visibility_changed)
self._btn_wp_origin.clicked.connect(self._new_sketch_origin)
- self._btn_wp_face.clicked.connect(self._new_sketch_from_face)
self._btn_wp_flip.clicked.connect(self._flip_workplane)
self._btn_wp_move.clicked.connect(self._move_workplane)
+ # Underlay show/hide and ClrFace — both stay in sync with the
+ # source face state managed by set_source_face / clear_source_face.
+ self._btn_underlay.toggled.connect(self._on_underlay_toggled)
+ self._btn_clr_face.clicked.connect(self._on_clear_source_face)
# Generic buttons
self._btn_move.clicked.connect(self._translate_body)
self._btn_array.clicked.connect(self._pattern_array)
- self._btn_layout.clicked.connect(self._toggle_panel_focus)
self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
# Snap toggle
@@ -2877,7 +3292,21 @@ class MainWindow(QMainWindow):
self._sketch_list.addItem(sketch.name)
for body_id, body in self._current_component.bodies.items():
- self._body_list.addItem(body.name)
+ # QListWidgetItem with a checkbox so the user can toggle
+ # each body's visibility in the 3D viewer. The item's
+ # data role stores the body id so the toggle handler can
+ # look up the right body without relying on display text.
+ item = QListWidgetItem(body.name)
+ item.setData(Qt.UserRole, body_id)
+ # Qt.Checked = visible, Qt.Unchecked = hidden. Default
+ # is whatever the body model says.
+ item.setCheckState(
+ Qt.Checked if body.visible else Qt.Unchecked
+ )
+ # Greying out a hidden body's name is a nice UX touch.
+ if not body.visible:
+ item.setForeground(QColor("#6c7086"))
+ self._body_list.addItem(item)
def _redraw_bodies(self):
self._viewer_3d.clear_scene()
@@ -2898,12 +3327,6 @@ class MainWindow(QMainWindow):
self._btn_line.setChecked(True)
logger.info("New sketch at origin")
- def _new_sketch_from_face(self):
- self._sketch_widget.create_sketch()
- self._sketch_widget.set_mode("line")
- self._btn_line.setChecked(True)
- logger.info("New sketch from face")
-
def _flip_workplane(self):
logger.info("Flip workplane (not implemented)")
@@ -2937,9 +3360,11 @@ class MainWindow(QMainWindow):
# ─── Sketch-on-surface (face pick) ────────────────────────────────────
def _on_face_sketch_toggled(self, checked: bool) -> None:
- """Toggle the 3D viewer's face-pick mode."""
+ """Toggle the 3D viewer's face-pick mode (WP Face button)."""
self._viewer_3d.set_pick_face_mode(checked)
if checked:
+ # Clear any previous face-selection tint before picking a new one.
+ self._viewer_3d.clear_face_highlight()
# Make sure the 3D viewer has focus so it receives the click.
self._viewer_3d.setFocus()
self._viewer_3d.activateWindow()
@@ -2949,12 +3374,34 @@ class MainWindow(QMainWindow):
)
def _on_face_picked(self, origin, normal, x_dir, face_shape) -> None:
- """Create a new sketch on the picked planar face and switch to 2D."""
+ """Create a new sketch on the picked planar face and switch to 2D.
+
+ Also records *which body* the picked face belonged to on the sketch
+ (``sketch._source_body_id``) so a later "Perform Cut" / "Combine"
+ extrude operation auto-targets that body instead of the first body
+ in the dict. Auto-selects the new sketch in the left-hand list so
+ the user can immediately Extrude/Cut without hunting for the row.
+ """
+ # ``facePicked`` carries the face shape PLUS the owning obj_id from
+ # ``pick_planar_face`` (the renderer matches DetectedInteractive
+ # against tracked AIS objects). We extract that owner so the cut
+ # can target the right body.
+ source_body = None
logger.info(
f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}"
)
+ # Pull the owning obj_id the renderer stashed on this pick pass.
+ owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
+ if owner_obj_id and self._current_component is not None:
+ for bid, body in self._current_component.bodies.items():
+ if body.render_object == owner_obj_id:
+ source_body = body
+ logger.info(f"Sketch source body: {body.name}")
+ break
+ # Tint the picked face light-blue so the selection is visible in 3D.
+ self._viewer_3d.highlight_face(face_shape)
# Leave pick mode (the button stays toggled until we uncheck it).
- self._btn_face_sketch.setChecked(False)
+ self._btn_wp_face.setChecked(False)
self._viewer_3d.set_pick_face_mode(False)
if not self._current_component:
@@ -2966,9 +3413,16 @@ class MainWindow(QMainWindow):
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
sketch._source_face = face_shape
+ # Remember which body the sketch lives on so a later cut / combine
+ # extrude auto-targets it. ``source_body`` may be None if the
+ # pick landed on an untracked shape (e.g. an imported STEP that
+ # wasn't registered as a component body — robust fallback then).
+ sketch._source_body_id = source_body.id if source_body else None
# Hand the sketch to the 2D widget and focus the sketch panel.
- if sketch.occ_sketch is None:
+ # Always build a clean OCC sketch carrying the face workplane so the
+ # widget draws on the picked plane.
+ if sketch.occ_sketch is None or sketch.occ_sketch.get_entity_count() > 0:
sketch.occ_sketch = self._sketch_widget.create_sketch()
sketch.apply_workplane()
self._sketch_widget.set_sketch(sketch.occ_sketch)
@@ -2978,11 +3432,58 @@ class MainWindow(QMainWindow):
self._btn_line.setChecked(True)
self._refresh_lists()
+ # Auto-select the freshly created sketch in the left-hand list so a
+ # 3D op (Extrude/Cut) operates on it without the user hunting for
+ # the row. _on_sketch_selected loads it into the widget for editing.
+ for row in range(self._sketch_list.count()):
+ item = self._sketch_list.item(row)
+ if item is not None and item.text() == sketch.name:
+ self._sketch_list.setCurrentRow(row)
+ break
# Switch focus to the sketch panel so the user can draw immediately.
self._set_panel_focus("sketch")
self.statusBar().showMessage(
f"Sketch placed on face — drawing in 2D on that plane", 6000
)
+ # The face is now the source for the underlay construction lines:
+ # enable the show/hide toggle and the ClrFace button.
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+
+ def _on_underlay_toggled(self, checked: bool) -> None:
+ """Show or hide the underlay construction lines in the 2D view.
+
+ Toggling this button does NOT remove the external entities from the
+ sketch solver — they stay there so existing constraints that
+ reference them keep working. The entities are just hidden from
+ paint + hover + hit-test while the toggle is off.
+ """
+ self._sketch_widget.set_underlay_visible(checked)
+ self.statusBar().showMessage(
+ f"Underlay {'visible' if checked else 'hidden'}", 2000
+ )
+
+ def _on_clear_source_face(self) -> None:
+ """Forget the source face: remove underlay entities, keep the workplane.
+
+ After this, the sketch remains on the same plane but the face
+ reference and its projected construction lines are gone. The user
+ keeps whatever user-drawn geometry they already added (and any
+ constraints they already applied, since they were pinned to entity
+ ids that are now removed along with the underlay).
+ """
+ self._sketch_widget.clear_source_face()
+ self._btn_underlay.setEnabled(False)
+ self._btn_underlay.setChecked(False)
+ self._btn_clr_face.setEnabled(False)
+ if self._current_sketch is not None:
+ # Drop the saved reference on the model so re-editing the
+ # sketch later doesn't re-create the underlay.
+ self._current_sketch._source_face = None
+ self.statusBar().showMessage(
+ "Source face cleared — underlay construction lines removed", 3000
+ )
def _pattern_array_placeholder(self):
pass
@@ -3004,6 +3505,17 @@ class MainWindow(QMainWindow):
logger.info("Creating new sketch in widget")
sketch.occ_sketch = self._sketch_widget.create_sketch()
+ # Adopt the widget sketch's existing 3D workplane (e.g. set by a
+ # face-pick) instead of clobbering it with this Sketch's default XY
+ # fields — otherwise a sketch drawn on a picked face would jump back
+ # to the world origin plane on the next extrude.
+ if sketch.occ_sketch is not None and hasattr(sketch.occ_sketch, "get_workplane"):
+ wp = sketch.occ_sketch.get_workplane()
+ import numpy as _np
+ sketch.workplane_origin = _np.asarray(wp[0], dtype=float)
+ sketch.workplane_normal = _np.asarray(wp[1], dtype=float)
+ sketch.workplane_x_dir = _np.asarray(wp[2], dtype=float)
+
# Sync the sketch's workplane (origin/normal/x_dir) into the OCC sketch
# so geometry is built on the right plane.
sketch.apply_workplane()
@@ -3026,6 +3538,25 @@ class MainWindow(QMainWindow):
if sketch.occ_sketch:
sketch.apply_workplane()
self._sketch_widget.set_sketch(sketch.occ_sketch)
+ # If the sketch carries a saved source face (sketch-on-
+ # surface), re-bind it so the underlay construction lines
+ # come back. set_source_face rebuilds the external
+ # entities and re-orients the 2D view.
+ if getattr(sketch, "_source_face", None) is not None and sketch.occ_sketch is not None:
+ wp = sketch.occ_sketch.get_workplane()
+ origin, normal, x_dir = wp[0], wp[1], wp[2]
+ self._sketch_widget.set_source_face(
+ sketch._source_face, origin, normal, x_dir
+ )
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+ else:
+ # No saved face: make sure the underlay buttons
+ # reflect that the widget has no source face bound.
+ self._btn_underlay.setEnabled(False)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(False)
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
logger.info(f"Editing sketch: {name}")
@@ -3076,6 +3607,239 @@ class MainWindow(QMainWindow):
logger.info(f"Selected: {name}")
break
+ def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
+ """Toggle a body's 3D visibility when the user flips its checkbox.
+
+ itemChanged also fires for selection (not just check-state) changes,
+ so we filter on the check state being the changed role. The body
+ is looked up via the UserRole data we set in _refresh_lists.
+ """
+ if self._current_component is None:
+ return
+ body_id = item.data(Qt.UserRole)
+ if body_id is None:
+ return
+ body = self._current_component.bodies.get(body_id)
+ if body is None:
+ return
+ new_visible = item.checkState() == Qt.Checked
+ if body.visible == new_visible:
+ return # no change
+ body.visible = new_visible
+ # Greying out hidden bodies gives a quick visual hint in the list.
+ item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
+ # Apply to the 3D viewer: if the body has a rendered object, hide
+ # or show it. Bodies without a render_object (e.g. just-created,
+ # not yet displayed) don't need viewer updates; they'll pick up
+ # the visibility at the next redraw.
+ if body.render_object is not None:
+ ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
+ if not ok:
+ logger.debug(
+ "set_visibility failed for body %s (render_object=%r)",
+ body.name, body.render_object,
+ )
+ logger.info(
+ f"{'Visible' if new_visible else 'Hidden'}: {body.name}"
+ )
+
+ # ─── Extrude / cut helpers (shared by live preview + apply) ────────
+
+ def _resolve_extrude_target(
+ self, sketch: Sketch, exclude_body: Optional[Body] = None
+ ) -> Optional[Body]:
+ """Choose the body a cut / union should target.
+
+ Preference order:
+ 1. the body the sketch was projected onto (``sketch._source_body_id``)
+ 2. the first body in the component that isn't the *exclude_body*
+ (the freshly-extruded tool itself, which we don't want to cut
+ *itself*).
+ Returns *None* if there is no candidate (e.g. the sketch wasn't
+ on a face and the component has no other bodies).
+ """
+ if self._current_component is None:
+ return None
+ bodies = self._current_component.bodies
+ src_id = getattr(sketch, "_source_body_id", None)
+ if src_id is not None and src_id in bodies:
+ cand = bodies[src_id]
+ if cand is not exclude_body:
+ return cand
+ for body in bodies.values():
+ if body is exclude_body:
+ continue
+ return body
+ return None
+
+ def _through_all_length(self, target: Body, sketch: Sketch) -> float:
+ """Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
+ *through* the target body.
+
+ Computes the target body's bounding-box extent along the sketch's
+ workplane normal direction ("extent" = how far the body reaches on
+ either side of the face). With ``symmetric=True`` the kernel
+ extrudes ``± height/2``, so to clear the full ``extent`` on each
+ side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
+ on each side guarantees the tool pokes out past the body so the
+ boolean reliably removes the through volume.
+ """
+ import numpy as _np
+ try:
+ p_min, p_max = self._kernel.get_bounding_box(target.geometry)
+ except Exception:
+ logger.debug("through-all bbox failed", exc_info=True)
+ return 2000.0 # generous fallback if bbox fails for any reason
+ origin = _np.asarray(sketch.workplane_origin, dtype=float)
+ normal = _np.asarray(sketch.workplane_normal, dtype=float)
+ normal = normal / max(_np.linalg.norm(normal), 1e-12)
+ corners = []
+ for xs in (p_min.x, p_max.x):
+ for ys in (p_min.y, p_max.y):
+ for zs in (p_min.z, p_max.z):
+ corners.append(_np.array([xs, ys, zs]))
+ ds = [_np.dot(c - origin, normal) for c in corners]
+ extent = max(abs(min(ds)), abs(max(ds)))
+ # Symmetric through: cover ±(extent + 5 mm) on each side of the
+ # face plane, which means a total height of 2×(extent + 5).
+ return 2.0 * float(extent) + 10.0
+
+ def _compute_extrude_result(
+ self,
+ sketch: Sketch,
+ face_geom: Any,
+ length: float,
+ symmetric: bool,
+ invert: bool,
+ cut: bool,
+ union: bool,
+ through_all: bool,
+ ) -> Optional[Dict[str, Any]]:
+ """Compute the *previewable* result of an extrude/cut/union.
+
+ Returns a dict with:
+ - "result_shape": final TopoDS_Shape (the thing to show / commit)
+ - "target_body": the Body being modified (None for plain extrude)
+ - "tool_geom": the extruded profile geometry (the boolean tool)
+ - "tool_shape": same, as a TopoDS_Shape (for show/remove)
+ Or *None* if the geometry can't be built (e.g. empty sketch).
+
+ Mutates nothing on the project — safe to call repeatedly for the
+ live preview. The apply path (:meth:`_extrude_sketch`) commits
+ the returned shape onto ``target_body`` (or creates a new body
+ for plain extrudes).
+ """
+ if face_geom is None:
+ return None
+ # Resolve target (only meaningful for cut / union).
+ target = self._resolve_extrude_target(sketch) if (cut or union) else None
+ # Determine the extrude length and direction.
+ if through_all and target is not None:
+ # Pass-through: symmetric extrude large enough to clear the body
+ # on both sides of the face plane (direction-agnostic).
+ extrude_length = self._through_all_length(target, sketch)
+ symmetric = True
+ invert = False
+ else:
+ # Cut targeting a body must go *into* the body — the picked face's
+ # outward normal points AWAY from the body, so a non-inverted
+ # extrude would build a boss ABOVE the face and the boolean cut
+ # would remove nothing. Force the tool into the body so
+ # "Perform Cut" always carves a real pocket.
+ if cut and target is not None:
+ invert = True
+ extrude_length = -length if invert else length
+ try:
+ tool_geom = self._kernel.extrude(
+ face_geom, extrude_length, symmetric=symmetric
+ )
+ except Exception as exc:
+ logger.debug("preview extrude failed: %s", exc)
+ return None
+ if tool_geom is None:
+ return None
+ tool_shape = self._kernel._get_shape(tool_geom)
+ if target is not None:
+ try:
+ if cut:
+ result_geom = self._kernel.boolean_difference(
+ target.geometry, tool_geom
+ )
+ else: # union
+ result_geom = self._kernel.boolean_union(
+ target.geometry, tool_geom
+ )
+ except Exception as exc:
+ logger.debug("preview boolean failed: %s", exc)
+ return None
+ result_shape = self._kernel._get_shape(result_geom)
+ return {
+ "result_shape": result_shape,
+ "result_geom": result_geom,
+ "target_body": target,
+ "tool_geom": tool_geom,
+ "tool_shape": tool_shape,
+ }
+ # Plain extrude: the tool IS the result.
+ return {
+ "result_shape": tool_shape,
+ "result_geom": tool_geom,
+ "target_body": None,
+ "tool_geom": tool_geom,
+ "tool_shape": tool_shape,
+ }
+
+ def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
+ """Install a live-preview callback on *dialog* for *sketch*.
+
+ The host dims the body the cut/union will target (if any) so the
+ previewed result reads clearly on top of it. The dimming is
+ reverted on dialog close (see hideEvent → callback(None)).
+ """
+ # Track which bodies we dimmed so we can restore their transparency
+ # exactly (they might have had a non-zero transparency to start, in
+ # which case we leave them alone).
+ state = {"dimmed": []}
+
+ def _apply_dim(target: Optional[Body]):
+ # Undo any prior dim, then dim the new target.
+ for bid, tval in state["dimmed"]:
+ body = self._current_component.bodies.get(bid) if self._current_component else None
+ if body is not None and body.render_object is not None:
+ self._viewer_3d.set_transparency(body.render_object, 0.0)
+ state["dimmed"].clear()
+ if target is not None and target.render_object is not None:
+ ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
+ if ok:
+ state["dimmed"].append((target.id, 0.6))
+
+ def _clear():
+ self._viewer_3d.clear_preview()
+ for bid, _tval in state["dimmed"]:
+ body = self._current_component.bodies.get(bid) if self._current_component else None
+ if body is not None and body.render_object is not None:
+ self._viewer_3d.set_transparency(body.render_object, 0.0)
+ state["dimmed"].clear()
+
+ def _callback(values):
+ if values is None:
+ _clear()
+ return
+ length, symmetric, invert, cut, union, through_all, _rounded = values
+ result = self._compute_extrude_result(
+ sketch, face_geom,
+ length, symmetric, invert, bool(cut), bool(union),
+ bool(through_all),
+ )
+ if result is None or result["result_shape"] is None:
+ self._viewer_3d.clear_preview()
+ _apply_dim(None)
+ return
+ self._viewer_3d.show_preview(result["result_shape"])
+ _apply_dim(result["target_body"])
+
+ dialog.set_preview_callback(_callback)
+
def _extrude_sketch(self):
logger.info("=== EXTRUDE SKETCH ===")
if not self._current_component:
@@ -3093,82 +3857,91 @@ class MainWindow(QMainWindow):
return
sketch.occ_sketch = sketch_entity
- dialog = ExtrudeDialog(self)
- if dialog.exec():
- length, symmetric, invert, cut, union, rounded = dialog.get_values()
- logger.info(f"Extrude params: length={length}, symmetric={symmetric}, invert={invert}")
+ # Resolve the profile geometry *before* opening the dialog so the
+ # live preview can use it. Prefer the selected face (which can
+ # include holes) over the full sketch.
+ face_geom = self._sketch_widget.get_selected_face_geometry()
+ if face_geom is not None:
+ logger.info("Using selected face geometry (with holes)")
else:
+ face_geom = sketch.occ_sketch.get_geometry()
+ logger.debug(f"Geometry: {face_geom}")
+ if not face_geom:
+ logger.error("No geometry from sketch")
+ QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
+ return
+
+ dialog = ExtrudeDialog(self)
+ # Wire up the live preview: every spinbox/checkbox change rebuilds
+ # the result via the shared helper and shows it transparent.
+ self._start_extrude_preview(dialog, sketch, face_geom)
+ accepted = dialog.exec()
+ # The dialog's hideEvent already fired the callback with *None* to
+ # clear the preview and un-dim any body — but be defensive in case
+ # a subclass swallows the event.
+ self._viewer_3d.clear_preview()
+ if not accepted:
logger.info("Extrude dialog cancelled")
return
+ length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
+ logger.info(
+ f"Extrude params: length={length}, symmetric={symmetric}, "
+ f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
+ )
+
try:
- logger.debug("Getting geometry from sketch")
- # Prefer the selected face (which can include holes) over the full sketch.
- face_geom = self._sketch_widget.get_selected_face_geometry()
- if face_geom is not None:
- geometry = face_geom
- logger.info("Using selected face geometry (with holes)")
- else:
- geometry = sketch.occ_sketch.get_geometry()
- logger.debug(f"Geometry: {geometry}")
- if not geometry:
- logger.error("No geometry from sketch")
- QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
+ result = self._compute_extrude_result(
+ sketch, face_geom,
+ length, symmetric, invert, bool(cut), bool(union),
+ bool(through_all),
+ )
+ if result is None or result["result_geom"] is None:
+ logger.warning("Extrude produced no geometry")
+ QMessageBox.warning(self, "No Geometry", "Extrude produced no geometry")
return
- logger.info("Calling kernel.extrude")
- # Apply invert: the user-input length is always positive; invert
- # flips the extrusion direction (plus is +Z, minus is -Z).
- extrude_length = -length if invert else length
- body_geometry = self._kernel.extrude(geometry, extrude_length, symmetric=symmetric)
- logger.debug(f"Body geometry created: {body_geometry}")
-
- body = self._current_component.add_body(
- Body(
- name=f"Extrusion_{len(self._current_component.bodies) + 1}",
- geometry=body_geometry,
- source_sketch=sketch,
- source_operation="extrude",
+ target = result["target_body"]
+ if target is not None:
+ # Cut / union: commit the result onto the *target* body in
+ # place (don't create a separate tool body — the previous
+ # implementation did, and that was the user-perceived
+ # "added without cut" bug once the spurious body was
+ # deleted).
+ target.geometry = result["result_geom"]
+ if target.render_object is not None:
+ self._viewer_3d.remove_mesh(target.render_object)
+ shape = self._kernel._get_shape(target.geometry)
+ target.render_object = self._viewer_3d.show_shape(
+ shape, target.color, target.name
)
- )
- logger.info(f"Created body: {body.name}")
-
- # Handle cut/union options from dialog
- if cut and self._current_component.bodies:
- logger.info("Applying cut after extrude")
- existing = list(self._current_component.bodies.values())
- if len(existing) > 1:
- target = existing[0] # first body is target
- try:
- result_geom = self._kernel.boolean_difference(
- target.geometry, body_geometry
- )
- body.geometry = result_geom
- logger.info("Cut applied")
- except Exception as ce:
- logger.warning(f"Cut failed: {ce}")
- elif union and self._current_component.bodies:
- logger.info("Applying union after extrude")
- existing = list(self._current_component.bodies.values())
- if len(existing) > 1:
- target = existing[0]
- try:
- result_geom = self._kernel.boolean_union(
- target.geometry, body_geometry
- )
- body.geometry = result_geom
- logger.info(f"Union applied")
- except Exception as ue:
- logger.warning(f"Union failed: {ue}")
-
- logger.debug("Adding shape to OCC viewer")
- shape = self._kernel._get_shape(body.geometry)
- body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
- logger.info(f"Render object: {body.render_object}")
+ op = "cut" if cut else "union"
+ logger.info(
+ f"{op.capitalize()} applied: {target.name} now holds the result"
+ )
+ body_name = target.name
+ else:
+ # Plain extrude: create a new body for the extrusion.
+ body = self._current_component.add_body(
+ Body(
+ name=f"Extrusion_{len(self._current_component.bodies) + 1}",
+ geometry=result["result_geom"],
+ source_sketch=sketch,
+ source_operation="extrude",
+ )
+ )
+ logger.info(f"Created body: {body.name}")
+ logger.debug("Adding shape to OCC viewer")
+ shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(
+ shape, body.color, body.name
+ )
+ logger.info(f"Render object: {body.render_object}")
+ body_name = body.name
self._refresh_lists()
self._viewer_3d.fit_camera()
- logger.info(f"Extruded: {body.name}")
+ logger.info(f"Extruded: {body_name}")
logger.info("=== EXTRUDE COMPLETE ===")
except Exception as e:
@@ -3354,9 +4127,16 @@ class MainWindow(QMainWindow):
btn.deleteLater()
self._component_buttons.clear()
+ # set_sketch(None) clears the underlay entities via the new
+ # set_sketch guard, but we also need to drop the saved source face
+ # and reset the workplane buttons to their disabled state.
+ self._sketch_widget.clear_source_face()
self._sketch_widget.set_sketch(None)
self._viewer_3d.clear_scene()
self._refresh_lists()
+ self._btn_underlay.setEnabled(False)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(False)
self._create_initial_component()
logger.info("New project created")
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index e7aae23..6623ed5 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -41,7 +41,13 @@ class OCCRenderer(Renderer):
self._parent_widget: Any = None
self._last_mouse_x: int = 0
self._last_mouse_y: int = 0
- self._nav_mode: Optional[str] = None # "rotate" | "pan" | "zoom" | None
+ self._pan_start_x: int = 0
+ self._pan_start_y: int = 0
+ self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
+ # Persistent light-blue transparent overlay marking the selected face.
+ self._highlight_ais: Any = None
+ # Temporary transparent preview AIS for the live extrude/cut dialog.
+ self._preview_ais: Any = None
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -156,6 +162,18 @@ class OCCRenderer(Renderer):
# Default display mode = shaded (AIS_Shaded = 1)
context.SetDisplayMode(1, True)
+ # Style the dynamic (hover) highlight as light-blue so the face
+ # pick preview matches the persistent selection overlay below.
+ try:
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+ # Modify the existing dynamic-highlight drawer in place (per
+ # OCC docs this is safer than building a fresh Prs3d_Drawer).
+ hd = context.HighlightStyle()
+ hd.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
+ hd.SetDisplayMode(1)
+ except Exception:
+ logger.debug("dynamic highlight style unavailable", exc_info=True)
+
# Attach OCC view to the Qt widget via the native window handle.
win = Aspect_NeutralWindow()
win.SetNativeHandle(hwnd)
@@ -372,10 +390,100 @@ class OCCRenderer(Renderer):
if obj is not None:
self.remove_object(obj)
+ def set_visibility(self, obj_id: str, visible: bool) -> bool:
+ """Show or hide an object by ID, preserving its place in the scene.
+
+ Unlike ``remove_mesh``, this doesn't free the object — the user can
+ toggle it back on later. Returns True on success, False if the
+ object isn't found (e.g. it was already removed).
+ """
+ obj = self._objects.get(obj_id)
+ if obj is None:
+ return False
+ self.set_object_visible(obj, visible)
+ return True
+
+ def set_object_transparency(self, obj_id: str, transparency: float) -> bool:
+ """Set the transparency of an object by ID (0.0 opaque..1.0 invisible).
+
+ Used by the live extrude preview to dim the existing target body
+ so the user can see the previewed result through/over it. Returns
+ True on success, False if the object isn't found.
+ """
+ obj = self._objects.get(obj_id)
+ if obj is None or obj.ais_shape is None:
+ return False
+ try:
+ obj.ais_shape.SetTransparency(transparency)
+ self._context.RecomputePrsOnly(obj.ais_shape, True)
+ except Exception:
+ logger.debug("set_object_transparency failed", exc_info=True)
+ return False
+ return True
+
+ # ─── Live preview (extrude/cut preview) ──────────────────────────────
+
+ _PREVIEW_ID = "__extrude_preview__"
+
+ def preview_shape(
+ self,
+ shape: Any,
+ color: Optional[Tuple[float, float, float]] = None,
+ transparency: float = 0.60,
+ ) -> None:
+ """Display a temporary transparent preview of *shape* (TopoDS_Shape).
+
+ The preview lives under a fixed id (``__extrude_preview__``) so a
+ subsequent call replaces the previous preview in place. Call
+ :meth:`clear_preview` to remove it. This is independent of the
+ tracked ``_objects`` dict — the preview is NOT a body and won't
+ be returned by ``pick_planar_face``'s owner scan.
+ """
+ if self._context is None:
+ return
+ # Clear any previous preview (uses the same id).
+ self.clear_preview()
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+
+ ais = AIS_Shape(shape)
+ try:
+ ais.SetMaterial(self._default_material())
+ except Exception:
+ logger.debug("preview material set failed", exc_info=True)
+ col = color or (0.45, 0.80, 0.95) # cyan-ish for preview
+ ais.SetColor(Quantity_Color(*col, Quantity_TOC_RGB))
+ ais.SetDisplayMode(1) # shaded
+ try:
+ ais.SetTransparency(transparency)
+ except Exception:
+ logger.debug("preview transparency set failed", exc_info=True)
+ # Draw face boundaries so the preview shape reads cleanly.
+ try:
+ drawer = ais.Attributes()
+ drawer.SetFaceBoundaryDraw(True)
+ except Exception:
+ pass
+ self._context.Display(ais, True)
+ self._preview_ais = ais
+ if self._view is not None:
+ self._view.Repaint()
+
+ def clear_preview(self) -> None:
+ """Remove the live extrude preview shape."""
+ if self._context is None or getattr(self, "_preview_ais", None) is None:
+ return
+ try:
+ self._context.Remove(self._preview_ais, True)
+ finally:
+ self._preview_ais = None
+
def clear_scene(self) -> None:
"""Remove all objects from the scene."""
if self._context is None:
return
+ self.clear_preview()
+ self.clear_face_highlight()
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
# path; fall back to iterating the displayed list if unavailable.
try:
@@ -642,6 +750,18 @@ class OCCRenderer(Renderer):
except Exception:
return None
+ # Outward normal: the plane's geometric axis is independent of the
+ # face's orientation (TopAbs_FORWARD / TopAbs_REVERSED). For a face
+ # on a solid the TRUE outward normal is the axis when FORWARD and its
+ # negation when REVERSED. Without this correction a top face whose
+ # axis happens to point inward would return an inward normal, so a
+ # default (non-inverted) extrude would punch back into the body
+ # instead of building outward on top of it.
+ from OCP.TopAbs import TopAbs_REVERSED
+ n = pln.Axis().Direction()
+ if face.Orientation() == TopAbs_REVERSED:
+ n = n.Reversed()
+
# Plane origin: use the face's bounding-box centre projected onto the
# plane, so the UV frame is centred on the face (nicer for sketching).
from OCP.Bnd import Bnd_Box
@@ -652,7 +772,6 @@ class OCCRenderer(Renderer):
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
# Project the bbox centre onto the plane.
pln_origin = pln.Location() # gp_Pnt
- n = pln.Axis().Direction()
nx, ny, nz = n.X(), n.Y(), n.Z()
# signed distance from bbox centre to plane
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
@@ -681,19 +800,84 @@ class OCCRenderer(Renderer):
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
+ # Identify the displayed body that owns this face, so the host can
+ # auto-target it as the cut/union body when the user extrudes the
+ # sketch-on-face. ``DetectedInteractive`` returns the AIS_
+ # InteractiveObject that the picked sub-shape belongs to; we match
+ # it against the renderer's tracked objects by AIS identity.
+ owner_obj_id: Optional[str] = None
+ try:
+ owner_ais = self._context.DetectedInteractive()
+ except Exception:
+ owner_ais = None
+ if owner_ais is not None:
+ for oid, robj in self._objects.items():
+ if robj.ais_shape is owner_ais:
+ owner_obj_id = oid
+ break
+
return {
"origin": origin,
"normal": (nx, ny, nz),
"x_dir": x_dir,
"face": face,
+ "owner_obj_id": owner_obj_id,
}
+ def highlight_face(self, face: Any) -> None:
+ """Overlay a persistent, mostly-transparent light-blue tint on *face*.
+
+ Used to mark the planar face the user has selected for sketch-on-
+ surface. The overlay is an independent ``AIS_Shape`` so it survives
+ until :meth:`clear_face_highlight` is called.
+ """
+ if self._context is None:
+ return
+ self.clear_face_highlight()
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+
+ ais = AIS_Shape(face)
+ try:
+ ais.SetMaterial(self._default_material())
+ except Exception:
+ logger.debug("highlight material set failed", exc_info=True)
+ ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
+ ais.SetDisplayMode(1) # shaded — tint the whole face, not just edges
+ try:
+ # Mostly transparent so the underlying face stays visible.
+ ais.SetTransparency(0.78)
+ except Exception:
+ logger.debug("highlight transparency set failed", exc_info=True)
+ try:
+ # Bias the overlay slightly toward the camera so it draws on top
+ # of the coincident face surface without z-fighting.
+ # mode 3 = Graphic3d_POM_Fill; negative units pull forward.
+ ais.SetPolygonOffsets(3, 1.0, -0.5)
+ except Exception:
+ logger.debug("highlight polygon offset failed", exc_info=True)
+
+ self._context.Display(ais, True)
+ self._highlight_ais = ais
+ if self._view is not None:
+ self._view.Update()
+
+ def clear_face_highlight(self) -> None:
+ """Remove the persistent face-selection overlay, if any."""
+ if self._context is None or self._highlight_ais is None:
+ return
+ try:
+ self._context.Remove(self._highlight_ais, True)
+ except Exception:
+ logger.debug("clear_face_highlight remove failed", exc_info=True)
+ self._highlight_ais = None
+
# ─── Mouse / keyboard event forwarding ──────────────────────────────
#
# CAD-style navigation:
# • Left button drag → orbit (rotate around target)
# • Middle button drag → pan
- # • Right button drag → dolly / zoom toward cursor
+ # • Right button → (reserved for future use)
# • Wheel → zoom toward cursor
# • Double-click left → fit all (handled by the widget)
@@ -717,12 +901,13 @@ class OCCRenderer(Renderer):
self._view.StartRotation(x, y, 0.4)
elif btn == Qt.MiddleButton:
self._nav_mode = "pan"
- # Pan uses deltas from this starting point.
+ # Record the gesture start; OCC's Pan(..., Start=False) expects
+ # deltas CUMULATIVE from this point, not per-frame deltas.
+ self._pan_start_x = x
+ self._pan_start_y = y
self._view.Pan(0, 0, 1.0, True)
- elif btn == Qt.RightButton:
- self._nav_mode = "zoom"
- self._view.StartZoomAtPoint(x, y)
else:
+ # Right button (and any other) is reserved — no gesture yet.
self._nav_mode = None
self._last_mouse_x = x
@@ -739,12 +924,12 @@ class OCCRenderer(Renderer):
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
self._view.Rotation(x, y)
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
- dx = x - self._last_mouse_x
- dy = y - self._last_mouse_y
+ # Cumulative delta from the gesture start — OCC interprets
+ # Pan(..., Start=False) as an absolute offset from the start point.
+ dx = x - self._pan_start_x
+ dy = y - self._pan_start_y
# dy negated because Qt y grows downward while OCC y grows upward.
self._view.Pan(dx, -dy, 1.0, False)
- elif self._nav_mode == "zoom" and (buttons & Qt.RightButton):
- self._view.ZoomAtPoint(self._last_mouse_x, self._last_mouse_y, x, y)
else:
# Idle: dynamic highlighting under the cursor.
self._context.MoveTo(x, y, self._view, True)
diff --git a/tests/test_geometry.py b/tests/test_geometry.py
index f5f0db8..ee77006 100644
--- a/tests/test_geometry.py
+++ b/tests/test_geometry.py
@@ -237,5 +237,896 @@ class TestOCCSketch:
assert abs(g.Mass() - expected) < 0.1
+class TestExternalEntities:
+ """Tests for the underlay / face-projected reference entity API.
+
+ External entities live in the solver so user constraints can reference
+ them (e.g. "hole center 50 mm from the body's top edge"), but they
+ are *not* part of the sketch profile and must be excluded from
+ detect_faces / get_geometry.
+ """
+
+ def test_add_external_point_flags_and_fixes(self):
+ sk = OCCSketch()
+ ep = sk.add_external_point(5.0, 7.0)
+ assert ep is not None
+ assert ep.is_external is True
+ assert ep.is_construction is True
+ # External point is in the solver, with a non-None handle.
+ assert ep.handle is not None
+ # The point is in the entities / points dicts.
+ assert ep.id in sk._entities
+ assert ep.id in sk._points
+ # It's tracked as external.
+ assert ep.id in sk.get_external_entity_ids()
+
+ def test_add_external_line_requires_external_endpoints(self):
+ sk = OCCSketch()
+ a = sk.add_external_point(0, 0)
+ b = sk.add_external_point(10, 0)
+ line = sk.add_external_line(a, b)
+ assert line is not None
+ assert line.is_external is True
+ assert line.is_construction is True
+ assert line.handle is not None
+ assert line.id in sk._lines
+ assert line.id in sk.get_external_entity_ids()
+
+ def test_add_external_polyline_shares_corners(self):
+ sk = OCCSketch()
+ # Closed rectangle: 4 unique corners reused at the joints.
+ pts = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
+ points, lines = sk.add_external_polyline(pts)
+ # 4 segments, 5 UV samples but the 1st and last are the same corner.
+ assert len(lines) == 4
+ # The 5 samples share the rectangle's 4 corners → 4 unique point entities.
+ assert len(set(p.id for p in points)) == 4
+ # All are external.
+ assert all(p.is_external for p in points)
+ assert all(ln.is_external for ln in lines)
+
+ def test_external_entities_excluded_from_detect_faces(self):
+ sk = OCCSketch()
+ # Underlay: a 20x20 square projected from a face (closed polyline).
+ sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
+ # User profile: a 5x5 square — this is what should be extruded.
+ a = sk.add_point(2, 2); b = sk.add_point(8, 2)
+ c = sk.add_point(8, 8); d = sk.add_point(2, 8)
+ sk.add_line(a, b); sk.add_line(b, c)
+ sk.add_line(c, d); sk.add_line(d, a)
+ faces = sk.detect_faces()
+ # Only the user-drawn face (5x5 square) should be detected.
+ assert len(faces) == 1
+ outer = faces[0]["outer"]
+ assert outer["type"] == "polygon"
+ # 5 vertices on the outer loop (4 corners + closing point).
+ assert len(outer["points"]) == 5
+ # It must be the user square, not the underlay.
+ xs = [p[0] for p in outer["points"][:4]]
+ ys = [p[1] for p in outer["points"][:4]]
+ assert min(xs) >= 2 and max(xs) <= 8
+ assert min(ys) >= 2 and max(ys) <= 8
+
+ def test_external_entities_excluded_from_get_polygon_points(self):
+ sk = OCCSketch()
+ sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
+ a = sk.add_point(1, 1); b = sk.add_point(2, 1)
+ c = sk.add_point(2, 2); d = sk.add_point(1, 2)
+ sk.add_line(a, b); sk.add_line(b, c)
+ sk.add_line(c, d); sk.add_line(d, a)
+ poly = sk.get_polygon_points()
+ # The user square (1..2 range) should appear, not the 0..100 underlay.
+ assert all(1.0 <= p.x <= 2.0 for p in poly)
+ assert all(1.0 <= p.y <= 2.0 for p in poly)
+
+ def test_external_entities_excluded_from_get_geometry(self):
+ """Underlay must never appear in the extruded face."""
+ from OCP.GProp import GProp_GProps
+ from OCP.BRepGProp import BRepGProp
+
+ sk = OCCSketch()
+ # Underlay (NOT to be extruded).
+ sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
+ # User profile: a 2x2 square inside the underlay.
+ a = sk.add_point(1, 1); b = sk.add_point(3, 1)
+ c = sk.add_point(3, 3); d = sk.add_point(1, 3)
+ sk.add_line(a, b); sk.add_line(b, c)
+ sk.add_line(c, d); sk.add_line(d, a)
+ geom = sk.get_geometry()
+ # Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
+ kernel = OCGeometryKernel()
+ solid = kernel.extrude(geom, 4.0)
+ s = kernel._get_shape(solid)
+ g = GProp_GProps()
+ BRepGProp.VolumeProperties_s(s, g)
+ assert abs(g.Mass() - 16.0) < 0.5
+
+ def test_distance_to_external_point_constraint(self):
+ """The headline use case: hole position fixed relative to a face edge.
+
+ User draws a circle (the hole) and a distance from its centre to
+ a face-projected point. After solve, the circle centre should be
+ exactly the requested distance from the external point.
+ """
+ sk = OCCSketch()
+ # Underlay corner: pick a known anchor on the projected face.
+ anchor = sk.add_external_point(0.0, 0.0)
+ # User geometry: a 1mm circle for the hole.
+ hole_centre = sk.add_point(7.0, 4.0) # start position: 7 from anchor
+ sk.add_circle(hole_centre, 1.0)
+ # Constrain the hole centre 50 mm from the underlay corner.
+ ok = sk.constrain_distance(anchor, hole_centre, 50.0)
+ assert ok
+ assert sk.solve()
+ solved = sk.get_solved_point(hole_centre.id)
+ assert solved is not None
+ # The starting (7, 4) is well short of 50, so the constraint
+ # forces the centre out to a point on the 50mm circle around (0,0).
+ x, y = solved
+ assert abs(math_hypot(x, y) - 50.0) < 0.01
+
+ def test_remove_external_entities_clears_them(self):
+ sk = OCCSketch()
+ sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
+ assert len(sk.get_external_entity_ids()) > 0
+ sk.remove_external_entities()
+ assert len(sk.get_external_entity_ids()) == 0
+ # No external points/lines left in the tracking dicts.
+ for eid in sk._entities:
+ assert not getattr(sk._entities[eid], "is_external", False)
+
+ def test_remove_external_entities_prunes_related_constraints(self):
+ """Constraints referencing external entities are pruned on removal.
+
+ A distance to an external point is recorded in the constraint log
+ on the ids of both endpoints. After remove_external_entities(),
+ those entries are gone and the solver rebuilds without them.
+ """
+ sk = OCCSketch()
+ anchor = sk.add_external_point(0.0, 0.0)
+ user = sk.add_point(20.0, 0.0)
+ sk.constrain_distance(anchor, user, 5.0)
+ sk.solve()
+ # At least one log entry references the external anchor.
+ assert any(anchor.id in entry["ids"] for entry in sk._constraint_log)
+ # Now wipe the underlay.
+ sk.remove_external_entities()
+ # The distance constraint is gone, and the user point is free.
+ assert not any(anchor.id in entry["ids"] for entry in sk._constraint_log)
+ assert sk.solve()
+
+ def test_external_polyline_dedupes_close_points(self):
+ """Co-located UV samples share a single point entity (closed loops)."""
+ sk = OCCSketch()
+ # Closed rectangle (closing point == start point).
+ pts = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)]
+ points, lines = sk.add_external_polyline(pts)
+ # 5 samples → 4 unique points (start/end collapse).
+ assert len(set(p.id for p in points)) == 4
+ # 4 segments connect them.
+ assert len(lines) == 4
+ # Every line's endpoints are among the 4 points.
+ point_ids = {p.id for p in points}
+ for line_id, (sid, eid2) in sk._lines.items():
+ if line_id in sk.get_external_entity_ids():
+ assert sid in point_ids and eid2 in point_ids
+
+ def test_external_point_is_solver_fixed(self):
+ """An external point's solver parameters must not change on re-solve.
+
+ python_solvespace drags the first user point; external points use
+ ``dragged`` directly so dragging a user point near an external
+ reference doesn't shift the reference.
+ """
+ sk = OCCSketch()
+ ep = sk.add_external_point(3.0, 4.0)
+ # Add a user point; solve; record the external point's solved
+ # params. Then delete the user point and add another one; the
+ # external point's params must not have moved.
+ sk.add_point(100.0, 0.0)
+ sk.solve()
+ x0, y0 = sk.solver.params(ep.handle.params)
+ for dx in range(-5, 6):
+ sk.add_point(100.0 + dx, 0.0)
+ sk.solve()
+ x1, y1 = sk.solver.params(ep.handle.params)
+ assert abs(x1 - x0) < 1e-6
+ assert abs(y1 - y0) < 1e-6
+
+ def test_horizontal_constraint_on_external_line(self):
+ """Horizontal constraint involving a partly-external line is solvable.
+
+ Both external endpoints are dragged (fixed), so a horizontal
+ constraint between them is over-determined when their y values
+ differ. To make the system solvable we add a free user point
+ connected to one external point via a line, then constrain that
+ line horizontal — the user endpoint is dragged to a y that
+ matches the external one, satisfying the constraint.
+ """
+ sk = OCCSketch()
+ a = sk.add_external_point(0.0, 0.0)
+ # Add a free user point first (skipped auto-drag because external
+ # points exist, so this one is free).
+ free = sk.add_point(7.0, 5.0)
+ # And an external endpoint to pair with the free point in a line.
+ b = sk.add_external_point(0.0, 0.0)
+ line = sk.add_external_line(b, free)
+ # Constrain it horizontal; the free point should drop to y=0.
+ sk.constrain_horizontal(line)
+ assert sk.solve()
+ sa = sk.get_solved_point(b.id)
+ sf = sk.get_solved_point(free.id)
+ assert sa is not None and sf is not None
+ assert abs(sa[1] - sf[1]) < 1e-6
+
+ def test_cleared_sketch_drops_external_entities(self):
+ sk = OCCSketch()
+ sk.add_external_polyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
+ sk.add_point(5, 5)
+ assert len(sk.get_external_entity_ids()) > 0
+ sk.clear()
+ assert len(sk.get_external_entity_ids()) == 0
+ assert sk.get_entity_count() == 0
+
+
+class TestExtrudeCutFix:
+ """Tests for the cut/union logic in MainWindow._extrude_sketch.
+
+ The old code stored the boolean result in the *tool* (newly extruded)
+ body, leaving the *target* body untouched — so the user would see a
+ separate "cavity-shaped" body next to the original instead of a
+ cavity in the original. After deleting that extra body, the next
+ extrude-cutter saw ``len(existing) <= 1`` and silently skipped the
+ cut, producing an unconstrained new body that looked "added without
+ cut". The fix:
+
+ 1. Apply the boolean to the *target* (existing[0]) body.
+ 2. Remove the tool body from the component.
+ 3. Re-render the target in place.
+
+ These tests verify the boolean operation produces the right solid and
+ that the post-extrude bookkeeping leaves exactly the right bodies
+ in the component.
+ """
+
+ def test_boolean_difference_modifies_target_not_tool(self):
+ """The fix: cut goes into the target, tool is removed.
+
+ Reproduces the cut/merge flow from ``_extrude_sketch`` without
+ spinning up the full MainWindow: build a target + tool body,
+ run boolean_difference, then verify the target's volume dropped
+ and the tool is no longer needed.
+ """
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+ from OCP.GProp import GProp_GProps
+ from OCP.BRepGProp import BRepGProp
+ import math
+
+ k = OCGeometryKernel()
+ target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
+ target_obj = OCCGeometryObject(target_shape, {"type": "box"})
+
+ # Tool: a 20x20x200 cuboid at the corner of the box, to make the
+ # expected volume easy to compute.
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
+ from OCP.gp import gp_Pnt, gp_Vec
+ # 20x20 square at (0,0,0), extruded along +Z by 200.
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
+ mp = BRepBuilderAPI_MakePolygon()
+ for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
+ mp.Add(gp_Pnt(x, y, 0))
+ mp.Close()
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
+ face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
+ tool_shape = BRepPrimAPI_MakePrism(
+ face, gp_Vec(0, 0, 200)
+ ).Shape()
+ tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
+
+ # Before cut: target is 100^3 = 1_000_000.
+ g0 = GProp_GProps()
+ BRepGProp.VolumeProperties_s(k._get_shape(target_obj), g0)
+ assert abs(g0.Mass() - 1_000_000.0) < 1.0
+
+ # Apply the fix: result goes to the target, not the tool.
+ result = k.boolean_difference(target_obj, tool_obj)
+ target_obj_geometry = result
+
+ # After cut: target is 1_000_000 - 20*20*100 = 960_000
+ # (the prism only intersects the box in z=[0,100], i.e. 100 deep).
+ g1 = GProp_GProps()
+ BRepGProp.VolumeProperties_s(
+ k._get_shape(target_obj_geometry), g1
+ )
+ assert abs(g1.Mass() - 960_000.0) < 1.0
+
+ def test_boolean_difference_does_not_leave_separate_cavity_body(self):
+ """Sanity: the cut result is a single body (not two).
+
+ The OLD bug stored the cut result in a SECOND body, so after a
+ cut the user would see the original body PLUS a "cavity-shaped"
+ body — the user thought the cut worked but it was just two
+ separate solids. With the fix the cut is folded into the
+ target, so a single body remains.
+ """
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_SOLID
+ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+
+ k = OCGeometryKernel()
+ target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
+ target_obj = OCCGeometryObject(target_shape, {})
+
+ # Tool: small box at the centre, fully inside the target.
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
+ tool_shape = BBox(20, 20, 20).Shape()
+ tool_obj = OCCGeometryObject(tool_shape, {})
+
+ # The fixed cut flow:
+ # 1. Apply boolean to target.
+ # 2. Remove the tool from the component dict.
+ result = k.boolean_difference(target_obj, tool_obj)
+ target_obj.geometry = result # the fix: result goes in target
+ # (the tool_obj is then discarded; the simulated flow above
+ # keeps it locally but doesn't use it for display).
+
+ # Count solids in the cut result. It should be exactly 1 (the
+ # target with a cavity), not 2 (target + cavity-shaped tool).
+ shape = k._get_shape(target_obj)
+ explorer = TopExp_Explorer(shape, TopAbs_SOLID)
+ n_solids = 0
+ while explorer.More():
+ n_solids += 1
+ explorer.Next()
+ assert n_solids == 1, f"Cut result has {n_solids} solids, expected 1"
+
+
+class TestBodyVisibilityToggle:
+ """Tests for the per-body visibility toggle on the right-hand body list.
+
+ The user asked for a visibility checkbox per body so they could
+ easily verify whether an operation (e.g. cut) had actually modified
+ a body. Hiding the second body and seeing whether the first still
+ has the cut shape is the intended workflow.
+ """
+
+ def _make_window(self):
+ import os
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ app = QApplication.instance() or QApplication([])
+ from fluency.main import MainWindow
+ return MainWindow()
+
+ def test_body_list_uses_checkable_items(self):
+ """Each body list item must be a checkable QListWidgetItem."""
+ from PySide6.QtCore import Qt
+ win = self._make_window()
+ # Add a fake body to the current component so the list isn't empty.
+ from fluency.models.data_model import Body
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+ box = OCCGeometryObject(
+ BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
+ )
+ win._current_component.bodies["a"] = Body(name="A", geometry=box)
+ win._refresh_lists()
+ items = win._body_list.findItems("A", Qt.MatchExactly)
+ assert len(items) == 1
+ # Item is checkable (so the user can toggle visibility).
+ assert items[0].flags() & Qt.ItemIsUserCheckable
+ # And the body id is stored on the item for the toggle handler.
+ assert items[0].data(Qt.UserRole) == "a"
+ # Default state is checked (= visible).
+ assert items[0].checkState() == Qt.Checked
+
+ def test_toggling_visibility_updates_body_model(self):
+ """Flipping the checkbox should set body.visible accordingly."""
+ from PySide6.QtCore import Qt
+ win = self._make_window()
+ from fluency.models.data_model import Body
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+ box = OCCGeometryObject(
+ BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
+ )
+ win._current_component.bodies["a"] = Body(name="A", geometry=box)
+ win._refresh_lists()
+ item = win._body_list.findItems("A", Qt.MatchExactly)[0]
+
+ # Toggle off.
+ item.setCheckState(Qt.Unchecked)
+ win._on_body_visibility_changed(item)
+ assert win._current_component.bodies["a"].visible is False
+
+ # Toggle back on.
+ item.setCheckState(Qt.Checked)
+ win._on_body_visibility_changed(item)
+ assert win._current_component.bodies["a"].visible is True
+
+ def test_visibility_no_op_when_unchanged(self):
+ """Re-emitting the same state must not trigger a viewer call.
+
+ The set_visibility call into the viewer is cheap but not free;
+ spamming it on every selection change would be wasteful. The
+ handler short-circuits when the new state matches the model's.
+ """
+ from PySide6.QtCore import Qt
+ win = self._make_window()
+ from fluency.models.data_model import Body
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+ box = OCCGeometryObject(
+ BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
+ )
+ win._current_component.bodies["a"] = Body(name="A", geometry=box)
+ win._refresh_lists()
+ item = win._body_list.findItems("A", Qt.MatchExactly)[0]
+
+ # Force the model's visibility to False to mimic a desync.
+ win._current_component.bodies["a"].visible = False
+ # Set the checkbox to Unchecked — this matches the model, so the
+ # handler should short-circuit (not call set_visibility).
+ item.setCheckState(Qt.Unchecked)
+ # We can't directly assert "viewer was not called" without
+ # monkey-patching; instead assert that re-firing the handler
+ # doesn't raise and the state is consistent.
+ win._on_body_visibility_changed(item)
+ assert win._current_component.bodies["a"].visible is False
+
+
+def math_hypot(x, y):
+ import math
+ return math.hypot(x, y)
+
+
+class TestConstraintTagRendering:
+ """Tests for constraint tag rendering when a tag references a line id.
+
+ The constraint log stores entity ids. A constraint that targets a
+ line (e.g. point-on-line coincident) puts a *line* id in the log,
+ and the tag rendering code used to naively unpack that line's
+ geometry ``((x1,y1), (x2,y2))`` as if it were a point's ``(x, y)``,
+ calling ``round()`` on a tuple and raising
+ ``TypeError: type tuple doesn't define __round__ method``.
+ These tests pin the fix in ``Sketch2DWidget._compute_constraint_tags``.
+ """
+
+ def _make_widget_with_sketch(self, sk):
+ """Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
+ import os
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ app = QApplication.instance() or QApplication([])
+ from fluency.main import Sketch2DWidget
+ w = Sketch2DWidget()
+ w.set_sketch(sk)
+ return w
+
+ def test_point_on_line_coincident_tag_renders(self):
+ """A coincident between a point and a line must not crash the paint event.
+
+ Reproduces the user-reported error: ids[1] is a line id, the old
+ code unpacked the line's geometry ``((x1,y1), (x2,y2))`` as a
+ point and called ``round()`` on the inner tuple.
+ """
+ sk = OCCSketch()
+ a = sk.add_point(0, 0)
+ b = sk.add_point(10, 0)
+ line = sk.add_line(a, b) # 3rd entity: the line itself
+ # Point-on-line: the line id is in the constraint log.
+ p3 = sk.add_point(5, 5)
+ sk.constrain_coincident(p3, line)
+ sk.solve()
+ w = self._make_widget_with_sketch(sk)
+ # Must not raise.
+ tags = w._compute_constraint_tags()
+ # One tag for the coincident.
+ coin_tags = [t for t in tags if "coin" in t["label"]]
+ assert len(coin_tags) == 1
+ # The tag was anchored (non-None center) and renders successfully.
+ assert coin_tags[0]["center"] is not None
+
+ def test_point_world_rejects_line_geometry(self):
+ """_point_world must return None (not crash) when given a line id."""
+ sk = OCCSketch()
+ a = sk.add_point(0, 0)
+ b = sk.add_point(10, 0)
+ line = sk.add_line(a, b)
+ w = self._make_widget_with_sketch(sk)
+ # Old behaviour: round() raised TypeError.
+ # New behaviour: _point_world returns None for non-point entities.
+ result = w._point_world(line.id)
+ assert result is None
+
+ def test_point_world_rejects_circle_geometry(self):
+ """_point_world must return None for circle entities too.
+
+ A circle's geometry is ``((cx, cy), radius)`` — also not a flat
+ 2-tuple of numbers. Same shape check rejects it.
+ """
+ sk = OCCSketch()
+ c = sk.add_point(0, 0)
+ circle = sk.add_circle(c, 5.0)
+ w = self._make_widget_with_sketch(sk)
+ result = w._point_world(circle.id)
+ assert result is None
+
+ def test_entity_anchor_routes_to_line_midpoint(self):
+ """_entity_anchor returns the line midpoint for line ids."""
+ sk = OCCSketch()
+ a = sk.add_point(0, 0)
+ b = sk.add_point(10, 0)
+ line = sk.add_line(a, b)
+ w = self._make_widget_with_sketch(sk)
+ anchor = w._entity_anchor(line.id)
+ assert anchor is not None
+ # Midpoint of (0,0) and (10,0) is (5, 0).
+ assert anchor.x() == 5
+ assert anchor.y() == 0
+
+ def test_distance_constraint_with_line_id(self):
+ """A distance constraint involving a line id must not crash.
+
+ Future enhancements might add a point-to-line distance; even
+ without that, the defensive routing through _entity_anchor
+ ensures the tag renders cleanly when such an entry is logged.
+ """
+ sk = OCCSketch()
+ a = sk.add_point(0, 0)
+ b = sk.add_point(10, 0)
+ line = sk.add_line(a, b)
+ p3 = sk.add_point(15, 5)
+ # Simulate a point-to-line distance by directly appending a log
+ # entry — this matches the solver's surface (it would call
+ # _record_constraint with these ids once a point-to-line
+ # distance is added to the solver wrapper).
+ sk._record_constraint("distance", (p3.id, line.id), (12.0,))
+ w = self._make_widget_with_sketch(sk)
+ tags = w._compute_constraint_tags()
+ dst_tags = [t for t in tags if "dst" in t["label"]]
+ assert len(dst_tags) == 1
+ assert dst_tags[0]["center"] is not None
+
+ def test_paint_tolerates_corrupted_entity_geometry(self):
+ """Paint must not crash if an entity's geometry is weird.
+
+ Simulates the user-reported case: after constraining many
+ points, the solver log still references an entity whose
+ geometry was corrupted (e.g. line-shape ``((x,y), r)`` on a
+ point entity, a 3-element list, or a value with a __round__
+ that raises). _compute_constraint_tags should drop the bad
+ tag and keep rendering the rest.
+ """
+ sk = OCCSketch()
+ a = sk.add_point(0, 0)
+ b = sk.add_point(10, 0)
+ c = sk.add_point(5, 5)
+ sk.constrain_coincident(c, a)
+ sk.solve()
+ w = self._make_widget_with_sketch(sk)
+
+ # Case 1: point entity has line-shape geometry.
+ sk._entities[c.id].geometry = ((1.0, 2.0), 3.0)
+ tags = w._compute_constraint_tags()
+ # Bad entry is dropped; the good one still renders.
+ assert all(t["center"] is not None for t in tags)
+
+ # Case 2: wrong-shape geometry (3-element list).
+ sk._entities[c.id].geometry = [1.0, 2.0, 3.0]
+ tags = w._compute_constraint_tags()
+ assert all(t["center"] is not None for t in tags)
+
+ # Case 3: exotic type whose __round__ raises.
+ class _BadRound:
+ def __round__(self, ndigits=0):
+ raise TypeError("cannot round")
+ sk._entities[c.id].geometry = (_BadRound(), _BadRound())
+ tags = w._compute_constraint_tags()
+ assert all(t["center"] is not None for t in tags)
+
+ def test_paint_tolerates_dangling_constraint_ids(self):
+ """Paint must not crash if the log references an entity that was deleted.
+
+ The log can briefly reference a stale id after a delete (e.g.
+ if a constraint handler logs first and deletes second). The
+ render path must skip such entries, not raise KeyError or
+ TypeError.
+ """
+ sk = OCCSketch()
+ a = sk.add_point(0, 0)
+ sk.constrain_fixed(a)
+ sk.solve()
+ # Simulate the entity being removed without pruning the log.
+ sk._entities.pop(a.id)
+ w = self._make_widget_with_sketch(sk)
+ tags = w._compute_constraint_tags()
+ # No crash; the dangling tag is dropped.
+ assert isinstance(tags, list)
+
+
+class TestExtrudeRedesign:
+ """Tests for the cut-through / source-body auto-target / live-preview
+ redesign (2026-06-29).
+
+ Headline workflow: a sketch projected on a face of a body, plus "Perform
+ Cut"
+ 1. auto-targets the body it was projected onto,
+ 2. auto-directs the cut INTO the body (the picked face's outward normal
+ points away, so a plain cut would carve nothing),
+ 3. with "Through All" ticked, fully passes through the body.
+ A live transparent preview is computed from the same shared helper, and
+ a freshly-projected sketch is auto-selected in the row-left list so the
+ user can Extrude/Cut without hunting for the row.
+ """
+
+ def _make_window_with_box(self, box_side=100.0):
+ import os
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ app = QApplication.instance() or QApplication([])
+ from fluency.main import MainWindow
+ from fluency.models.data_model import Sketch, Body
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+ from fluency.geometry_occ.sketch import OCCSketch
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+
+ win = MainWindow()
+ k = win._kernel
+ box_shape = BRepPrimAPI_MakeBox(box_side, box_side, box_side).Shape()
+ box_obj = OCCGeometryObject(box_shape, {"type": "box"})
+ win._current_component.bodies["b1"] = Body(name="Box1", geometry=box_obj)
+
+ # Sketch on the TOP face of the box (normal +Z points outward).
+ sk = OCCSketch()
+ origin = (box_side / 2.0, box_side / 2.0, box_side)
+ normal = (0.0, 0.0, 1.0)
+ x_dir = (1.0, 0.0, 0.0)
+ sk.set_workplane(origin, normal, x_dir)
+ sketch = Sketch(name="S on top")
+ sketch.occ_sketch = sk
+ sketch.set_workplane(origin, normal, x_dir)
+ sketch._source_body_id = "b1"
+ win._current_component.sketches[sketch.id] = sketch
+ win._current_sketch = sketch
+ # Return all the fixtures.
+ return win, sketch, sk, box_obj
+
+ def _add_circle(self, sk, r=10.0):
+ from fluency.geometry_occ.sketch import OCCSketch
+ c = sk.add_point(0, 0)
+ sk.add_circle(c, r)
+ sk.solve()
+ return sk.get_geometry()
+
+ def _geometry_volume(self, win, geom):
+ from OCP.GProp import GProp_GProps
+ from OCP.BRepGProp import BRepGProp
+ sh = win._kernel._get_shape(geom)
+ g = GProp_GProps()
+ BRepGProp.VolumeProperties_s(sh, g)
+ return g.Mass()
+
+ def test_cut_auto_directs_into_body(self):
+ """A plain "Perform Cut" on a sketch-on-top-of-body carves a pocket.
+
+ Without the redesign a non-inverted extrude goes *outward* (up),
+ missing the box and carving nothing. The redesign auto-flips the
+ extrusion to go *into* the body regardless of the Invert checkbox,
+ so a 5 mm cut makes a real 5 mm-deep pocket.
+ """
+ import math
+ win, sketch, sk, box_obj = self._make_window_with_box(100.0)
+ face_geom = self._add_circle(sk, r=10.0)
+ # Plain cut, length=5, NOT inverted. Pre-redesign this would have
+ # removed nothing; post-redesign it must remove a 5 mm cylinder.
+ result = win._compute_extrude_result(
+ sketch, face_geom,
+ length=5.0, symmetric=False, invert=False,
+ cut=True, union=False, through_all=False,
+ )
+ assert result is not None
+ assert result["target_body"] is not None
+ assert result["target_body"].name == "Box1"
+ vol = self._geometry_volume(win, result["result_geom"])
+ expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
+ assert abs(vol - expected) < 1.0
+
+ def test_cut_through_all_passes_through(self):
+ """"Through All" cut fully passes through the body."""
+ import math
+ win, sketch, sk, box_obj = self._make_window_with_box(100.0)
+ face_geom = self._add_circle(sk, r=10.0)
+ result = win._compute_extrude_result(
+ sketch, face_geom,
+ length=5.0, # ignored when through_all
+ symmetric=False, invert=False,
+ cut=True, union=False, through_all=True,
+ )
+ assert result is not None
+ vol = self._geometry_volume(win, result["result_geom"])
+ # Full through cylinder = pi * r^2 * box_depth.
+ expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
+ assert abs(vol - expected) < 1.0
+
+ def test_cut_auto_targets_source_body_not_existing_zero(self):
+ """Cut should target the source body, not the dict's first body.
+
+ Constructs a 2-body scenario where the first body in the dict is NOT
+ the source, and verifies the cut goes into the source.
+ """
+ import math
+ import os
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ app = QApplication.instance() or QApplication([])
+ from fluency.main import MainWindow
+ from fluency.models.data_model import Sketch, Body
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+ from fluency.geometry_occ.sketch import OCCSketch
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+
+ win = MainWindow()
+ # First body in the dict: a 50-millimetre box ALSO.
+ first = OCCGeometryObject(
+ BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
+ )
+ win._current_component.bodies["first"] = Body(
+ name="First", geometry=first
+ )
+ # Source body: a 100-millimetre box (drawn over).
+ src = OCCGeometryObject(
+ BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
+ )
+ win._current_component.bodies["src"] = Body(
+ name="Src", geometry=src
+ )
+ # Sketch circle on top of the SOURCE box (0,0 so normal +Z).
+ sk = OCCSketch()
+ sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
+ centre = sk.add_point(0, 0)
+ sk.add_circle(centre, 10.0)
+ sk.solve()
+ sketch = Sketch(name="S")
+ sketch.occ_sketch = sk
+ sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
+ sketch._source_body_id = "src" # explicitly the source box.
+ win._current_component.sketches[sketch.id] = sketch
+ win._current_sketch = sketch
+
+ face_geom = sk.get_geometry()
+ result = win._compute_extrude_result(
+ sketch, face_geom,
+ length=5.0, symmetric=False, invert=False,
+ cut=True, union=False, through_all=True,
+ )
+ assert result is not None
+ # Target is the source box, NOT the dict's first body.
+ assert result["target_body"].name == "Src"
+ vol = self._geometry_volume(win, result["result_geom"])
+ # 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
+ expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
+ assert abs(vol - expected) < 1.0
+
+ def test_union_default_builds_outward(self):
+ """Combine (Union) builds a boss OUTWARD (no auto-into-body flip).
+
+ Union semantics: the new material adds on TOP of the face, not
+ into the body. So a 10 mm union adds a 10 mm cylinder of material
+ rather than "subtracting" from the existing box.
+ """
+ import math
+ win, sketch, sk, box_obj = self._make_window_with_box(100.0)
+ face_geom = self._add_circle(sk, r=10.0)
+ result = win._compute_extrude_result(
+ sketch, face_geom,
+ length=10.0, symmetric=False, invert=False,
+ cut=False, union=True, through_all=False,
+ )
+ assert result is not None
+ vol = self._geometry_volume(win, result["result_geom"])
+ # 100^3 + pi*100*10 — material added on top.
+ expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
+ assert abs(vol - expected) < 1.0
+
+ def test_plain_extrude_untouched_by_source_body(self):
+ """Without cut/union, the extrusion is a standalone new body."""
+ win, sketch, sk, box_obj = self._make_window_with_box(100.0)
+ face_geom = self._add_circle(sk, r=10.0)
+ result = win._compute_extrude_result(
+ sketch, face_geom,
+ length=10.0, symmetric=False, invert=False,
+ cut=False, union=False, through_all=False,
+ )
+ assert result is not None
+ # No boolean target; result is the standalone tool extrusion.
+ assert result["target_body"] is None
+ vol = self._geometry_volume(win, result["result_geom"])
+ # Standalone cylinder 10 mm tall.
+ import math
+ assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
+
+ def test_freshly_picked_sketch_is_auto_selected(self):
+ """After _on_face_picked, the new sketch is the current list row.
+
+ The user should be able to click Extrude/Cut immediately without
+ first hunting for the new sketch in the left list.
+ """
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+ win, _, sk, box_obj = self._make_window_with_box(100.0)
+ # Simulate _on_face_picked by calling it through a fake face
+ # shape — but the simplest behavioural check is to call the
+ # bookkeeping directly: a new sketch matching src exists and is
+ # set as _current_sketch, and it appears (and is selected) in
+ # the list after _refresh_lists + setCurrentRow.
+ from fluency.models.data_model import Sketch
+ sketch = Sketch(name="Sketch on face 99")
+ sketch._source_body_id = "b1"
+ sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
+ win._current_component.sketches[sketch.id] = sketch
+ win._current_sketch = sketch
+ win._refresh_lists()
+ # The auto-select block from _on_face_picked — re-derive it
+ # here since we can't run the full pick path offscreen.
+ target_row = None
+ for row in range(win._sketch_list.count()):
+ if win._sketch_list.item(row).text() == sketch.name:
+ target_row = row
+ break
+ assert target_row is not None
+ win._sketch_list.setCurrentRow(target_row)
+ assert win._sketch_list.currentRow() == target_row
+ assert win._sketch_list.currentItem().text() == sketch.name
+
+ def test_preview_callback_invoked_on_value_change(self):
+ """The live preview callback fires on spinbox/checkbox changes."""
+ import os
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ app = QApplication.instance() or QApplication([])
+ from fluency.main import ExtrudeDialog
+
+ calls = []
+ dialog = ExtrudeDialog()
+ dialog.set_preview_callback(lambda v: calls.append(v))
+ # set_preview_callback emits once for the initial state.
+ assert len(calls) == 1
+ # Changing the length should emit a new values tuple.
+ dialog.length_input.setValue(42.0)
+ assert len(calls) == 2
+ # Toggling "Through All" should emit again.
+ dialog.through_all_checkbox.setChecked(True)
+ assert len(calls) >= 3
+ # Passing None clears the preview (as the host does on close).
+ dialog.set_preview_callback(None)
+ # New callback None → no further emissions.
+ before = len(calls)
+ dialog.length_input.setValue(99.0)
+ assert len(calls) == before # callback gone → no emit
+
+ def test_preview_hidden_event_sends_none(self):
+ """hideEvent should deliver None to the callback so the host clears."""
+ import os
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ app = QApplication.instance() or QApplication([])
+ from fluency.main import ExtrudeDialog
+
+ seen = []
+ dialog = ExtrudeDialog()
+ dialog.set_preview_callback(lambda v: seen.append(v))
+ # hideEvent only fires when the dialog was previously visible, so
+ # show it first (window system / offscreen both honour this) and
+ # then hide it — which is exactly what dialog.exec() does when the
+ # user accepts or cancels.
+ dialog.show()
+ dialog.hide()
+ # The last value emitted to the callback must be None (clear).
+ assert seen and seen[-1] is None
+
+
if __name__ == "__main__":
pytest.main([__file__, "-v"])