diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index e1ec969..6e73b9d 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,12 @@
-
+
+
+
@@ -49,6 +51,8 @@
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
+ "Python.base.executor": "Run",
+ "Python.data_model.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
@@ -294,7 +298,15 @@
1782928990792
-
+
+
+ 1783108151675
+
+
+
+ 1783108151676
+
+
@@ -335,6 +347,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index a646d84..24b3627 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -1159,16 +1159,24 @@ class OCCSketch(SketchInterface):
return loops
@staticmethod
- def _point_in_polygon(pt: Tuple[float, float], poly: List[Tuple[float, float]]) -> bool:
+ def _point_in_polygon(
+ pt: Tuple[float, float],
+ poly: List[Tuple[float, float]],
+ margin: float = 0.0,
+ ) -> bool:
"""Ray-casting point-in-polygon test.
- Returns *True* only for strictly interior points. Points on the
- boundary (within 1e-9) are considered *outside* so that the outer
- boundary of a nested shape doesn't falsely contain another loop whose
- representative point happens to land on that boundary.
+ Returns *True* for points strictly inside the polygon. Points on
+ the boundary (within eps=1e-9) are *outside* by default so the
+ outer boundary of a nested shape doesn't falsely contain a hole's
+ rep point. When *margin* > 0, points that are within that many
+ world-unit of the boundary are also treated as inside — used by
+ ``_loop_contains`` to prevent float rounding from breaking
+ thin-wall nesting detection.
"""
x, y = pt
- eps = 1e-9
+ eps = 1e-9 # strict boundary rejection
+ margin = float(margin)
n = len(poly)
inside = False
j = n - 1
@@ -1177,11 +1185,17 @@ class OCCSketch(SketchInterface):
xj, yj = poly[j]
# Point-on-segment test — exclude strict boundary hits.
# First check bounding box of the segment.
- if min(xi, xj) - eps <= x <= max(xi, xj) + eps and min(yi, yj) - eps <= y <= max(yi, yj) + eps:
+ bbox_tol = max(eps, margin)
+ if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
# Check collinearity
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
- if abs(cross) < eps:
- return False # on boundary
+ abs_cross = abs(cross)
+ if abs_cross < eps:
+ # Strictly on boundary — return False unless margin says otherwise.
+ if margin > 0 and abs_cross < margin:
+ pass # fall through to ray-cast below
+ else:
+ return False
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
inside = not inside
j = i
@@ -1189,34 +1203,78 @@ class OCCSketch(SketchInterface):
@staticmethod
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
- """Does ``outer`` fully enclose ``inner``? Uses a representative point +
- boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
- rep = OCCSketch._loop_rep_point(inner)
- if outer["type"] == "polygon":
- return OCCSketch._point_in_polygon(rep, outer["points"])
- else: # circle
- cx, cy = outer["center"]
- return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"]
+ """Does ``outer`` fully enclose ``inner``?
+
+ For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
+ inside ``outer`` using ray-casting. This is robust for convex polygons
+ and avoids the representative-point issue where a large nested loop's
+ centroid lands inside an inner loop.
+
+ For circle-in-polygon: checks the circle centre is inside the polygon
+ (vertex check would be too strict for tessellated arc segments).
+
+ For circle-in-circle: checks distance between centres + inner radius
+ < outer radius + margin.
+
+ For polygon-in-circle: checks all polygon vertices are inside the
+ circle.
+ """
+ eps = 1e-3
+
+ if outer["type"] == "circle":
+ ox, oy = outer["center"]
+ orad = outer["radius"]
+ if inner["type"] == "circle":
+ # Two circles: centre distance + inner radius < outer radius
+ dx = inner["center"][0] - ox
+ dy = inner["center"][1] - oy
+ return math.hypot(dx, dy) + inner["radius"] < orad + eps
+ else:
+ # Polygon in circle: all vertices inside
+ pts = inner["points"]
+ if len(pts) > 1 and pts[0] == pts[-1]:
+ pts = pts[:-1]
+ for pt in pts:
+ if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps:
+ return False
+ return True
+ else:
+ # outer is polygon
+ if inner["type"] == "circle":
+ # Circle in polygon: centre must be inside with margin
+ cx, cy = inner["center"]
+ return OCCSketch._point_in_polygon(
+ (cx, cy), outer["points"], margin=1e-3
+ )
+ else:
+ # Polygon in polygon: ALL inner vertices inside outer
+ pts = inner["points"]
+ if len(pts) > 1 and pts[0] == pts[-1]:
+ pts = pts[:-1]
+ for pt in pts:
+ if not OCCSketch._point_in_polygon(
+ pt, outer["points"], margin=eps
+ ):
+ return False
+ return True
@staticmethod
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
"""An interior representative point inside a loop.
- For polygons we use the midpoint between the centroid and the first
- vertex (而不是 centroid 本身): a nested shape centered on the polygon's
- centroid (e.g. a circle inside a rectangle, both centered on the same
- point) would otherwise make the polygon's rep point coincide with the
- hole and break containment tests. This midpoint stays inside convex
- loops and is unlikely to land on a nested feature's center.
+ Only used for circle-in-polygon containment checks (polygon-in-polygon
+ uses all-vertex containment). Returns the centroid for polygons and
+ the centre for circles.
"""
if loop["type"] == "polygon":
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
n = len(pts)
+ if n < 3:
+ return loop.get("center", (0.0, 0.0))
sx = sum(p[0] for p in pts) / n
sy = sum(p[1] for p in pts) / n
- v0 = pts[0]
- return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0)
- return loop["center"]
+ return (sx, sy)
+ return loop.get("center", (0.0, 0.0))
@staticmethod
def _loop_area(loop: Dict[str, Any]) -> float:
@@ -1303,6 +1361,28 @@ class OCCSketch(SketchInterface):
best = face
return best
+ @staticmethod
+ def _loop_signed_area(loop: Dict[str, Any]) -> float:
+ """Signed area of a loop. Positive = CCW, negative = CW.
+
+ Circles are treated as CCW (positive area) because
+ ``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric
+ direction when looking against the normal.
+ """
+ if loop["type"] == "circle":
+ r = loop.get("radius", 0.0)
+ return math.pi * r * r # always positive (CCW)
+ pts = loop["points"]
+ if len(pts) < 3:
+ return 0.0
+ area = 0.0
+ n = len(pts) - 1 # last point == first for closed loops
+ for i in range(n):
+ x1, y1 = pts[i]
+ x2, y2 = pts[i + 1]
+ area += x1 * y2 - x2 * y1
+ return area / 2.0
+
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
"""Build an OCC face (outer boundary + inner holes) on the workplane.
@@ -1311,6 +1391,13 @@ class OCCSketch(SketchInterface):
sketch's 3D plane (not necessarily XY). The returned object stores
the raw OCC face in ``.shape`` and the plane normal in
``metadata["normal"]`` for the extrude kernel.
+
+ Hole wires are oriented to have OPPOSITE geometric winding relative
+ to the outer wire, which is what OCC's face builder expects for
+ proper hole treatment. Previous code unconditionally reversed ALL
+ hole wires, which produced solid islands (not holes) whenever the
+ outer loop had clockwise winding — e.g. after dragging a rectangle
+ from top-left to bottom-right.
"""
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
@@ -1319,32 +1406,40 @@ class OCCSketch(SketchInterface):
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
from OCP.TopoDS import TopoDS as _TopoDS
- def wire_loop(loop: Dict[str, Any], is_hole: bool = False):
+ def _wire_from_loop(loop: Dict[str, Any]):
+ """Build a wire from a loop dict. No orientation adjustment."""
if loop["type"] == "polygon":
mp = BRepBuilderAPI_MakePolygon()
for (pu, pv) in loop["points"]:
mp.Add(self._uv_to_world(pu, pv))
mp.Close()
mp.Build()
- w = mp.Wire()
- else:
- cu, cv = loop["center"]
- r = loop["radius"]
- circ = gp_Circ(self._circle_axis(cu, cv), r)
- me = BRepBuilderAPI_MakeEdge(circ)
- me.Build()
- mw = BRepBuilderAPI_MakeWire()
- mw.Add(me.Edge())
- mw.Build()
- w = mw.Wire()
- if is_hole:
- w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
- return w
+ return mp.Wire()
+ cu, cv = loop["center"]
+ r = loop["radius"]
+ circ = gp_Circ(self._circle_axis(cu, cv), r)
+ me = BRepBuilderAPI_MakeEdge(circ)
+ me.Build()
+ mw = BRepBuilderAPI_MakeWire()
+ mw.Add(me.Edge())
+ mw.Build()
+ return mw.Wire()
+
+ outer_loop = face["outer"]
+ outer_wire = _wire_from_loop(outer_loop)
+ outer_winding = self._loop_signed_area(outer_loop)
- outer_wire = wire_loop(face["outer"], is_hole=False)
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
for h in face["holes"]:
- face_maker.Add(wire_loop(h, is_hole=True))
+ hole_wire = _wire_from_loop(h)
+ hole_winding = self._loop_signed_area(h)
+ # OCC expects hole wires to have OPPOSITE winding to the outer
+ # wire (material on the other side). We reverse the hole wire
+ # only when its natural winding matches the outer's; if they
+ # already differ the wire is left as-is.
+ if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
+ hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
+ face_maker.Add(hole_wire)
face_maker.Build()
occ_face = face_maker.Face()
diff --git a/src/fluency/main.py b/src/fluency/main.py
index 34af923..d379f9e 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -49,6 +49,8 @@ from PySide6.QtWidgets import (
QSplitter,
QSizePolicy,
QInputDialog,
+ QRadioButton,
+ QLineEdit,
)
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
from PySide6.QtGui import (
@@ -133,6 +135,150 @@ def _project_face_to_uv(
return polylines
+def _project_body_to_workplane(
+ body_shape: Any,
+ workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
+) -> List[List[Tuple[float, float]]]:
+ """Project ALL edges of a 3D body onto a workplane, returning UV polylines.
+
+ *workplane* is (origin, normal, x_dir). Every edge (linear and curved)
+ of every face of *body_shape* is projected onto the workplane by mapping
+ each sample point from 3D \u2192 UV (orthographic projection along the
+ workplane normal). The result is a list of polylines, each a list of
+ (u, v) points, suitable as underlay construction lines in the 2D sketch.
+
+ This lets the user see the body's silhouette from the workplane's
+ perspective and draw sketches precisely aligned to the body's features.
+ """
+ import numpy as np
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE
+ from OCP.TopoDS import TopoDS
+ from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
+ from OCP.GeomAbs import GeomAbs_Line
+ from OCP.gp import gp_Pnt
+
+ origin = np.asarray(workplane[0], dtype=float)
+ normal = np.asarray(workplane[1], dtype=float)
+ x_dir = np.asarray(workplane[2], dtype=float)
+ x_dir = x_dir / np.linalg.norm(x_dir)
+ normal = normal / np.linalg.norm(normal)
+ y_dir = np.cross(normal, x_dir)
+ y_dir = y_dir / np.linalg.norm(y_dir)
+
+ def world_to_uv(p: gp_Pnt) -> Tuple[float, float]:
+ v = np.array([p.X() - origin[0], p.Y() - origin[1], p.Z() - origin[2]])
+ return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir)))
+
+ polylines: List[List[Tuple[float, float]]] = []
+
+ # Iterate every face of the body, then each wire/edge within.
+ face_expl = TopExp_Explorer(body_shape, TopAbs_FACE)
+ while face_expl.More():
+ face = face_expl.Current()
+ wire_expl = TopExp_Explorer(face, TopAbs_WIRE)
+ while wire_expl.More():
+ wire = wire_expl.Current()
+ edge_expl = TopExp_Explorer(wire, TopAbs_EDGE)
+ while edge_expl.More():
+ edge = TopoDS.Edge_s(edge_expl.Current())
+ try:
+ crv = BRepAdaptor_Curve(edge)
+ f = crv.FirstParameter()
+ l = crv.LastParameter()
+ is_line = crv.GetType() == GeomAbs_Line
+ if is_line:
+ pts = [crv.Value(f), crv.Value(l)]
+ else:
+ # Sample 24 segments \u2014 enough for smooth curves.
+ pts = [crv.Value(f + (l - f) * i / 24.0) for i in range(25)]
+ poly = [world_to_uv(p) for p in pts]
+ polylines.append(poly)
+ except Exception:
+ pass
+ edge_expl.Next()
+ wire_expl.Next()
+ face_expl.Next()
+
+ return polylines
+
+
+def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]:
+ """Offset a closed polygon by *distance* (positive = outward).
+
+ Uses the edge-normal method: each edge is offset along its outward
+ normal, then adjacent offset edges are intersected to find the new
+ vertex positions. Handles convex polygons well; concave (reflex)
+ corners may produce self-intersecting results for large offsets.
+
+ Returns the offset polygon as a list of (x, y) tuples (same length
+ as *points*, closed).
+ """
+ import math
+ n = len(points)
+ if n < 3:
+ return list(points)
+
+ # Determine polygon orientation (signed area).
+ area = 0.0
+ for i in range(n):
+ j = (i + 1) % n
+ area += points[i][0] * points[j][1] - points[j][0] * points[i][1]
+ is_ccw = area > 0.0
+
+ # Compute edge directions and left normals.
+ edges: List[Tuple[float, float]] = []
+ normals: List[Tuple[float, float]] = []
+ for i in range(n):
+ j = (i + 1) % n
+ dx = points[j][0] - points[i][0]
+ dy = points[j][1] - points[i][1]
+ length = math.hypot(dx, dy)
+ if length < 1e-9:
+ edges.append((0.0, 0.0))
+ normals.append((0.0, 0.0))
+ else:
+ ux = dx / length
+ uy = dy / length
+ edges.append((ux, uy))
+ # Left normal: (-uy, ux)
+ normals.append((-uy, ux))
+
+ # For CCW polygons the left normal points *inward*; flip for outward.
+ if is_ccw:
+ normals = [(-nx, -ny) for (nx, ny) in normals]
+
+ result: List[Tuple[float, float]] = []
+ for i in range(n):
+ prev_i = (i - 1 + n) % n
+ n_prev = normals[prev_i] # outward normal of edge (prev, i)
+ n_curr = normals[i] # outward normal of edge (i, next)
+
+ # Intersect the two offset edge lines to find the new vertex.
+ # Line 1: through points[prev_i] + d*n_prev, direction = edges[prev_i]
+ # Line 2: through points[i] + d*n_curr, direction = edges[i]
+ p1x = points[prev_i][0] + distance * n_prev[0]
+ p1y = points[prev_i][1] + distance * n_prev[1]
+ d1x, d1y = edges[prev_i]
+
+ p2x = points[i][0] + distance * n_curr[0]
+ p2y = points[i][1] + distance * n_curr[1]
+ d2x, d2y = edges[i]
+
+ det = d1x * d2y - d1y * d2x
+ if abs(det) < 1e-9:
+ # Parallel edges — fall back to normal offset.
+ result.append((points[i][0] + distance * n_curr[0],
+ points[i][1] + distance * n_curr[1]))
+ else:
+ diff_x = p2x - p1x
+ diff_y = p2y - p1y
+ t1 = (diff_x * d2y - diff_y * d2x) / det
+ result.append((p1x + t1 * d1x, p1y + t1 * d1y))
+
+ return result
+
+
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
@@ -295,6 +441,309 @@ class RevolveDialog(QDialog):
layout.addLayout(button_layout)
+class OffsetDialog(QDialog):
+ """Dialog for 2D sketch offset options.
+
+ Shows a number input for the offset distance with a live preview
+ callback so the sketch widget can render the offset result in real
+ time. On accept the caller retrieves ``get_values()`` → distance.
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Offset Sketch")
+ self.setMinimumWidth(300)
+
+ self._preview_callback = None
+
+ layout = QVBoxLayout(self)
+
+ dist_layout = QHBoxLayout()
+ dist_layout.addWidget(QLabel("Offset Distance (mm):"))
+ self.distance_input = QDoubleSpinBox()
+ self.distance_input.setDecimals(2)
+ self.distance_input.setRange(-10000, 10000)
+ self.distance_input.setValue(10.0)
+ self.distance_input.setSingleStep(0.5)
+ dist_layout.addWidget(self.distance_input)
+ layout.addLayout(dist_layout)
+
+ self.inward_checkbox = QCheckBox("Offset Inward (negative)")
+ self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
+ layout.addWidget(self.inward_checkbox)
+
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setFrameShadow(QFrame.Sunken)
+ layout.addWidget(line)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("OK")
+ ok_button.clicked.connect(self.accept)
+ cancel_button = QPushButton("Cancel")
+ cancel_button.clicked.connect(self.reject)
+ button_layout.addWidget(ok_button)
+ button_layout.addWidget(cancel_button)
+ layout.addLayout(button_layout)
+
+ # Live preview on every value change.
+ self.distance_input.valueChanged.connect(self._emit_preview)
+ self.inward_checkbox.stateChanged.connect(self._emit_preview)
+
+ def set_preview_callback(self, callback) -> None:
+ """Install the live-preview callback (or *None* to disable)."""
+ self._preview_callback = callback
+ 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:
+ logger.debug("offset preview callback raised: %s", exc)
+
+ def hideEvent(self, event):
+ 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]:
+ return (self.distance_input.value(), self.inward_checkbox.isChecked())
+
+
+class WorkplaneOrientationDialog(QDialog):
+ """Modal dialog to choose the orientation of a new workplane.
+
+ Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
+ can retrieve the chosen orientation via :meth:`get_orientation`, which
+ returns (normal, x_dir) pair (both as 3-tuples).
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("New Workplane Orientation")
+ self.setMinimumWidth(320)
+
+ self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
+ self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
+
+ # Optional callback for live 3D preview of the workplane.
+ # The host installs it via ``set_preview_callback``. The callback
+ # receives ``(normal, x_dir)`` or *None* to clear.
+ self._preview_callback = None
+
+ layout = QVBoxLayout(self)
+
+ # ── Orientation presets ──
+ lbl = QLabel("Choose orientation:")
+ layout.addWidget(lbl)
+
+ self._preset_group = QButtonGroup(self)
+ preset_layout = QGridLayout()
+ presets = [
+ ("XY (Top)", (0, 0, 1), (1, 0, 0)),
+ ("XZ (Front)", (0, 1, 0), (1, 0, 0)),
+ ("YZ (Right)", (1, 0, 0), (0, 1, 0)),
+ ("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
+ ("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
+ ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
+ ]
+ for idx, (label, normal, x_dir) in enumerate(presets):
+ btn = QRadioButton(label)
+ btn.setChecked(idx == 0)
+ self._preset_group.addButton(btn, idx)
+ btn.normal = normal
+ btn.x_dir = x_dir
+ preset_layout.addWidget(btn, idx // 2, idx % 2)
+ layout.addLayout(preset_layout)
+
+ # ── Custom angle (offset from XY) ──
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setFrameShadow(QFrame.Sunken)
+ layout.addWidget(line)
+
+ self._custom_radio = QRadioButton("Custom (angle from XY):")
+ self._custom_radio.setChecked(False)
+ layout.addWidget(self._custom_radio)
+
+ angle_layout = QHBoxLayout()
+ angle_layout.addWidget(QLabel("Angle X (°):"))
+ self._angle_x = QDoubleSpinBox()
+ self._angle_x.setDecimals(1)
+ self._angle_x.setRange(-360, 360)
+ self._angle_x.setValue(0.0)
+ self._angle_x.setSuffix("°")
+ angle_layout.addWidget(self._angle_x)
+ angle_layout.addWidget(QLabel("Angle Y (°):"))
+ self._angle_y = QDoubleSpinBox()
+ self._angle_y.setDecimals(1)
+ self._angle_y.setRange(-360, 360)
+ self._angle_y.setValue(0.0)
+ self._angle_y.setSuffix("°")
+ angle_layout.addWidget(self._angle_y)
+ layout.addLayout(angle_layout)
+
+ self._name_label = QLabel("Workplane Name:")
+ layout.addWidget(self._name_label)
+
+ self._name_input = QLineEdit()
+ self._name_input.setText("Workplane 1")
+ layout.addWidget(self._name_input)
+
+ # ── Buttons ──
+ line2 = QFrame()
+ line2.setFrameShape(QFrame.HLine)
+ line2.setFrameShadow(QFrame.Sunken)
+ layout.addWidget(line2)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("Create")
+ ok_button.clicked.connect(self._on_ok)
+ cancel_button = QPushButton("Cancel")
+ cancel_button.clicked.connect(self.reject)
+ button_layout.addWidget(ok_button)
+ button_layout.addWidget(cancel_button)
+ layout.addLayout(button_layout)
+
+ # Live preview: update the 3D view whenever the user changes
+ # the preset, custom radio toggle, or angle values.
+ self._preset_group.buttonClicked.connect(self._on_preset_changed)
+ self._custom_radio.toggled.connect(self._emit_preview)
+ self._angle_x.valueChanged.connect(self._emit_preview)
+ self._angle_y.valueChanged.connect(self._emit_preview)
+
+ def set_preview_callback(self, callback) -> None:
+ """Install a callback for live 3D preview of the workplane orientation.
+
+ *callback* is called with ``(normal, x_dir)`` whenever the user
+ changes the selection, or with *None* when the dialog closes.
+ """
+ self._preview_callback = callback
+ # Emit once so the initial state shows a preview right away.
+ self._emit_preview()
+
+ def _emit_preview(self, *args) -> None:
+ """Call the preview callback with the current orientation, if installed."""
+ if self._preview_callback is None:
+ return
+ try:
+ normal, x_dir, _name = self.get_orientation()
+ self._preview_callback((normal, x_dir))
+ except Exception as exc:
+ logger.debug("workplane preview callback raised: %s", exc)
+
+ def hideEvent(self, event):
+ """Clear the live preview when the dialog closes."""
+ if self._preview_callback is not None:
+ try:
+ self._preview_callback(None)
+ except Exception:
+ pass
+ super().hideEvent(event)
+
+ def _on_preset_changed(self, btn):
+ """When a preset is selected, deselect the custom radio and emit preview."""
+ self._custom_radio.setChecked(False)
+ self._emit_preview()
+
+ def _on_ok(self):
+ """Compute the final orientation and accept."""
+ import numpy as np
+ import math
+
+ if self._custom_radio.isChecked():
+ # Custom: start from XY normal and rotate by the two angles.
+ ax = math.radians(self._angle_x.value())
+ ay = math.radians(self._angle_y.value())
+ # Start from +Z normal, rotate around X then Y
+ n = np.array([0.0, 0.0, 1.0])
+ # Rotate around X
+ rx = np.array([
+ [1, 0, 0],
+ [0, math.cos(ax), -math.sin(ax)],
+ [0, math.sin(ax), math.cos(ax)],
+ ])
+ n = rx @ n
+ # Rotate around Y
+ ry = np.array([
+ [math.cos(ay), 0, math.sin(ay)],
+ [0, 1, 0],
+ [-math.sin(ay), 0, math.cos(ay)],
+ ])
+ n = ry @ n
+ n = n / np.linalg.norm(n)
+ # x_dir: cross product of normal with world Y, or world Z if normal ~ Y
+ world_y = np.array([0.0, 1.0, 0.0])
+ if abs(np.dot(n, world_y)) > 0.99:
+ world_y = np.array([0.0, 0.0, 1.0])
+ x = np.cross(world_y, n)
+ x_norm = np.linalg.norm(x)
+ if x_norm > 1e-9:
+ x = x / x_norm
+ else:
+ x = np.array([1.0, 0.0, 0.0])
+ self._normal = tuple(float(v) for v in n)
+ self._x_dir = tuple(float(v) for v in x)
+ else:
+ btn = self._preset_group.checkedButton()
+ if btn is not None:
+ self._normal = btn.normal
+ self._x_dir = btn.x_dir
+ self.accept()
+
+ def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
+ """Return (normal, x_dir, name) for the chosen workplane.
+
+ Computes the current selection from the UI state so it works
+ whether called before or after ``_on_ok``.
+ """
+ import numpy as np
+ import math
+
+ if self._custom_radio.isChecked():
+ ax = math.radians(self._angle_x.value())
+ ay = math.radians(self._angle_y.value())
+ n = np.array([0.0, 0.0, 1.0])
+ rx = np.array([
+ [1, 0, 0],
+ [0, math.cos(ax), -math.sin(ax)],
+ [0, math.sin(ax), math.cos(ax)],
+ ])
+ n = rx @ n
+ ry = np.array([
+ [math.cos(ay), 0, math.sin(ay)],
+ [0, 1, 0],
+ [-math.sin(ay), 0, math.cos(ay)],
+ ])
+ n = ry @ n
+ n = n / np.linalg.norm(n)
+ world_y = np.array([0.0, 1.0, 0.0])
+ if abs(np.dot(n, world_y)) > 0.99:
+ world_y = np.array([0.0, 0.0, 1.0])
+ x = np.cross(world_y, n)
+ x_norm = np.linalg.norm(x)
+ if x_norm > 1e-9:
+ x = x / x_norm
+ else:
+ x = np.array([1.0, 0.0, 0.0])
+ return (
+ tuple(float(v) for v in n),
+ tuple(float(v) for v in x),
+ self._name_input.text().strip() or "Workplane",
+ )
+ else:
+ btn = self._preset_group.checkedButton()
+ if btn is not None:
+ return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
+ # Fallback: XY default.
+ return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
+
+
class Sketch2DWidget(QWidget):
"""2D sketching widget with SolveSpace constraint solving and drawing tools."""
@@ -377,6 +826,14 @@ class Sketch2DWidget(QWidget):
self._snap_horizontal: bool = False
self._snap_vertical: bool = False
+ # Rectangle first-click snap target (stored so the second click
+ # doesn't overwrite it and the correct corner gets constrained).
+ self._rect_first_snap_target: Optional[OCCSketchEntity] = None
+
+ # Offset preview state (live preview while the OffsetDialog is open).
+ self._offset_preview_points: List[Tuple[float, float]] = []
+ self._offset_preview_active: bool = False
+
self.setFocusPolicy(Qt.StrongFocus)
self._setup_ui()
@@ -413,6 +870,7 @@ class Sketch2DWidget(QWidget):
self._source_workplane = None
self._source_underlay_uv = []
self._rebuild_from_sketch()
+ self.clear_offset_preview()
self._hovered_point = None
self._hovered_point_entity = None
self._hovered_line = None
@@ -420,6 +878,61 @@ class Sketch2DWidget(QWidget):
self._selected_entities = []
self.update()
+ def _convert_underlay_to_sketch(self) -> None:
+ """Convert underlay (external/construction) entities into regular geometry.
+
+ Creates new non-construction point and line entities at the same
+ positions as every external entity in the sketch, coincident-
+ constrained to their external counterparts so they stay aligned.
+ The underlay reference entities are left intact (the user can still
+ toggle them on/off).
+
+ After calling this, the newly created regular geometry participates
+ in face detection and can be selected for offset, extrude, or cut
+ operations — without the user having to manually trace the
+ projected outlines.
+ """
+ if self._sketch is None:
+ return
+
+ # Map external point IDs → newly created regular point entities
+ ext_to_new: Dict[int, OCCSketchEntity] = {}
+
+ # Snapshot the entity list before modifying (add_point adds to
+ # _entities, which would change dict size mid-iteration).
+ for eid, entity in list(self._sketch._entities.items()):
+ if (
+ entity.is_external
+ and entity.entity_type == "point"
+ and entity.geometry is not None
+ ):
+ x, y = entity.geometry
+ new_pt = self._sketch.add_point(x, y)
+ new_pt.is_construction = False
+ self._points.append(new_pt)
+ self._sketch.constrain_coincident(new_pt, entity)
+ ext_to_new[eid] = new_pt
+
+ if not ext_to_new:
+ return
+
+ # Create matching lines for each external line segment
+ for eid, (sid, eid2) in list(self._sketch._lines.items()):
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if s_ent and e_ent and s_ent.is_external and e_ent.is_external:
+ new_s = ext_to_new.get(sid)
+ new_e = ext_to_new.get(eid2)
+ if new_s is not None and new_e is not None:
+ new_line = self._sketch.add_line(new_s, new_e)
+ self._lines.append((new_s, new_e))
+
+ self._solve_and_sync()
+ self._rebuild_from_sketch()
+ # Hide the underlay so the user can see the new solid geometry
+ self._underlay_visible = False
+ self.update()
+
def set_source_face(
self,
face: Any,
@@ -672,6 +1185,29 @@ class Sketch2DWidget(QWidget):
self._dynamic_line_end = None
self._mode = None
self._clear_move_state()
+ self._rect_first_snap_target = None
+ self.clear_offset_preview()
+ self.update()
+
+ def set_offset_preview(self, points: Optional[List[Tuple[float, float]]]) -> None:
+ """Show or clear the offset preview overlay in the 2D view.
+
+ *points* is a list of (x, y) tuples forming a closed polygon, or
+ *None* / empty to clear the preview. Used by the OffsetDialog
+ live-preview callback to show the offset result in real time.
+ """
+ if points:
+ self._offset_preview_points = list(points)
+ self._offset_preview_active = True
+ else:
+ self._offset_preview_points = []
+ self._offset_preview_active = False
+ self.update()
+
+ def clear_offset_preview(self) -> None:
+ """Remove the offset preview overlay."""
+ self._offset_preview_points = []
+ self._offset_preview_active = False
self.update()
def _clear_move_state(self):
@@ -686,6 +1222,7 @@ class Sketch2DWidget(QWidget):
self._snap_line_target = None
self._snap_horizontal = False
self._snap_vertical = False
+ self._rect_first_snap_target = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
@@ -698,6 +1235,7 @@ class Sketch2DWidget(QWidget):
self._hovered_face = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
+ self.clear_offset_preview()
# Cancel an ongoing move when switching modes
if self._move_active:
self._clear_move_state()
@@ -727,6 +1265,18 @@ class Sketch2DWidget(QWidget):
int((self.height() / 2 - pos.y() + self._offset.y()) / self._zoom),
)
+ def _screen_to_world_f(self, pos: QPoint) -> Tuple[float, float]:
+ """Float-precision version of ``_screen_to_world``.
+
+ Used by face detection so that small shapes (sub-integer size at
+ the current zoom) are pickable. The integer version truncates
+ fractional world coords, which can place the hit point outside
+ a tiny polygon.
+ """
+ wx = (pos.x() - self.width() / 2 - self._offset.x()) / self._zoom
+ wy = (self.height() / 2 - pos.y() + self._offset.y()) / self._zoom
+ return (wx, wy)
+
def _world_to_screen(self, pos: QPoint) -> QPoint:
return QPoint(
int(pos.x() * self._zoom + self.width() / 2 + self._offset.x()),
@@ -1244,18 +1794,39 @@ class Sketch2DWidget(QWidget):
# ─── Element move helpers ─────────────────────────────────────────────
def _collect_connected_points(self, anchor: OCCSketchEntity) -> List[OCCSketchEntity]:
- """Return anchor plus all point entities connected through lines.
+ """Return anchor plus all point entities connected through lines AND arcs.
- ``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id); we
- BFS over that graph to gather the whole element (e.g. all 4 corners of a
- rectangle, or both endpoints of a single line).
+ ``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id) and
+ ``OCCSketch._arcs`` maps arc id -> {"center": cid, "start": sid, "end": eid, ...}.
+ We BFS over that graph to gather the whole element (e.g. all 4 corners of a
+ rectangle, or both endpoints of a single line, or all 6 points of a slot).
"""
if not self._sketch:
return [anchor]
adjacency: Dict[int, List[int]] = {}
+ # Line connections
for _line_id, (sid, eid2) in self._sketch._lines.items():
adjacency.setdefault(sid, []).append(eid2)
adjacency.setdefault(eid2, []).append(sid)
+ # Arc connections — an arc ties its centre to its start and end, and
+ # also ties start to end so the whole arc body moves as a single unit.
+ for _arc_id, arc_data in self._sketch._arcs.items():
+ center_id = arc_data.get("center")
+ start_id = arc_data.get("start")
+ end_id = arc_data.get("end")
+ if center_id is not None:
+ if start_id is not None:
+ adjacency.setdefault(center_id, []).append(start_id)
+ adjacency.setdefault(start_id, []).append(center_id)
+ if end_id is not None:
+ adjacency.setdefault(center_id, []).append(end_id)
+ adjacency.setdefault(end_id, []).append(center_id)
+ # Connect start <-> end so the arc body moves as a unit even
+ # without traversing through centre (e.g. grabbing an endpoint
+ # pulls the other endpoint and the centre together).
+ if start_id is not None and end_id is not None:
+ adjacency.setdefault(start_id, []).append(end_id)
+ adjacency.setdefault(end_id, []).append(start_id)
seen = {anchor.id}
result: List[OCCSketchEntity] = [anchor]
queue = [anchor.id]
@@ -1374,7 +1945,10 @@ class Sketch2DWidget(QWidget):
return
# ② Face region — click inside a closed face to select it.
- face = self._sketch.find_face_at(world_pos.x(), world_pos.y())
+ # Use float-precision world coords so small shapes (sub-integer
+ # at the current zoom) are still pickable.
+ fwx, fwy = self._screen_to_world_f(event.pos())
+ face = self._sketch.find_face_at(fwx, fwy)
if face is not None:
if self._faces_match(face, self._selected_face):
# Clicking the same face again toggles it off.
@@ -1517,7 +2091,7 @@ class Sketch2DWidget(QWidget):
self._arc_prev_angle = current_angle
# Constraint-tag hover takes priority — if the cursor is over a tag,
- # we highlight it for delete and skip point/line hover this move.
+ # we highlight it for delete and skip point/line/face hover this move.
self._constraint_tags = self._compute_constraint_tags()
tag_hit = None
for tag in self._constraint_tags:
@@ -1530,71 +2104,81 @@ class Sketch2DWidget(QWidget):
self._hovered_point_entity = None
self._hovered_line = None
self._hovered_line_entity = None
+ self._hovered_face = None
self.setCursor(Qt.PointingHandCursor)
self.update()
return
if self._hovered_constraint_idx != -1:
self._hovered_constraint_idx = -1
- self.update()
- # Point hover — show an open-hand cursor when a move is possible
- # (explicit Move tool OR no tool selected at all)
+ # Priority for select/move mode: point > face > line > circle.
+ # Face is checked before line so the user can see and select the
+ # wall region between two concentric boundaries (e.g. after offset).
+ # In drawing modes, lines take priority so the user can snap to them.
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
+
+ # ── ① Point hover (tightest, always first) ──
point_snap = self._find_nearest_point(event.pos())
if point_snap:
self._hovered_point = point_snap
self._hovered_point_entity = self._find_nearest_point_entity(event.pos())
self._hovered_line = None
self._hovered_line_entity = None
+ self._hovered_face = None
self.setCursor(hover_cursor)
else:
self._hovered_point = None
self._hovered_point_entity = None
- # Check line hover
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- if p1_ent.geometry and p2_ent.geometry:
- self._hovered_line = (
- QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
- QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
- )
- self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
- self.setCursor(hover_cursor)
+
+ # ── ② Face hover (checked before line in select mode) ──
+ # In select/move mode the face is the primary interaction target
+ # (select to extrude etc.). Lines remain selectable via click
+ # (element move) but the visual highlight shows the face region.
+ face_found = False
+ if self._mode in ("select", None) and self._sketch is not None:
+ fwx, fwy = self._screen_to_world_f(event.pos())
+ face = self._sketch.find_face_at(fwx, fwy)
+ if face is not None:
+ self._hovered_face = face
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self.setCursor(Qt.PointingHandCursor)
+ face_found = True
+
+ if not face_found:
+ self._hovered_face = None
+
+ # ── ③ Line hover (when no face under cursor) ──
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ if p1_ent.geometry and p2_ent.geometry:
+ self._hovered_line = (
+ QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
+ QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
+ )
+ self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
+ self.setCursor(hover_cursor)
+ else:
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self.setCursor(Qt.ArrowCursor)
else:
self._hovered_line = None
self._hovered_line_entity = None
- self.setCursor(Qt.ArrowCursor)
- else:
- self._hovered_line = None
- self._hovered_line_entity = None
- # In move mode, hovering a circle curve should also show grab cursor
- if self._mode in ("select", None):
- over_circle = False
- for c_ent, r in self._circles:
- if c_ent.geometry and r > 0:
- cx, cy = c_ent.geometry
- d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
- if abs(d - r) < 8:
- over_circle = True
- break
- self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
- else:
- self.setCursor(Qt.ArrowCursor)
-
- # Face region hover (fallback when no point/line/circle/tag is under the cursor).
- if (self._hovered_constraint_idx < 0
- and self._hovered_point is None
- and self._hovered_line is None
- and self._sketch is not None):
- face = self._sketch.find_face_at(world_pos.x(), world_pos.y())
- if face is not None:
- self._hovered_face = face
- self.setCursor(Qt.PointingHandCursor if self._mode in ("select", None) else Qt.ArrowCursor)
- else:
- self._hovered_face = None
- else:
- self._hovered_face = None
+ # ── ④ Circle hover ──
+ if self._mode in ("select", None):
+ over_circle = False
+ for c_ent, r in self._circles:
+ if c_ent.geometry and r > 0:
+ cx, cy = c_ent.geometry
+ d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
+ if abs(d - r) < 8:
+ over_circle = True
+ break
+ self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
+ else:
+ self.setCursor(Qt.ArrowCursor)
self.update()
@@ -1829,6 +2413,7 @@ class Sketch2DWidget(QWidget):
if not self._draw_buffer:
self._draw_buffer.append(pos)
+ self._rect_first_snap_target = self._snap_point_target
else:
p1 = self._draw_buffer[0]
p2 = pos
@@ -1851,9 +2436,13 @@ class Sketch2DWidget(QWidget):
self._lines.append((pts[i], pts[(i + 1) % 4]))
line_entities.append(line)
- # Auto-constrain: point snap → coincident on nearest corner
+ # Auto-constrain: point snap → coincident on the correct corners.
+ # pts[0] = first click snapped position
+ # pts[2] = second click snapped position
+ if self._rect_first_snap_target is not None:
+ self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
if self._snap_point_target is not None:
- self._sketch.constrain_coincident(pts[0], self._snap_point_target)
+ self._sketch.constrain_coincident(pts[2], self._snap_point_target)
# Auto-constrain: detect horizontal / vertical from geometry
if self._snap_mode.get("horiz", False):
@@ -2129,6 +2718,37 @@ class Sketch2DWidget(QWidget):
line2 = self._sketch.add_line(pt2, pt1) # top line
self._lines.append((pt2, pt1))
+ # ── Parametric construction: perpendicular reference per arc ──
+ # Each arc needs a construction line from its centre, perpendicular
+ # to the connecting lines, to define the arc-endpoint direction and
+ # maintain smooth tangency between arc and line. The corner points
+ # sit on this line at distance r from the centre (the arc radius).
+ # Without this the solver can rotate the endpoints around the
+ # centre, breaking the visual arc-line tangency, and bare distance
+ # constraints lock the slot because the centre is the sketch's
+ # first (reference-fixed) point.
+
+ # Arc 1 (C1): construction line from centre to top endpoint
+ acl1 = self._sketch.add_line(c1_ent, pt1)
+ acl1.is_construction = True
+ self._lines.append((c1_ent, pt1))
+ # ⟂ to the connecting lines (line1 ∥ line2, so ⟂to one = ⟂to both)
+ self._sketch.constrain_perpendicular(acl1, line1)
+ # Bottom endpoint sits on this same line (diametrically opposite)
+ self._sketch.constrain_coincident(pb1, acl1)
+ # Arc radius: both endpoints at distance r from centre
+ self._sketch.constrain_distance(pt1, c1_ent, r)
+ self._sketch.constrain_distance(pb1, c1_ent, r)
+
+ # Arc 2 (C2): construction line from centre to top endpoint
+ acl2 = self._sketch.add_line(c2_ent, pt2)
+ acl2.is_construction = True
+ self._lines.append((c2_ent, pt2))
+ self._sketch.constrain_perpendicular(acl2, line1)
+ self._sketch.constrain_coincident(pb2, acl2)
+ self._sketch.constrain_distance(pt2, c2_ent, r)
+ self._sketch.constrain_distance(pb2, c2_ent, r)
+
# Auto-constrain: horizontal / vertical on the two slot lines
if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
for line_ent, pa, pb in [(line1, pb1, pb2), (line2, pt2, pt1)]:
@@ -2669,6 +3289,21 @@ class Sketch2DWidget(QWidget):
painter.setBrush(Qt.NoBrush)
painter.drawArc(rect, start_deg_16, span_deg_16)
+ # ── Offset preview (live preview from OffsetDialog) ──
+ if self._offset_preview_active and self._offset_preview_points:
+ preview = self._offset_preview_points
+ painter.setPen(QPen(QColor("#f9e2af"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ for i in range(len(preview)):
+ p1 = self._world_to_screen(
+ QPoint(int(round(preview[i][0])), int(round(preview[i][1])))
+ )
+ p2 = self._world_to_screen(
+ QPoint(int(round(preview[(i + 1) % len(preview)][0])),
+ int(round(preview[(i + 1) % len(preview)][1])))
+ )
+ painter.drawLine(p1, p2)
+
# ── Dynamic drawing previews ──
if self._draw_buffer and self._dynamic_line_end and self._mode == "line":
start = self._world_to_screen(self._draw_buffer[0])
@@ -3113,6 +3748,40 @@ class Viewer3DWidget(QWidget):
self._renderer.fit_camera()
self._renderer.render()
+ # ─── Workplane visualization ───────────────────────────────────────────
+
+ def show_workplane(
+ self,
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ normal: Tuple[float, float, float] = (0, 0, 1),
+ x_dir: Tuple[float, float, float] = (1, 0, 0),
+ size: float = 200.0,
+ name: Optional[str] = None,
+ ) -> Optional[str]:
+ """Display a semi-transparent workplane plane in the 3D view.
+
+ Returns the object ID (for later removal) or None if the renderer
+ doesn't support workplane planes.
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "show_workplane_plane", None)
+ if fn is None:
+ return None
+ oid = fn(origin, normal, x_dir, size, name)
+ self._renderer.render()
+ return oid
+
+ def remove_workplane(self, obj_id: str) -> bool:
+ """Remove a workplane plane visual by its ID."""
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "remove_workplane_plane", None)
+ if fn is None:
+ return False
+ ok = fn(obj_id)
+ if ok:
+ self._renderer.render()
+ return ok
+
def mousePressEvent(self, event):
self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on.
@@ -3428,6 +4097,11 @@ 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)
+ # ── New Workplane button ──
+ self._btn_wp_new = QPushButton("WP New")
+ self._btn_wp_new.setToolTip("Create a new independent workplane (datum plane)")
+ self._btn_wp_new.setShortcut("Shift+W")
+ wp_layout.addWidget(self._btn_wp_new, 2, 0, 1, 2)
# 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.
@@ -3438,14 +4112,24 @@ class MainWindow(QMainWindow):
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)
+ wp_layout.addWidget(self._btn_underlay, 3, 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)
+ wp_layout.addWidget(self._btn_clr_face, 3, 1)
+ # ToSketch: convert the underlay (projected construction lines)
+ # into regular non-construction sketch geometry so the user can
+ # offset, modify, or extrude the projected shape directly without
+ # having to redraw it manually.
+ self._btn_to_sketch = QPushButton("ToSketch")
+ self._btn_to_sketch.setToolTip(
+ "Convert projected construction lines into real sketch geometry"
+ )
+ self._btn_to_sketch.setEnabled(False)
+ wp_layout.addWidget(self._btn_to_sketch, 4, 0, 1, 2)
grid.addWidget(wp_group, 0, 0)
# ---- Row 1, Col 0: Drawing ----
@@ -3485,6 +4169,9 @@ class MainWindow(QMainWindow):
self._btn_snap.setCheckable(True)
self._btn_snap.setChecked(True)
draw_layout.addWidget(self._btn_snap, 3, 1)
+ self._btn_offset = QPushButton("Offst")
+ self._btn_offset.setToolTip("Offset selected sketch face (duplicate + offset boundary)")
+ draw_layout.addWidget(self._btn_offset, 4, 0, 1, 2)
grid.addWidget(draw_group, 1, 0)
# ---- Row 2, Col 0: Constrain ----
@@ -3846,16 +4533,20 @@ class MainWindow(QMainWindow):
self._body_list.itemChanged.connect(self._on_body_visibility_changed)
self._btn_wp_origin.clicked.connect(self._new_sketch_origin)
+ self._btn_wp_new.clicked.connect(self._new_workplane)
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.
+ # Underlay show/hide, ClrFace, and ToSketch — all stay in sync
+ # with the source face state managed by set_source_face /
+ # clear_source_face / _project_body_to_active_wp.
self._btn_underlay.toggled.connect(self._on_underlay_toggled)
self._btn_clr_face.clicked.connect(self._on_clear_source_face)
+ self._btn_to_sketch.clicked.connect(self._on_convert_underlay_to_sketch)
# Generic buttons
self._btn_move.clicked.connect(self._translate_body)
self._btn_array.clicked.connect(self._pattern_array)
+ self._btn_offset.clicked.connect(self._offset_sketch)
self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
# Snap toggle
@@ -4022,8 +4713,207 @@ class MainWindow(QMainWindow):
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
logger.info(f"Redraw render object: {body.render_object}")
+ # Re-add workplane visuals after the clear.
+ for wp_id, wp in self._current_component.workplanes.items():
+ if wp.visible:
+ wp.render_object = self._viewer_3d.show_workplane(
+ origin=wp.origin,
+ normal=wp.normal,
+ x_dir=wp.x_dir,
+ size=250.0,
+ name=f"workplane_{wp.id}",
+ )
+
self._viewer_3d.fit_camera()
+ def _new_workplane(self):
+ """Open the orientation dialog and create a new independent workplane.
+
+ The workplane is shown as a semi-transparent plane in the 3D view
+ (with live preview as the user adjusts options in the dialog).
+ A fresh sketch is created on it ready for drawing, and body outlines
+ are projected as underlay construction lines for precise alignment.
+ """
+ dialog = WorkplaneOrientationDialog(self)
+ origin = (0.0, 0.0, 0.0)
+ _preview_obj_id: Optional[str] = None
+
+ def _preview_callback(orientation):
+ """Live-preview the workplane orientation in the 3D viewer."""
+ nonlocal _preview_obj_id
+ if orientation is None:
+ # Dialog closing — clear the preview.
+ if _preview_obj_id is not None:
+ self._viewer_3d.remove_workplane(_preview_obj_id)
+ _preview_obj_id = None
+ return
+ normal, x_dir = orientation
+ # Replace the previous preview (same ID = update in place).
+ if _preview_obj_id is not None:
+ self._viewer_3d.remove_workplane(_preview_obj_id)
+ _preview_obj_id = self._viewer_3d.show_workplane(
+ origin=origin,
+ normal=normal,
+ x_dir=x_dir,
+ size=250.0,
+ name="__wp_preview__",
+ )
+
+ dialog.set_preview_callback(_preview_callback)
+
+ if not dialog.exec():
+ # Preview already cleared by dialog.hideEvent → callback(None).
+ return
+
+ normal, x_dir, wp_name = dialog.get_orientation()
+
+ if not self._current_component:
+ self._current_component = self._project.add_component()
+
+ # Create the Workplane model.
+ from fluency.models.data_model import Workplane
+ wp = self._current_component.add_workplane(
+ Workplane(
+ name=wp_name,
+ origin=origin,
+ normal=normal,
+ x_dir=x_dir,
+ )
+ )
+
+ # The preview visual becomes the permanent workplane; just update
+ # its name so it can be found later for removal.
+ if _preview_obj_id is not None:
+ # Store the render object ID in the workplane model.
+ wp.render_object = _preview_obj_id
+ # The show_workplane_plane method replaced the preview already,
+ # so the visual is showing the final orientation.
+ else:
+ # Fallback: create a new visual (shouldn't happen).
+ wp.render_object = self._viewer_3d.show_workplane(
+ origin=origin,
+ normal=normal,
+ x_dir=x_dir,
+ size=250.0,
+ name=f"workplane_{wp.id}",
+ )
+
+ # Create a sketch on this workplane and set up the 2D widget.
+ sketch = self._current_component.add_sketch()
+ sketch.name = f"Sketch on {wp.name}"
+ sketch.set_workplane(origin, normal, x_dir)
+ sketch._source_workplane_id = wp.id
+
+ # Prepare the OCC sketch in the widget.
+ 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)
+ self._current_sketch = sketch
+
+ # Project body outlines onto the workplane for alignment.
+ self._project_body_to_active_wp()
+
+ self._sketch_widget.set_mode("line")
+ self._btn_line.setChecked(True)
+
+ self._refresh_lists()
+ self._set_panel_focus("sketch")
+ self.statusBar().showMessage(
+ f"Workplane '{wp.name}' created — sketch on it to draw. "
+ f"Body outlines projected as underlay.",
+ 6000,
+ )
+ logger.info(f"New workplane '{wp.name}' with orientation n={normal} x={x_dir}")
+
+ def _project_body_to_active_wp(self) -> None:
+ """Project all body outlines in the current component onto the active
+ sketch's workplane as underlay construction lines.
+
+ This lets the user see the 3D body's silhouette from the workplane's
+ perspective and position their 2D sketch precisely relative to the
+ existing geometry. Uses the same external-entity mechanism as
+ face-projected underlay (``set_source_face``).
+ """
+ if not self._current_component or not self._current_sketch:
+ return
+ occ_sketch = self._current_sketch.occ_sketch
+ if occ_sketch is None:
+ return
+ wp = occ_sketch.get_workplane()
+ if not wp:
+ return
+ origin = wp[0] # (ox, oy, oz)
+ normal = wp[1] # (nx, ny, nz)
+ x_dir = wp[2] # (xx, xy, xz)
+
+ # Collect all body shapes from the current component.
+ body_shapes = []
+ kernel = self._kernel
+ for body in self._current_component.bodies.values():
+ if body.geometry is not None:
+ shape = kernel._get_shape(body.geometry)
+ if shape is not None:
+ body_shapes.append(shape)
+
+ if not body_shapes:
+ self._sketch_widget.clear_source_face()
+ self._btn_underlay.setEnabled(False)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(False)
+ self._btn_to_sketch.setEnabled(False)
+ return
+
+ # Project edges of all bodies onto the workplane.
+ workplane_data = (origin, normal, x_dir)
+ all_polylines: List[List[Tuple[float, float]]] = []
+ for shape in body_shapes:
+ try:
+ polys = _project_body_to_workplane(shape, workplane_data)
+ all_polylines.extend(polys)
+ except Exception as exc:
+ logger.debug("body projection failed for a shape: %s", exc)
+
+ if not all_polylines:
+ return
+
+ # Import the polylines as external/underlay entities in the sketch.
+ # First clear any existing external entities, then add the new ones.
+ occ_sketch.remove_external_entities()
+ imported_count = 0
+ for poly in all_polylines:
+ if len(poly) < 2:
+ continue
+ try:
+ _, lines = occ_sketch.add_external_polyline(
+ [(float(u), float(v)) for (u, v) in poly]
+ )
+ imported_count += len(lines)
+ except Exception as exc:
+ logger.debug("workplane underlay polyline import failed: %s", exc)
+
+ if imported_count > 0:
+ logger.info(
+ "Imported %d construction-line segments from body outlines",
+ imported_count,
+ )
+ # Refresh the 2D widget's entity tracking. We do NOT set
+ # _source_underlay_uv here because body projections produce
+ # many disjoint polylines — the fill paintEvent draws from
+ # _source_underlay_uv[0] would look wrong. The external
+ # entities themselves (orange dashed lines) provide the
+ # visual underlay.
+ self._sketch_widget._rebuild_from_sketch()
+ self._sketch_widget._source_workplane = workplane_data
+ self._sketch_widget._source_underlay_uv = []
+ self._sketch_widget._underlay_visible = True
+ self._sketch_widget.update()
+ # Enable the underlay toggle so the user can hide lines.
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.setEnabled(True)
+
def _new_sketch_origin(self):
self._sketch_widget.create_sketch()
self._sketch_widget.set_mode("line")
@@ -4060,6 +4950,300 @@ class MainWindow(QMainWindow):
def _pattern_array(self):
logger.info("Pattern array not yet implemented")
+ # ─── Offset sketch ─────────────────────────────────────────────────────
+
+ @staticmethod
+ def _find_parent_point_entities(
+ sketch: OCCSketch,
+ positions: List[Tuple[float, float]],
+ tolerance: float = 0.01,
+ ) -> List[Optional[OCCSketchEntity]]:
+ """Match position tuples to the corresponding point entities in the sketch.
+
+ Searches ``sketch._entities`` for point entities whose geometry
+ matches each entry in *positions* within *tolerance*. Returns a
+ list parallel to *positions*; unmatched entries are *None*.
+ Skips external / centerline / construction entities so we only
+ pick up user-drawn boundary points.
+ """
+ matches: List[Optional[OCCSketchEntity]] = []
+ for (tx, ty) in positions:
+ found: Optional[OCCSketchEntity] = None
+ for eid, entity in sketch._entities.items():
+ if entity.entity_type != "point":
+ continue
+ if entity.is_external or entity.is_construction:
+ continue
+ if entity.id in sketch._centerline_ids:
+ continue
+ if entity.geometry is not None:
+ ex, ey = entity.geometry
+ if abs(ex - tx) < tolerance and abs(ey - ty) < tolerance:
+ found = entity
+ break
+ matches.append(found)
+ return matches
+
+ def _offset_sketch(self) -> None:
+ """Open the offset dialog and apply an offset to the selected sketch face.
+
+ The user must first select a closed face (region) in the 2D sketch.
+ When the Offset button is pressed:
+ 1. The selected face's outer boundary is read.
+ 2. An OffsetDialog appears with a number spinner.
+ 3. Live preview shows the offset result in the 2D view.
+ 4. On OK, new point & line entities are created in the sketch
+ at the offset position, duplicating the original boundary.
+ 5. Distance constraints auto-connect each offset point to its
+ parent so the offset stays parametric.
+ 6. The region between the original and offset boundaries forms
+ a selectable wall face (e.g. for extrusion into a thin wall).
+ """
+ # Ensure we have a sketch and a selected face.
+ sketch = self._sketch_widget.get_sketch()
+ if sketch is None:
+ QMessageBox.warning(self, "No Sketch", "Please create and select a sketch first.")
+ return
+
+ selected_face = self._sketch_widget._selected_face
+ if selected_face is None:
+ QMessageBox.warning(
+ self, "No Face Selected",
+ "Click inside a closed face (region) in the sketch to select it, "
+ "then press Offset."
+ )
+ return
+
+ outer = selected_face.get("outer")
+ if outer is None:
+ QMessageBox.warning(self, "No Outer Boundary", "Selected face has no outer boundary.")
+ return
+
+ # ── Extract boundary points ──
+ if outer["type"] == "circle":
+ cx, cy = outer["center"]
+ radius = outer["radius"]
+ is_circle = True
+ elif outer["type"] == "polygon":
+ pts = list(outer["points"])
+ if len(pts) < 3:
+ QMessageBox.warning(self, "Invalid Polygon", "Face boundary has fewer than 3 points.")
+ return
+ # Remove closing duplicate (last == first) if present.
+ if len(pts) > 1 and pts[-1] == pts[0]:
+ pts.pop()
+ is_circle = False
+ else:
+ QMessageBox.warning(self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported.")
+ return
+
+ # ── Find the ORIGINAL point entities so we can constrain to them ──
+ if is_circle:
+ parent_center = self._find_parent_point_entities(sketch, [(cx, cy)], tolerance=0.01)
+ parent_center_entity = parent_center[0] if parent_center else None
+ else:
+ parent_entities = self._find_parent_point_entities(sketch, pts, tolerance=0.01)
+
+ # ── Open dialog with live preview ──
+ dialog = OffsetDialog(self)
+
+ def _compute_offset_preview(distance: float, inward: bool) -> Optional[List[Tuple[float, float]]]:
+ """Return offset polygon points, or None for circles."""
+ d = -distance if inward else distance
+ if is_circle:
+ return None # circles not drawn as polygon preview
+ try:
+ return _offset_polygon(pts, d)
+ except Exception as exc:
+ logger.debug("offset preview compute failed: %s", exc)
+ return None
+
+ def _preview_callback(values):
+ if values is None:
+ self._sketch_widget.clear_offset_preview()
+ return
+ distance, inward = values
+ preview_pts = _compute_offset_preview(distance, inward)
+ self._sketch_widget.set_offset_preview(preview_pts)
+
+ dialog.set_preview_callback(_preview_callback)
+
+ if not dialog.exec():
+ # Preview already cleared by hideEvent.
+ self._sketch_widget.clear_offset_preview()
+ return
+
+ self._sketch_widget.clear_offset_preview()
+
+ distance, inward = dialog.get_values()
+ d = -distance if inward else distance
+ logger.info(f"Offset distance: {abs(d):.2f} mm {'inward' if inward else 'outward'}")
+
+ try:
+ # ── Apply offset: create new entities in the sketch ──
+ if is_circle:
+ self._apply_circle_offset(
+ sketch, cx, cy, radius, d, selected_face,
+ parent_center_entity=parent_center_entity,
+ offset_distance=abs(d),
+ )
+ else:
+ self._apply_polygon_offset(
+ sketch, pts, d, selected_face,
+ parent_entities=parent_entities,
+ offset_distance=abs(d),
+ )
+
+ self._sketch_widget._rebuild_from_sketch()
+ self._sketch_widget._solve_and_sync()
+ self._sketch_widget.sketch_updated.emit()
+ self._sketch_widget.update()
+
+ self.statusBar().showMessage(
+ f"Offset sketch by {abs(d):.2f} mm {'inward' if inward else 'outward'}", 4000
+ )
+ logger.info("Offset complete")
+
+ except Exception as e:
+ logger.exception(f"Offset failed: {e}")
+ QMessageBox.critical(self, "Error", f"Offset failed: {e}")
+
+ def _apply_polygon_offset(
+ self, sketch: OCCSketch,
+ pts: List[Tuple[float, float]],
+ distance: float,
+ face: Dict[str, Any],
+ parent_entities: Optional[List[Optional[OCCSketchEntity]]] = None,
+ offset_distance: float = 0.0,
+ ) -> None:
+ """Duplicate a polygon boundary at *distance* offset and add to the sketch.
+
+ Creates new point + line entities for the offset boundary and
+ re-applies any holes from the original face (offset by the same
+ distance, clipped if they collapse). The region between the
+ original boundary and the offset boundary becomes a selectable
+ face (e.g. a thin wall for extrusion). When *parent_entities*
+ is provided, a distance constraint is added between each parent
+ point and the corresponding offset point with the
+ *offset_distance* value.
+ """
+ offset_pts = _offset_polygon(pts, distance)
+
+ # Create new point entities at the offset positions.
+ new_points = []
+ for (x, y) in offset_pts:
+ pt = sketch.add_point(float(x), float(y))
+ new_points.append(pt)
+
+ # Create line entities connecting the new points.
+ new_lines = []
+ for i in range(len(new_points)):
+ j = (i + 1) % len(new_points)
+ line = sketch.add_line(new_points[i], new_points[j])
+ new_lines.append(line)
+
+ # ── Auto-constrain: distance constraint between each parent
+ # point and its corresponding offset point ──
+ if parent_entities and offset_distance > 0:
+ constrained = 0
+ for parent_ent, new_pt in zip(parent_entities, new_points):
+ if parent_ent is not None:
+ try:
+ sketch.constrain_distance(parent_ent, new_pt, offset_distance)
+ constrained += 1
+ except Exception as exc:
+ logger.debug(
+ "distance constraint failed for parent id=%s: %s",
+ parent_ent.id, exc,
+ )
+ if constrained:
+ logger.info("Added %d distance constraints to offset polygon", constrained)
+
+ # ── Offset holes ──
+ holes = face.get("holes", [])
+ for hole in holes:
+ if hole["type"] != "polygon":
+ continue
+ hole_pts = list(hole["points"])
+ if len(hole_pts) < 3:
+ continue
+ if len(hole_pts) > 1 and hole_pts[-1] == hole_pts[0]:
+ hole_pts.pop()
+ # Holes are offset in the OPPOSITE direction (a positive outer
+ # offset should make holes smaller, not larger).
+ offset_hole = _offset_polygon(hole_pts, -distance)
+ if len(offset_hole) < 3:
+ logger.debug("Hole offset collapsed — skipping")
+ continue
+ hole_points = []
+ for (x, y) in offset_hole:
+ pt = sketch.add_point(float(x), float(y))
+ hole_points.append(pt)
+ for i in range(len(hole_points)):
+ j = (i + 1) % len(hole_points)
+ sketch.add_line(hole_points[i], hole_points[j])
+
+ logger.info(
+ "Created %d offset points and %d offset lines for polygon boundary + %d holes",
+ len(offset_pts), len(offset_pts),
+ len([h for h in holes if h.get("type") == "polygon"]),
+ )
+
+ def _apply_circle_offset(
+ self, sketch: OCCSketch,
+ cx: float, cy: float, radius: float,
+ distance: float,
+ face: Dict[str, Any],
+ parent_center_entity: Optional[OCCSketchEntity] = None,
+ offset_distance: float = 0.0,
+ ) -> None:
+ """Duplicate a circle at *distance* offset and add to the sketch.
+
+ For circles the offset is simply a new circle with (radius ± distance).
+ A new center point is created so the original is not disturbed.
+ The region between the original and offset circles becomes a
+ selectable face (annular wall for extrusion). When
+ *parent_center_entity* is provided a distance constraint links
+ it to the new center.
+ """
+ new_radius = radius + distance
+ if new_radius <= 0:
+ logger.warning("Offset radius would be non-positive — skipping")
+ return
+
+ # Create a new center point (slightly nudged so it's distinct).
+ new_cx = cx + 0.001 if abs(distance) < 0.01 else cx
+ new_cy = cy + 0.001 if abs(distance) < 0.01 else cy
+ center_pt = sketch.add_point(float(new_cx), float(new_cy))
+ sketch.add_circle(center_pt, float(new_radius))
+
+ # ── Auto-constrain: distance from parent center to new center ──
+ if parent_center_entity is not None and offset_distance > 0:
+ try:
+ sketch.constrain_distance(parent_center_entity, center_pt, offset_distance)
+ logger.info("Added distance constraint to offset circle center")
+ except Exception as exc:
+ logger.debug("circle distance constraint failed: %s", exc)
+
+ # Also offset any holes.
+ holes = face.get("holes", [])
+ for hole in holes:
+ if hole["type"] != "circle":
+ continue
+ h_cx, h_cy = hole["center"]
+ h_r = hole["radius"]
+ new_h_r = h_r - distance # holes shrink when outer grows
+ if new_h_r <= 0:
+ logger.debug("Hole circle offset collapsed — skipping")
+ continue
+ h_center = sketch.add_point(float(h_cx), float(h_cy))
+ sketch.add_circle(h_center, float(new_h_r))
+
+ logger.info(
+ "Created offset circle: center=(%.2f, %.2f), radius=%.2f",
+ new_cx, new_cy, new_radius,
+ )
+
# ─── Sketch-on-surface (face pick) ────────────────────────────────────
def _on_face_sketch_toggled(self, checked: bool) -> None:
@@ -4149,10 +5333,11 @@ class MainWindow(QMainWindow):
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.
+ # enable the show/hide toggle, ClrFace, and ToSketch buttons.
self._btn_underlay.setEnabled(True)
self._btn_underlay.setChecked(True)
self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.setEnabled(True)
def _on_underlay_toggled(self, checked: bool) -> None:
"""Show or hide the underlay construction lines in the 2D view.
@@ -4180,6 +5365,7 @@ class MainWindow(QMainWindow):
self._btn_underlay.setEnabled(False)
self._btn_underlay.setChecked(False)
self._btn_clr_face.setEnabled(False)
+ self._btn_to_sketch.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.
@@ -4188,6 +5374,23 @@ class MainWindow(QMainWindow):
"Source face cleared — underlay construction lines removed", 3000
)
+ def _on_convert_underlay_to_sketch(self) -> None:
+ """Convert the underlay/projected construction lines into real sketch geometry.
+
+ Delegates to the widget's ``_convert_underlay_to_sketch`` which
+ creates regular (non-construction, non-external) point and line
+ entities at every underlay position. The underlay reference stays
+ intact so the user can still toggle it on/off.
+ """
+ self._sketch_widget._convert_underlay_to_sketch()
+ # Sync the main window's underlay toggle to match the widget
+ # (the conversion auto-hides the underlay).
+ self._btn_underlay.setChecked(False)
+ self.statusBar().showMessage(
+ "Underlay converted to sketch geometry — now you can select faces, offset, and extrude",
+ 5000,
+ )
+
def _pattern_array_placeholder(self):
pass
@@ -4254,12 +5457,21 @@ class MainWindow(QMainWindow):
self._btn_underlay.setEnabled(True)
self._btn_underlay.setChecked(True)
self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.setEnabled(True)
+ elif getattr(sketch, "_source_workplane_id", None) is not None:
+ # Sketch on an independent workplane: project body outlines.
+ self._project_body_to_active_wp()
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.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._btn_to_sketch.setEnabled(False)
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
logger.info(f"Editing sketch: {name}")
@@ -4840,6 +6052,7 @@ class MainWindow(QMainWindow):
self._btn_underlay.setEnabled(False)
self._btn_underlay.setChecked(True)
self._btn_clr_face.setEnabled(False)
+ self._btn_to_sketch.setEnabled(False)
self._create_initial_component()
logger.info("New project created")
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index c3ccd9b..9ee5091 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
+@dataclass
+class Workplane:
+ """
+ An independent working plane (datum plane) not tied to a face.
+
+ Workplanes can be created at any time and serve as the foundation for
+ sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
+ They are visible in the 3D view as a semi-transparent reference grid.
+ """
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ name: str = "Untitled Workplane"
+
+ origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
+ normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
+ x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
+
+ # OCC AIS shape (visual plane) object id in the renderer
+ render_object: Any = None
+ visible: bool = True
+
+ created_at: datetime = field(default_factory=datetime.now)
+ modified_at: datetime = field(default_factory=datetime.now)
+
+ def __post_init__(self):
+ # Normalise normal and x_dir on construction.
+ import numpy as np
+ n = np.asarray(self.normal, dtype=float)
+ n = n / np.linalg.norm(n)
+ x = np.asarray(self.x_dir, dtype=float)
+ # Remove any component of x along n, then renormalise.
+ x = x - np.dot(x, n) * n
+ x_norm = np.linalg.norm(x)
+ if x_norm < 1e-9:
+ fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
+ x = fallback - np.dot(fallback, n) * n
+ x_norm = np.linalg.norm(x)
+ x = x / x_norm
+ y = np.cross(n, x)
+ y = y / np.linalg.norm(y)
+ self.normal = tuple(float(v) for v in n)
+ self.x_dir = tuple(float(v) for v in x)
+ self._y_dir = tuple(float(v) for v in y)
+
+ @property
+ def y_dir(self) -> Tuple[float, float, float]:
+ """Derived in-plane Y axis (normal × x_dir)."""
+ return self._y_dir
+
+ def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
+ """Map a UV point to 3D world coordinates on this plane."""
+ ox, oy, oz = self.origin
+ xx, xy, xz = self.x_dir
+ yx, yy, yz = self._y_dir
+ return (
+ ox + u * xx + v * yx,
+ oy + u * xy + v * yy,
+ oz + u * xz + v * yz,
+ )
+
+ def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
+ """Map a 3D world point to UV coordinates on this plane."""
+ import numpy as np
+ ox, oy, oz = self.origin
+ v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
+ xd = np.array(self.x_dir, dtype=float)
+ yd = np.array(self._y_dir, dtype=float)
+ return (float(np.dot(v, xd)), float(np.dot(v, yd)))
+
+
@dataclass
class Sketch:
"""
@@ -192,12 +262,34 @@ class Component:
sketches: Dict[str, Sketch] = field(default_factory=dict)
bodies: Dict[str, Body] = field(default_factory=dict)
+ workplanes: Dict[str, Workplane] = field(default_factory=dict)
active_sketch: Optional[str] = None
+ active_workplane: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
+ def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
+ """Add an independent workplane to the component."""
+ if workplane is None:
+ workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
+ self.workplanes[workplane.id] = workplane
+ if self.active_workplane is None:
+ self.active_workplane = workplane.id
+ self.modified_at = datetime.now()
+ return workplane
+
+ def remove_workplane(self, wp_id: str) -> bool:
+ """Remove a workplane from the component."""
+ if wp_id in self.workplanes:
+ del self.workplanes[wp_id]
+ if self.active_workplane == wp_id:
+ self.active_workplane = next(iter(self.workplanes.keys()), None)
+ self.modified_at = datetime.now()
+ return True
+ return False
+
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add a sketch to the component."""
if sketch is None:
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 6623ed5..4b1a1e1 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -687,6 +687,141 @@ class OCCRenderer(Renderer):
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
self._view.SetBackgroundColor(qcol)
+ # ─── Workplane visualization ────────────────────────────────────────
+
+ _WORKPLANE_BASE_ID: str = "__workplane__"
+ _WORKPLANE_GRID_SIZE: float = 200.0 # total extent of the visual plane
+
+ def show_workplane_plane(
+ self,
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ normal: Tuple[float, float, float] = (0, 0, 1),
+ x_dir: Tuple[float, float, float] = (1, 0, 0),
+ size: float = 200.0,
+ name: Optional[str] = None,
+ ) -> str:
+ """Display a semi-transparent planar grid at the workplane location.
+
+ The plane is a single large rectangular face with a grid of lines
+ drawn on it, rendered at low opacity so it does not obscure the
+ existing bodies. Returns an object ID that can be passed to
+ :meth:`remove_workplane_plane`.
+
+ If *name* is provided it is used as the object ID; otherwise a
+ unique one is generated. Passing an existing workplane ID will
+ replace that workplane visual in place.
+ """
+ import numpy as np
+ from OCP.gp import gp_Pnt, gp_Dir
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
+ from OCP.gp import gp_Pln
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+ from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
+
+ obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}"
+
+ # Compute orthonormal basis.
+ n = np.asarray(normal, dtype=float)
+ n = n / np.linalg.norm(n)
+ x = np.asarray(x_dir, dtype=float)
+ x = x - np.dot(x, n) * n
+ x_norm = np.linalg.norm(x)
+ if x_norm < 1e-9:
+ fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
+ x = fallback - np.dot(fallback, n) * n
+ x_norm = np.linalg.norm(x)
+ x = x / x_norm
+ y = np.cross(n, x)
+
+ hs = size / 2.0
+ # Four corners of the plane in local coords.
+ corners_3d = [
+ (
+ origin[0] + (-hs) * x[0] + (-hs) * y[0],
+ origin[1] + (-hs) * x[1] + (-hs) * y[1],
+ origin[2] + (-hs) * x[2] + (-hs) * y[2],
+ ),
+ (
+ origin[0] + (hs) * x[0] + (-hs) * y[0],
+ origin[1] + (hs) * x[1] + (-hs) * y[1],
+ origin[2] + (hs) * x[2] + (-hs) * y[2],
+ ),
+ (
+ origin[0] + (hs) * x[0] + (hs) * y[0],
+ origin[1] + (hs) * x[1] + (hs) * y[1],
+ origin[2] + (hs) * x[2] + (hs) * y[2],
+ ),
+ (
+ origin[0] + (-hs) * x[0] + (hs) * y[0],
+ origin[1] + (-hs) * x[1] + (hs) * y[1],
+ origin[2] + (-hs) * x[2] + (hs) * y[2],
+ ),
+ ]
+
+ # Build the planar face: point + normal direction.
+ pln = gp_Pln(
+ gp_Pnt(*origin),
+ gp_Dir(n[0], n[1], n[2]),
+ )
+ # Use gp_Pnt for the corners to make a bounded face.
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
+ mp = BRepBuilderAPI_MakePolygon()
+ for c in corners_3d:
+ mp.Add(gp_Pnt(*c))
+ mp.Close()
+ wire = mp.Wire()
+ face_maker = BRepBuilderAPI_MakeFace(pln, wire)
+ face = face_maker.Face()
+
+ # Check if a workplane with this id already exists and remove it.
+ existing = self._objects.get(obj_id)
+ if existing is not None:
+ self.remove_object(existing)
+
+ ais = AIS_Shape(face)
+ # Semi-transparent blue-ish tint.
+ ais.SetColor(Quantity_Color(0.55, 0.75, 0.95, Quantity_TOC_RGB)) # light blue
+ try:
+ ais.SetTransparency(0.75)
+ except Exception:
+ pass
+ ais.SetDisplayMode(1) # shaded
+
+ # Draw the plane with a slight polygon offset so it doesn't z-fight.
+ try:
+ ais.SetPolygonOffsets(3, 1.0, -1.0)
+ except Exception:
+ pass
+
+ self._context.Display(ais, True)
+ # Deactivate selection on the workplane plane so that face-picking
+ # (``pick_planar_face``) does NOT intercept the workplane's face
+ # when the user clicks through it to select a body face underneath.
+ # The workplane is decorative reference geometry — it must never
+ # be a target of face-pick mode.
+ try:
+ self._context.Deactivate(ais)
+ except Exception:
+ pass
+
+ robj = OCCRenderObject(
+ obj_id=obj_id,
+ ais_shape=ais,
+ ais_type="workplane",
+ color=RenderColor(0.55, 0.75, 0.95),
+ )
+ self._objects[obj_id] = robj
+ self._view.Redraw()
+ return obj_id
+
+ def remove_workplane_plane(self, obj_id: str) -> bool:
+ """Remove a workplane plane visual by its object ID."""
+ robj = self._objects.get(obj_id)
+ if robj is not None:
+ return self.remove_object(robj)
+ return False
+
def take_screenshot(self) -> bytes:
return b""