diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 10fc8ec..d71f00d 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,9 +6,20 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -78,7 +89,7 @@
"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",
+ "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
@@ -93,8 +104,8 @@
-
+
@@ -463,7 +474,15 @@
1785701099363
-
+
+
+ 1785915859137
+
+
+
+ 1785915859137
+
+
diff --git a/gui.ui b/gui.ui
index 5696c90..54dd469 100644
--- a/gui.ui
+++ b/gui.ui
@@ -1180,7 +1180,7 @@
-
- Arry
+ Array
@@ -1220,9 +1220,9 @@
-
-
+
- Phase
+ Chamfer
diff --git a/gui_ui.py b/gui_ui.py
index fc0d4d7..68af409 100644
--- a/gui_ui.py
+++ b/gui_ui.py
@@ -663,10 +663,10 @@ class Ui_fluencyCAD(object):
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
- self.pb_face_op = QPushButton(self.groupBox)
- self.pb_face_op.setObjectName(u"pb_face_op")
+ self.pb_chamfer = QPushButton(self.groupBox)
+ self.pb_chamfer.setObjectName(u"pb_chamfer")
- self.gridLayout_3.addWidget(self.pb_face_op, 3, 1, 1, 1)
+ self.gridLayout_3.addWidget(self.pb_chamfer, 3, 1, 1, 1)
self.pb_thread = QPushButton(self.groupBox)
self.pb_thread.setObjectName(u"pb_thread")
@@ -923,13 +923,13 @@ class Ui_fluencyCAD(object):
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
- self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
+ self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
- self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None))
+ self.pb_chamfer.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py
index 77fc37e..fdc5021 100644
--- a/src/fluency/geometry_occ/kernel.py
+++ b/src/fluency/geometry_occ/kernel.py
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the geometry kernel
using OCP (OpenCASCADE Python bindings).
"""
+import logging
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
@@ -15,6 +16,8 @@ from fluency.geometry.base import (
Point3D,
)
+logger = logging.getLogger(__name__)
+
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
@@ -480,10 +483,11 @@ class OCGeometryKernel(GeometryKernel):
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
+ from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
- chamfer.Add(size, explorer.Current())
+ chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
explorer.Next()
chamfer.Build()
@@ -576,6 +580,67 @@ class OCGeometryKernel(GeometryKernel):
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
+ def pattern(
+ self,
+ body: GeometryObject,
+ pattern_type: str = "linear",
+ count: int = 2,
+ direction: Tuple[float, float, float] = (1, 0, 0),
+ spacing: float = 10.0,
+ axis: Tuple[float, float, float] = (0, 0, 1),
+ origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
+ angle: float = 360.0,
+ ) -> GeometryObject:
+ """Repeat *body* in a linear or circular array (pattern).
+
+ Linear: *count* copies spaced *spacing* mm apart along
+ *direction* (a negative spacing reverses the direction).
+
+ Circular: *count* copies rotated evenly around *axis* passing
+ through *origin*, distributed over a total angular span of
+ *angle* degrees (step = angle / count). ``angle=360`` gives the
+ classic evenly-spaced full-circle bolt pattern.
+
+ Returns the union (compound when the copies don't touch) of the
+ original solid and all its copies — disjoint copies keep their
+ separate volumes inside one result object, touching copies fuse.
+ """
+ count = max(1, int(count))
+ if count <= 1:
+ return body
+
+ import math as _math
+
+ instances: list = [body]
+ if pattern_type == "circular":
+ # Normalize the rotation axis.
+ ax = float(axis[0]), float(axis[1]), float(axis[2])
+ norm = _math.sqrt(ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2])
+ if norm < 1e-12:
+ ax = (0.0, 0.0, 1.0)
+ else:
+ ax = (ax[0] / norm, ax[1] / norm, ax[2] / norm)
+ step = _math.radians(float(angle)) / count
+ for i in range(1, count):
+ instances.append(self.rotate(body, ax, step * i, origin))
+ else:
+ d = float(direction[0]), float(direction[1]), float(direction[2])
+ norm = _math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
+ if norm < 1e-12:
+ d = (1.0, 0.0, 0.0)
+ else:
+ d = (d[0] / norm, d[1] / norm, d[2] / norm)
+ step = float(spacing)
+ for i in range(1, count):
+ instances.append(
+ self.translate(
+ body,
+ (d[0] * step * i, d[1] * step * i, d[2] * step * i),
+ )
+ )
+
+ return self.boolean_union(*instances)
+
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
try:
@@ -868,3 +933,363 @@ class OCGeometryKernel(GeometryKernel):
cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z())
+
+ def create_thread(
+ self,
+ body: GeometryObject,
+ cylindrical_face: Any,
+ nominal_diameter: float,
+ pitch: float,
+ thread_length: Optional[float] = None,
+ internal: bool = False,
+ ) -> Optional[GeometryObject]:
+ """Cut (or add) an ISO metric thread on the cylindrical face of *body*.
+
+ The geometry is driven by the PICKED face's actual radius and axis
+ (``nominal_diameter`` is only metadata used for the feature record).
+ External threads cut the ISO groove trapezoid (7P/8 at the surface,
+ P/4 at the root, 5H/8 deep) out of the shaft; internal threads fuse
+ the ISO ridge trapezoid (3P/4 at the wall, P/8 crest) into the hole.
+ """
+ import math
+
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
+ from OCP.GeomAbs import GeomAbs_Cylinder
+ from OCP.TopoDS import TopoDS
+ from OCP.gp import gp_Pnt, gp_Pnt2d, gp_Dir2d
+ from OCP.BRepBuilderAPI import (
+ BRepBuilderAPI_MakeEdge,
+ BRepBuilderAPI_MakeWire,
+ )
+
+ # ── 1. Cylinder parameters from the picked face ─────────────────
+ try:
+ surf = BRepAdaptor_Surface(cylindrical_face)
+ except Exception:
+ try:
+ surf = BRepAdaptor_Surface(TopoDS.Face_s(cylindrical_face))
+ except Exception as exc:
+ logger.warning(f"create_thread: cannot adapt face: {exc}")
+ return None
+
+ if surf.GetType() != GeomAbs_Cylinder:
+ logger.warning("create_thread: face is not cylindrical")
+ return None
+
+ cyl = surf.Cylinder() # gp_Cylinder
+ radius = cyl.Radius() # ACTUAL picked radius
+ ax3 = cyl.Position() # gp_Ax3 (location, Z, X)
+ loc = ax3.Location()
+ zdir = ax3.Direction()
+ xdir = ax3.XDirection()
+ axis_origin = np.array([loc.X(), loc.Y(), loc.Z()])
+ axis_dir = np.array([zdir.X(), zdir.Y(), zdir.Z()])
+ axis_dir = axis_dir / np.linalg.norm(axis_dir)
+ axis_x = np.array([xdir.X(), xdir.Y(), xdir.Z()])
+ axis_x = axis_x / np.linalg.norm(axis_x)
+ axis_y = np.cross(axis_dir, axis_x)
+
+ u_start = surf.FirstUParameter() # angular start of face
+ v1, v2 = surf.FirstVParameter(), surf.LastVParameter()
+ v_lo, v_hi = min(v1, v2), max(v1, v2)
+ face_height = v_hi - v_lo
+
+ if not thread_length or thread_length <= 0:
+ thread_length = face_height
+ thread_length = min(thread_length, face_height)
+
+ num_turns = thread_length / pitch
+ if num_turns < 0.05:
+ logger.warning("create_thread: thread too short for one turn")
+ return None
+
+ # ── 2. ISO metric profile dimensions ────────────────────────────
+ # Basic profile (H = P·√3/2, thread engagement depth 5H/8):
+ # • external shaft: groove cut is a trapezoid 7P/8 wide at the
+ # surface narrowing to P/4 at the root.
+ # • internal hole: ridge fused onto the wall is a trapezoid 3P/4
+ # wide at the wall narrowing to P/8 at the inner crest, leaving
+ # the 7P/8-wide groove open at the bore.
+ H = pitch * math.sqrt(3.0) / 2.0
+ depth = (5.0 / 8.0) * H
+ overcut = max(0.1 * depth, 0.02) # overhang past the surface
+ if internal:
+ w_surf = 3.0 * pitch / 4.0
+ w_deep = pitch / 8.0
+ else:
+ w_surf = 7.0 * pitch / 8.0
+ w_deep = pitch / 4.0
+
+ # ── 3. Helix spine ON the picked cylinder's surface ─────────────
+ # The swept profile sits in the helix's normal plane, tilted by the
+ # lead angle; its end caps therefore stick out past the spine ends
+ # by roughly half the profile width along the axis. For a CUT that
+ # is harmless (the groove simply runs to the part edge), but a FUSE
+ # would leave the protruding cap as floating material outside the
+ # part, so inset the internal helix by exactly that amount.
+ lead = math.atan2(pitch, 2.0 * math.pi * radius)
+ cap_axial = (w_surf / 2.0) * math.cos(lead) # cap half-extent along axis
+
+ if internal:
+ v_start = v_lo + cap_axial
+ v_end = min(v_lo + thread_length, v_hi) - cap_axial
+ else:
+ # extend one pitch past each face end so the groove runs off
+ # the part edges cleanly
+ v_start = v_lo - pitch
+ v_end = min(v_lo + thread_length + pitch, v_hi + pitch)
+ thread_span = v_end - v_start
+ if thread_span < 0.5 * pitch:
+ logger.warning("create_thread: part too short for a thread")
+ return None
+ turns_ext = thread_span / pitch
+
+ spine_wire = None
+
+ # 3a. TRUE helix: a 2D straight line on the cylinder surface.
+ #
+ # NOTE 1: gp_Dir2d NORMALIZES its argument, so the 2D line
+ # parameter t advances the point by t·|(2π, pitch)| in (u, v)
+ # space — scale the trim range so t = n turns covers exactly
+ # n revolutions plus n·pitch of axial travel.
+ # NOTE 2: the edge from a pcurve has no 3D curve; the pipe sweep
+ # needs one, so force it with BRepLib.BuildCurves3d.
+ spine_wire = None
+ try:
+ from OCP.Geom import Geom_CylindricalSurface
+ from OCP.Geom2d import Geom2d_Line, Geom2d_TrimmedCurve
+ from OCP.BRepLib import BRepLib
+
+ dir_len = math.hypot(2.0 * math.pi, pitch)
+ cyl_surf = Geom_CylindricalSurface(cyl)
+ line2d = Geom2d_Line(
+ gp_Pnt2d(u_start, v_start), gp_Dir2d(2.0 * math.pi, pitch)
+ )
+ seg = Geom2d_TrimmedCurve(line2d, 0.0, turns_ext * dir_len)
+ helix_edge = BRepBuilderAPI_MakeEdge(seg, cyl_surf).Edge()
+ BRepLib.BuildCurves3d_s(helix_edge)
+ spine_wire = BRepBuilderAPI_MakeWire(helix_edge).Wire()
+ logger.info("create_thread: using exact helix spine")
+ except Exception as exc:
+ logger.info(f"create_thread: exact helix failed ({exc})")
+
+ # 3b. Fallback: smooth BSpline through sampled helix points
+ # (only if the exact construction is unavailable).
+ if spine_wire is None:
+ try:
+ from OCP.GeomAPI import GeomAPI_PointsToBSpline
+ from OCP.TColgp import TColgp_Array1OfPnt
+ from OCP.GeomAbs import GeomAbs_C2
+
+ pts_per_turn = 96
+ n_total = max(int(turns_ext * pts_per_turn) + 1, 2)
+ arr = TColgp_Array1OfPnt(1, n_total)
+ for i in range(1, n_total + 1):
+ u = u_start + ((i - 1) / pts_per_turn) * 2.0 * math.pi
+ v = v_start + ((i - 1) / pts_per_turn) * pitch
+ p = (
+ axis_origin
+ + radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
+ + v * axis_dir
+ )
+ arr.SetValue(i, gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
+ bspline = GeomAPI_PointsToBSpline(arr, 3, 8, GeomAbs_C2, 1e-5)
+ bs_edge = BRepBuilderAPI_MakeEdge(bspline.Curve()).Edge()
+ spine_wire = BRepBuilderAPI_MakeWire(bs_edge).Wire()
+ logger.info("create_thread: using BSpline helix fallback")
+ except Exception as exc:
+ logger.warning(f"create_thread: BSpline helix failed ({exc})")
+
+ if spine_wire is None:
+ logger.warning("create_thread: no usable helix spine")
+ return None
+
+ # Start frame (same for both spine types — computed analytically).
+ def _cyl_pt(u: float, v: float) -> np.ndarray:
+ return (
+ axis_origin
+ + radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
+ + v * axis_dir
+ )
+
+ start_S = _cyl_pt(u_start, v_start)
+ start_T = (
+ 2.0 * math.pi * radius
+ * (-math.sin(u_start) * axis_x + math.cos(u_start) * axis_y)
+ + pitch * axis_dir
+ )
+ start_T = start_T / np.linalg.norm(start_T)
+ start_R = math.cos(u_start) * axis_x + math.sin(u_start) * axis_y # outward
+
+ # Profile width direction: perpendicular to tangent in the surface
+ # plane (≈ axial direction). Trapezoid is symmetric so sign is fine.
+ binormal = np.cross(start_T, start_R)
+ binormal = binormal / np.linalg.norm(binormal)
+
+ # ── 4. Trapezoidal profile at the spine start ───────────────────
+ # Built directly in world coords: base sits *overcut* OUTSIDE the
+ # surface so the boolean fuses/cuts cleanly across it; the working
+ # end reaches *depth* INSIDE the surface.
+ def _mk(b: float, r: float) -> gp_Pnt:
+ p = start_S + b * binormal + r * start_R
+ return gp_Pnt(float(p[0]), float(p[1]), float(p[2]))
+
+ p0 = _mk(-w_surf / 2.0, overcut)
+ p1 = _mk(-w_deep / 2.0, -depth)
+ p2 = _mk(+w_deep / 2.0, -depth)
+ p3 = _mk(+w_surf / 2.0, overcut)
+
+ prof_wb = BRepBuilderAPI_MakeWire()
+ for a, b in ((p0, p1), (p1, p2), (p2, p3), (p3, p0)):
+ prof_wb.Add(BRepBuilderAPI_MakeEdge(a, b).Edge())
+ profile_wire = prof_wb.Wire()
+
+ # ── 5. Sweep the profile along the helix ────────────────────────
+ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
+
+ try:
+ pipe = BRepOffsetAPI_MakePipeShell(spine_wire)
+ pipe.SetMode(True) # Frenet frame
+ pipe.Add(profile_wire, False, False)
+ pipe.Build()
+ if not pipe.IsDone():
+ logger.warning("create_thread: pipe sweep failed")
+ return None
+ solid_ok = False
+ try:
+ solid_ok = bool(pipe.MakeSolid()) # cap the tube ends
+ except Exception as exc:
+ logger.info(f"create_thread: MakeSolid unavailable ({exc})")
+ tool_shape = pipe.Shape()
+ if not solid_ok:
+ logger.warning("create_thread: sweep is not a solid")
+ except Exception as exc:
+ logger.warning(f"create_thread: sweep failed: {exc}")
+ return None
+
+ # ── 6. Boolean cut (shaft) or fuse (hole) ───────────────────────
+ body_shape = self._get_shape(body)
+ if body_shape is None:
+ logger.warning("create_thread: body has no shape")
+ return None
+
+ tool = OCCGeometryObject(tool_shape)
+ vol_before = self.get_volume(body)
+
+ if internal:
+ result = self.boolean_union(body, tool)
+ else:
+ result = self.boolean_difference(body, tool)
+
+ if result is None or self._get_shape(result) is None:
+ logger.warning("create_thread: boolean op produced no shape")
+ return None
+
+ try:
+ vol_after = self.get_volume(result)
+ except Exception:
+ vol_after = -1.0
+
+ if internal and vol_after <= vol_before:
+ logger.warning(
+ f"create_thread: fuse did not add volume "
+ f"({vol_before:.4f} → {vol_after:.4f}) — tool missed the body?"
+ )
+ return None
+ if not internal and vol_after >= vol_before:
+ logger.warning(
+ f"create_thread: cut did not remove volume "
+ f"({vol_before:.4f} → {vol_after:.4f}) — tool missed the body?"
+ )
+ return None
+
+ logger.info(
+ f"create_thread: {'internal' if internal else 'external'} thread OK, "
+ f"volume {vol_before:.4f} → {vol_after:.4f}"
+ )
+ return result
+
+ def detect_cylindrical_face(
+ self,
+ face: Any,
+ ) -> Optional[Dict[str, Any]]:
+ """Check if *face* is cylindrical and return its parameters.
+
+ The *face* can be a ``TopoDS_Face`` (from the picker) or a
+ ``TopoDS_Shape`` that contains a face. We try several paths to
+ extract the underlying cylindrical surface.
+
+ Returns a dict with keys ``radius``, ``axis_origin``, ``axis_dir``,
+ ``height``, or *None* if the face isn't cylindrical.
+ """
+ import logging
+ import numpy as np
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
+ from OCP.GeomAbs import GeomAbs_Cylinder
+ from OCP.TopoDS import TopoDS
+
+ _log = logging.getLogger(__name__)
+
+ # ── Resolve the actual face from whatever the caller handed us ──
+ actual_face: Any = None
+
+ # Try direct BRepAdaptor_Surface first — the picker already returns
+ # a valid TopoDS_Face, and calling TopoDS.Face_s() again on an
+ # already-downcast face can fail in some OCP versions.
+ try:
+ surf = BRepAdaptor_Surface(face)
+ surf_type_test = surf.GetType()
+ actual_face = face
+ except Exception:
+ pass
+
+ if actual_face is None:
+ # Fallback: try the explicit TopoDS.Face_s downcast path.
+ try:
+ candidate = TopoDS.Face_s(face)
+ _ = BRepAdaptor_Surface(candidate)
+ actual_face = candidate
+ except Exception:
+ pass
+
+ if actual_face is None:
+ _log.warning("detect_cylindrical_face: could not resolve face from pick result")
+ return None
+
+ # ── Probe the surface type ──
+ try:
+ surf = BRepAdaptor_Surface(actual_face)
+ surf_type = surf.GetType()
+ if surf_type != GeomAbs_Cylinder:
+ type_names = {
+ 0: "Plane", 1: "Cylinder", 2: "Cone", 3: "Sphere",
+ 4: "Torus", 5: "Bezier", 6: "BSpline", 7: "Revolution",
+ 8: "Extrusion", 9: "Offset", 10: "Other",
+ }
+ type_name = type_names.get(int(surf_type), f"Unknown({int(surf_type)})")
+ _log.warning(
+ f"detect_cylindrical_face: face is {type_name}, not a Cylinder"
+ )
+ return None
+
+ cyl = surf.Cylinder()
+ radius = cyl.Radius()
+ axis = cyl.Axis()
+ origin = axis.Location()
+ direction = axis.Direction()
+ # BRepAdaptor_Surface uses FirstUParameter/LastUParameter etc.
+ u1 = surf.FirstUParameter()
+ u2 = surf.LastUParameter()
+ v1 = surf.FirstVParameter()
+ v2 = surf.LastVParameter()
+ height = abs(v2 - v1)
+ return {
+ "radius": radius,
+ "diameter": 2.0 * radius,
+ "axis_origin": (origin.X(), origin.Y(), origin.Z()),
+ "axis_dir": (direction.X(), direction.Y(), direction.Z()),
+ "height": height,
+ }
+ except Exception as exc:
+ _log.warning(f"detect_cylindrical_face: surface probe failed: {exc}")
+ return None
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index cadb9cd..7f31489 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -1779,6 +1779,39 @@ class OCCSketch(SketchInterface):
return points
+ def get_line_axis(self, line_id: int) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
+ """Return ``((origin_x, origin_y, origin_z), (dir_x, dir_y, dir_z))`` for a line.
+
+ The axis is computed from the line's solved endpoints mapped into world
+ coordinates on the sketch workplane: origin = line start, direction =
+ normalized start→end. Returns ``None`` if the id is missing, not a
+ line, or degenerate. Used by the revolve tool and feature replay so
+ the revolve axis tracks the sketch line even after it is dragged.
+ """
+ import math
+
+ ent = self._entities.get(line_id)
+ if ent is None or ent.entity_type != "line" or ent.is_external:
+ return None
+ geom = ent.geometry
+ if not geom or len(geom) != 2 or not geom[0] or not geom[1]:
+ return None
+ try:
+ start = self._uv_to_world(*geom[0])
+ end = self._uv_to_world(*geom[1])
+ except Exception:
+ return None
+ dx = end.X() - start.X()
+ dy = end.Y() - start.Y()
+ dz = end.Z() - start.Z()
+ length = math.sqrt(dx * dx + dy * dy + dz * dz)
+ if length < 1e-9:
+ return None
+ return (
+ (float(start.X()), float(start.Y()), float(start.Z())),
+ (dx / length, dy / length, dz / length),
+ )
+
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines (uses solved positions).
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index 4f21ef4..9357b6f 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -219,10 +219,17 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"cut_all_bodies": bool(feat.cut_all_bodies),
"face_index": feat.face_index,
"angle": _to_float(feat.angle, 360.0),
+ "axis": [float(v) for v in (feat.axis or (0, 0, 1))],
+ "origin": [float(v) for v in (feat.origin or (0.0, 0.0, 0.0))],
+ "axis_line_id": feat.axis_line_id,
"radius": feat.radius,
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
+ "pattern_type": feat.pattern_type,
+ "count": feat.count,
+ "spacing": feat.spacing,
+ "direction": [float(v) for v in (feat.direction or (1, 0, 0))],
}
@@ -239,10 +246,17 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
face_index=data.get("face_index"),
angle=_to_float(data.get("angle"), 360.0),
+ axis=tuple(float(v) for v in (data.get("axis") or (0, 0, 1))),
+ origin=tuple(float(v) for v in (data.get("origin") or (0.0, 0.0, 0.0))),
+ axis_line_id=data.get("axis_line_id"),
radius=data.get("radius"),
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
+ pattern_type=data.get("pattern_type", "linear"),
+ count=int(data.get("count") or 2),
+ spacing=_to_float(data.get("spacing"), 10.0),
+ direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
)
sid = data.get("sketch_id")
if sid and sid in sketches:
diff --git a/src/fluency/main.py b/src/fluency/main.py
index 53b64b0..fbf7437 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -24,6 +24,7 @@ from fluency.ui.dialogs import (
ExtrudeDialog,
OffsetDialog,
RevolveDialog,
+ ThreadDialog,
WorkplaneOrientationDialog,
)
from fluency.ui.main_window import MainWindow
@@ -37,6 +38,7 @@ __all__ = [
"ExtrudeDialog",
"RevolveDialog",
"OffsetDialog",
+ "ThreadDialog",
"WorkplaneOrientationDialog",
"main",
]
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 1f95403..0d23f45 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -226,6 +226,17 @@ class Feature:
- "fillet": round a set of edges of the running geometry
(``radius``, ``tangent_propagation``, ``scope``,
``edge_refs`` — see below)
+ - "chamfer": bevel a set of edges of the running geometry
+ (same fields as "fillet"; ``radius`` holds the
+ chamfer size)
+ - "array" / "pattern": repeat the running solid in a linear or
+ circular array. ``pattern_type`` is "linear" or
+ "circular"; ``count`` is the total number of items
+ (original + copies). Linear uses ``direction``
+ (unit vector) and ``spacing`` (mm between adjacent
+ items); circular reuses ``axis`` + ``origin`` for the
+ rotation axis and ``angle`` for the total angular
+ span in degrees (copies evenly distributed).
- "base": frozen geometry snapshot (``geometry`` field) — used
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
@@ -242,20 +253,34 @@ class Feature:
cut_all_bodies: bool = False
face_index: Optional[int] = None # which sketch face was selected
angle: float = 360.0 # revolve only (degrees)
+ axis: Tuple[float, float, float] = (0, 0, 1) # revolve only: unit axis vector
+ origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) # revolve only: axis point
+ axis_line_id: Optional[int] = None # revolve only: sketch line entity used as axis
# "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None
- # "fillet" features only: radius (mm) of the round, whether the fillet
- # should extend along edges tangent to the picked ones, the edge scope
- # ("selected" = edges between the two picked faces, "all" = every edge
- # of the body), and stable fingerprints of the selected edges so the
- # replay can re-find them after the base geometry is rebuilt.
+ # "fillet" / "chamfer" features only: radius (mm) of the round or the
+ # chamfer size, whether the op should extend along edges tangent to the
+ # picked ones, the edge scope ("selected" = edges between the two
+ # picked faces, "all" = every edge of the body), and stable
+ # fingerprints of the selected edges so the replay can re-find them
+ # after the base geometry is rebuilt.
radius: Optional[float] = None
tangent_propagation: bool = False
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
+ # "array" / "pattern" features only: repeat the running solid.
+ # ``pattern_type`` is "linear" or "circular"; ``count`` is the total
+ # number of items including the original. Linear arrays use
+ # ``direction`` (unit vector) and ``spacing`` (mm); circular arrays
+ # reuse ``axis`` / ``origin`` / ``angle`` (total angular span, deg).
+ pattern_type: str = "linear"
+ count: int = 2
+ spacing: float = 10.0
+ direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
+
created_at: datetime = field(default_factory=datetime.now)
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 8df1adb..335a17a 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -97,6 +97,35 @@ def _compute_viewport_aligned_xdir(
return (1.0, 0.0, 0.0)
+def _dist_point_segment_sq(px: float, py: float, a: Tuple[float, float], b: Tuple[float, float]) -> float:
+ """Squared distance from point (px, py) to segment a→b in 2D screen space."""
+ ax, ay = a
+ bx, by = b
+ dx = bx - ax
+ dy = by - ay
+ if dx == 0.0 and dy == 0.0:
+ return (px - ax) ** 2 + (py - ay) ** 2
+ t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
+ t = max(0.0, min(1.0, t))
+ cx = ax + t * dx
+ cy = ay + t * dy
+ return (px - cx) ** 2 + (py - cy) ** 2
+
+
+def _point_in_poly(px: float, py: float, poly: List[Tuple[float, float]]) -> bool:
+ """Ray-casting point-in-polygon test for a 2D polygon (list of (x, y))."""
+ inside = False
+ n = len(poly)
+ j = n - 1
+ for i in range(n):
+ xi, yi = poly[i]
+ xj, yj = poly[j]
+ if (yi > py) != (yj > py) and px < (xj - xi) * (py - yi) / (yj - yi + 1e-30) + xi:
+ inside = not inside
+ j = i
+ return inside
+
+
@dataclass
class OCCRenderObject(RenderObject):
"""Internal object state for the OCC renderer."""
@@ -133,6 +162,12 @@ class OCCRenderer(Renderer):
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
self._gizmo_objects: Dict[str, Any] = {}
+ # World-anchored sketch reference gizmo (a triad at the sketch
+ # midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
+ # dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
+ self._sketch_gizmo_parts: Dict[str, Any] = {}
+ # Part kind currently highlighted on hover (for restore-on-leave).
+ self._sketch_gizmo_highlighted: Optional[str] = None
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -574,6 +609,8 @@ class OCCRenderer(Renderer):
self.clear_preview()
self.clear_face_highlight()
self.clear_entity_gizmo()
+ # The sketch reference gizmo is scene-anchored — drop it with the rest.
+ self.remove_sketch_gizmo()
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
# path; fall back to iterating the displayed list if unavailable.
try:
@@ -784,6 +821,59 @@ class OCCRenderer(Renderer):
self._view.FitAll(margin)
self._view.ZFitAll()
+ def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
+ """Fit the camera to a specific bounding box (``Bnd_Box``).
+
+ Used e.g. by the array tool: as the dialog opens, the camera
+ frames exactly the space the array copies will occupy (rather
+ than the whole scene). Falls back to a no-op when the view is
+ not initialised or the box is empty.
+ """
+ if self._view is None or bnd_box is None:
+ return
+ try:
+ if bnd_box.IsVoid():
+ return
+ margin = max(0.0, min(padding, 0.99))
+ self._view.FitAll(bnd_box, margin)
+ self._view.ZFitAll()
+ self._view.Redraw()
+ except Exception:
+ logger.warning("fit_camera_to_box failed", exc_info=True)
+
+ def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
+ """True when all 8 corners of *bnd_box* project inside the viewport.
+
+ Used by the array dialog live preview: when the user grows the
+ pattern (more repeats / bigger spacing) the copies can slide out
+ of view, and the host re-fits the camera. Unknown / unprojectable
+ corners count as NOT visible so the host refits.
+ """
+ if self._view is None or bnd_box is None or bnd_box.IsVoid():
+ return True
+ xmin, ymin, zmin, xmax, ymax, zmax = bnd_box.Get()
+ w, h = self.get_screen_size()
+ if w <= 0 or h <= 0:
+ return True
+ pad = margin * min(w, h)
+ corners = (
+ (xmin, ymin, zmin),
+ (xmax, ymin, zmin),
+ (xmin, ymax, zmin),
+ (xmax, ymax, zmin),
+ (xmin, ymin, zmax),
+ (xmax, ymin, zmax),
+ (xmin, ymax, zmax),
+ (xmax, ymax, zmax),
+ )
+ for cx, cy, cz in corners:
+ px = self._project_to_screen((float(cx), float(cy), float(cz)))
+ if px is None:
+ return False
+ if px[0] < -pad or px[0] > w + pad or px[1] < -pad or px[1] > h + pad:
+ return False
+ return True
+
def set_view_orientation(self, orientation: str = "iso") -> None:
"""Snap the camera to a standard CAD view.
@@ -1792,6 +1882,341 @@ class OCCRenderer(Renderer):
if had_any and self._view is not None:
self._view.Update()
+ # ─── World Sketch Reference Gizmo (triad at the sketch midpoint) ────────
+ #
+ # A selectable X/Y/Z triad rendered IN the 3D world at the midpoint of
+ # the active sketch (in sync with the sketch's workplane frame). Parts:
+ # • center — small white sphere at the triad origin
+ # • axis_x / axis_y / axis_z — coloured shaft + arrow cone
+ # • plane_xy / plane_yz / plane_xz — translucent squares
+ # Picking is done geometrically in screen space (no OCC selection system
+ # involvement), so it works even while connector/assembly modes have the
+ # selection modes deactivated.
+
+ # Base colour per part kind (plane colours double as the boundary colour).
+ _SKETCH_GIZMO_COLORS: Dict[str, Tuple[float, float, float]] = {
+ "center": (1.0, 1.0, 1.0),
+ "axis_x": (0.95, 0.25, 0.22),
+ "axis_y": (0.25, 0.85, 0.30),
+ "axis_z": (0.25, 0.45, 1.0),
+ "plane_xy": (0.55, 0.85, 1.0),
+ "plane_yz": (1.0, 0.62, 0.80),
+ "plane_xz": (0.68, 1.0, 0.70),
+ }
+ _SKETCH_GIZMO_HIGHLIGHT: Tuple[float, float, float] = (1.0, 1.0, 0.30)
+
+ def show_sketch_gizmo(
+ self,
+ origin: Tuple[float, float, float],
+ normal: Tuple[float, float, float],
+ x_dir: Tuple[float, float, float],
+ size: float = 40.0,
+ ) -> None:
+ """Display the world-space reference triad at *origin*.
+
+ *origin* is typically the midpoint of the active sketch's geometry;
+ the triad is aligned to the sketch's workplane frame (x_dir → red
+ X axis, normal → blue Z axis, normal × x_dir → green Y axis).
+ *size* is the axis shaft length in world units (the caller should
+ size it relative to the sketch, e.g. a fraction of the sketch
+ diagonal, so it stays proportional to the model at any zoom).
+ Replaces any previously shown triad.
+ """
+ self.remove_sketch_gizmo()
+ if self._context is None or self._view is None:
+ return
+
+ import numpy as np
+ from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
+ from OCP.BRepBuilderAPI import (
+ BRepBuilderAPI_MakeEdge,
+ BRepBuilderAPI_MakePolygon,
+ BRepBuilderAPI_MakeFace,
+ )
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeCone
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+
+ # Orthonormal frame.
+ n = np.asarray(normal, dtype=float)
+ nn = np.linalg.norm(n)
+ if nn < 1e-9:
+ n = np.array([0.0, 0.0, 1.0])
+ else:
+ n = n / nn
+ u = np.asarray(x_dir, dtype=float)
+ u = u - np.dot(u, n) * n
+ un = np.linalg.norm(u)
+ if un < 1e-9:
+ fb = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
+ u = fb - np.dot(fb, n) * n
+ un = np.linalg.norm(u)
+ u = u / un
+ v = np.cross(n, u)
+ o = np.asarray(origin, dtype=float)
+
+ # World-anchored size: proportional to the sketch, zooms with it.
+ L = float(size)
+ if L < 1e-6:
+ return
+
+ def _vec(a) -> Tuple[float, float, float]:
+ return (float(a[0]), float(a[1]), float(a[2]))
+
+ def _ais(shape, color, transparency=None, display_mode=1) -> Any:
+ ais = AIS_Shape(shape)
+ ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
+ if transparency is not None:
+ ais.SetTransparency(transparency)
+ ais.SetDisplayMode(display_mode)
+ self._context.Display(ais, True)
+ return ais
+
+ def _add_axis(kind: str, direction, color) -> None:
+ d = direction
+ tip = o + L * d
+ shaft = _ais(
+ BRepBuilderAPI_MakeEdge(gp_Pnt(*_vec(o)), gp_Pnt(*_vec(tip))).Edge(),
+ color,
+ display_mode=0,
+ )
+ # Arrow cone with apex at the tip.
+ cone_h = 0.22 * L
+ cone_r = 0.09 * L
+ base = tip - cone_h * d
+ ax2 = gp_Ax2(gp_Pnt(*_vec(base)), gp_Dir(*_vec(d)))
+ cone = _ais(
+ BRepPrimAPI_MakeCone(ax2, cone_r, 0.0, cone_h).Shape(),
+ color,
+ display_mode=1,
+ )
+ self._sketch_gizmo_parts[kind] = {
+ "ais": [shaft, cone],
+ "color": color,
+ "pick": {"type": "segment", "start": _vec(o), "end": _vec(tip)},
+ }
+
+ _add_axis("axis_x", u, self._SKETCH_GIZMO_COLORS["axis_x"])
+ _add_axis("axis_y", v, self._SKETCH_GIZMO_COLORS["axis_y"])
+ _add_axis("axis_z", n, self._SKETCH_GIZMO_COLORS["axis_z"])
+
+ # Center sphere.
+ center_color = self._SKETCH_GIZMO_COLORS["center"]
+ center_ais = _ais(
+ BRepPrimAPI_MakeSphere(gp_Pnt(*_vec(o)), 0.12 * L).Shape(),
+ center_color,
+ display_mode=1,
+ )
+ self._sketch_gizmo_parts["center"] = {
+ "ais": [center_ais],
+ "color": center_color,
+ "pick": {"type": "point", "pos": _vec(o)},
+ }
+
+ def _add_plane(kind: str, normal_dir, a_dir, b_dir, color) -> None:
+ """Translucent square in the plane (a_dir, b_dir), nudged along its
+ normal so it sits slightly off the origin; spans [0.4L, 0.7L]."""
+ offset = 0.03 * L * normal_dir
+ corners = []
+ for sa in (0.40, 0.70):
+ for sb in (0.40, 0.70):
+ corners.append(o + offset + sa * L * a_dir + sb * L * b_dir)
+ # CCW order when viewed from +normal_dir (so the face faces out).
+ ordered = [corners[0], corners[1], corners[3], corners[2]]
+ poly = BRepBuilderAPI_MakePolygon()
+ for c in ordered:
+ poly.Add(gp_Pnt(*_vec(c)))
+ poly.Close()
+ wire = poly.Wire()
+ face_ais = _ais(
+ BRepBuilderAPI_MakeFace(wire, True).Face(),
+ color,
+ transparency=0.62,
+ display_mode=1,
+ )
+ wire_ais = _ais(wire, color, display_mode=0)
+ center_p = o + offset + 0.55 * L * a_dir + 0.55 * L * b_dir
+ self._sketch_gizmo_parts[kind] = {
+ "ais": [face_ais, wire_ais],
+ "color": color,
+ "pick": {
+ "type": "quad",
+ "corners": [_vec(c) for c in ordered],
+ "center": _vec(center_p),
+ "normal": _vec(normal_dir),
+ },
+ }
+
+ _add_plane("plane_xy", n, u, v, self._SKETCH_GIZMO_COLORS["plane_xy"])
+ _add_plane("plane_yz", u, v, n, self._SKETCH_GIZMO_COLORS["plane_yz"])
+ _add_plane("plane_xz", v, u, n, self._SKETCH_GIZMO_COLORS["plane_xz"])
+
+ self._view.Update()
+
+ def update_sketch_gizmo(
+ self,
+ origin: Tuple[float, float, float],
+ normal: Tuple[float, float, float],
+ x_dir: Tuple[float, float, float],
+ size: float = 40.0,
+ ) -> None:
+ """Rebuild the triad in place — used when the sketch midpoint moves."""
+ self.show_sketch_gizmo(origin, normal, x_dir, size)
+
+ def remove_sketch_gizmo(self) -> None:
+ """Remove the world sketch triad, if any."""
+ if self._context is not None:
+ for part in self._sketch_gizmo_parts.values():
+ for ais in part.get("ais", []):
+ try:
+ self._context.Remove(ais, True)
+ except Exception:
+ pass
+ had = bool(self._sketch_gizmo_parts)
+ self._sketch_gizmo_parts.clear()
+ self._sketch_gizmo_highlighted = None
+ if had and self._view is not None:
+ self._view.Update()
+
+ def pick_sketch_gizmo(
+ self, x: int, y: int, tolerance: float = 18.0
+ ) -> Optional[str]:
+ """Return the gizmo part under screen pixel (x, y), or None.
+
+ Nearest-part hit test in screen space (the centre sphere wins when
+ the cursor is close to the triad origin). Returns a kind string:
+ "center", "axis_x", "axis_y", "axis_z", "plane_xy", "plane_yz"
+ or "plane_xz".
+ """
+ if not self._sketch_gizmo_parts or self._view is None:
+ return None
+ tol = float(tolerance)
+
+ # Centre point takes priority when the cursor is near the origin.
+ center = self._sketch_gizmo_parts.get("center")
+ if center is not None:
+ pick = center.get("pick")
+ if pick is not None and pick.get("type") == "point":
+ sp = self._project_to_screen(pick["pos"])
+ if sp is not None:
+ d2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
+ if d2 <= (tol * 0.8) ** 2:
+ return "center"
+
+ best: Optional[str] = None
+ best_d2 = tol * tol
+ for kind, part in self._sketch_gizmo_parts.items():
+ if kind == "center":
+ continue
+ pick = part.get("pick")
+ if not pick:
+ continue
+ ptype = pick.get("type")
+ if ptype == "segment":
+ s = self._project_to_screen(pick["start"])
+ e = self._project_to_screen(pick["end"])
+ if s is None or e is None:
+ continue
+ d2 = _dist_point_segment_sq(x, y, s, e)
+ elif ptype == "quad":
+ pts = []
+ ok = True
+ for c in pick["corners"]:
+ sp = self._project_to_screen(c)
+ if sp is None:
+ ok = False
+ break
+ pts.append(sp)
+ if not ok:
+ continue
+ if _point_in_poly(x, y, pts):
+ d2 = 0.0
+ else:
+ d2 = min(
+ _dist_point_segment_sq(x, y, pts[i], pts[(i + 1) % 4])
+ for i in range(4)
+ )
+ else:
+ continue
+ if d2 <= best_d2:
+ best_d2 = d2
+ best = kind
+ return best
+
+ def sketch_gizmo_pick_info(self, kind: str) -> Optional[Dict[str, Any]]:
+ """World-space pick metadata for a part kind (position + direction).
+
+ For axes the direction is the axis unit vector; for planes it is the
+ plane normal; for the centre it is the zero vector. Position is the
+ triad origin for axes/centre and the quad centre for planes.
+ """
+ part = self._sketch_gizmo_parts.get(kind)
+ if part is None:
+ return None
+ pick = part.get("pick") or {}
+ ptype = pick.get("type")
+ if ptype == "point":
+ return {"position": pick["pos"], "direction": (0.0, 0.0, 0.0)}
+ if ptype == "segment":
+ s = np.asarray(pick["start"], dtype=float)
+ e = np.asarray(pick["end"], dtype=float)
+ d = e - s
+ norm = np.linalg.norm(d)
+ if norm < 1e-9:
+ direction = (0.0, 0.0, 0.0)
+ else:
+ d = d / norm
+ direction = (float(d[0]), float(d[1]), float(d[2]))
+ return {"position": pick["start"], "direction": direction}
+ if ptype == "quad":
+ return {
+ "position": pick["center"],
+ "direction": pick.get("normal") or (0.0, 0.0, 1.0),
+ }
+ return None
+
+ def highlight_sketch_gizmo_part(self, kind: str) -> None:
+ """Tint a gizmo part yellow to show it is under the cursor."""
+ if kind not in self._sketch_gizmo_parts:
+ self.clear_sketch_gizmo_highlight()
+ return
+ if self._sketch_gizmo_highlighted == kind:
+ return
+ self.clear_sketch_gizmo_highlight()
+ part = self._sketch_gizmo_parts[kind]
+ if self._context is None:
+ return
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+
+ for ais in part.get("ais", []):
+ try:
+ ais.SetColor(Quantity_Color(*self._SKETCH_GIZMO_HIGHLIGHT, Quantity_TOC_RGB))
+ self._context.Redisplay(ais, True)
+ except Exception:
+ pass
+ self._sketch_gizmo_highlighted = kind
+ if self._view is not None:
+ self._view.Update()
+
+ def clear_sketch_gizmo_highlight(self) -> None:
+ """Restore every gizmo part to its base colour."""
+ if self._context is None:
+ self._sketch_gizmo_highlighted = None
+ return
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+
+ changed = self._sketch_gizmo_highlighted is not None
+ for part in self._sketch_gizmo_parts.values():
+ for ais in part.get("ais", []):
+ try:
+ ais.SetColor(Quantity_Color(*part["color"], Quantity_TOC_RGB))
+ self._context.Redisplay(ais, True)
+ except Exception:
+ pass
+ self._sketch_gizmo_highlighted = None
+ if changed and self._view is not None:
+ self._view.Update()
+
def show_entity_gizmo(
self,
entity_type: str,
diff --git a/src/fluency/tests/test_project_io.py b/src/fluency/tests/test_project_io.py
index cc0cdc6..b3b8437 100644
--- a/src/fluency/tests/test_project_io.py
+++ b/src/fluency/tests/test_project_io.py
@@ -13,16 +13,58 @@ import unittest
# Allow running this file directly: ``python tests/test_project_io.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
-from fluency.io.project_io import save_project, load_project
+from fluency.io.project_io import save_project, load_project, _feature_to_dict, _feature_from_dict
from fluency.models.data_model import (
Project,
Component,
Body,
+ Sketch,
Workplane,
Assembly,
+ Feature,
)
+class TestRevolveAxisSerialization(unittest.TestCase):
+ """Revolve features persist their revolve axis across save/load."""
+
+ def test_axis_round_trip(self):
+ sk = Sketch()
+ feat = Feature(
+ operation="revolve",
+ angle=180.0,
+ axis=(1, 0, 0),
+ origin=(10.0, 5.0, 0.0),
+ axis_line_id=7,
+ )
+ feat.sketch = sk
+ data = _feature_to_dict(feat)
+ self.assertEqual(data["axis"], [1.0, 0.0, 0.0])
+ self.assertEqual(data["origin"], [10.0, 5.0, 0.0])
+ self.assertEqual(data["axis_line_id"], 7)
+ restored = _feature_from_dict(data, {sk.id: sk})
+ self.assertEqual(tuple(restored.axis), (1.0, 0.0, 0.0))
+ self.assertEqual(tuple(restored.origin), (10.0, 5.0, 0.0))
+ self.assertEqual(restored.axis_line_id, 7)
+ self.assertEqual(restored.angle, 180.0)
+
+ def test_default_axis(self):
+ """Old files without axis fields fall back to Z axis at the origin."""
+ sk = Sketch()
+ data = _feature_to_dict(Feature(operation="revolve", angle=90.0))
+ restored = _feature_from_dict(data, {sk.id: sk})
+ self.assertEqual(tuple(restored.axis), (0, 0, 1))
+ self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
+ self.assertIsNone(restored.axis_line_id)
+ # legacy file without the keys
+ del data["axis"]
+ del data["origin"]
+ del data["axis_line_id"]
+ restored = _feature_from_dict(data, {sk.id: sk})
+ self.assertEqual(tuple(restored.axis), (0, 0, 1))
+ self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
+
+
class TestProjectIO(unittest.TestCase):
"""Round-trip the same project through save/load and check equivalence."""
diff --git a/src/fluency/ui/dialogs.py b/src/fluency/ui/dialogs.py
index 7a358bd..44f4c7d 100644
--- a/src/fluency/ui/dialogs.py
+++ b/src/fluency/ui/dialogs.py
@@ -14,11 +14,13 @@ from PySide6.QtWidgets import (
QDoubleSpinBox,
QFrame,
QGridLayout,
+ QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
+ QSpinBox,
QVBoxLayout,
QWidget,
)
@@ -162,12 +164,20 @@ class ExtrudeDialog(QDialog):
class RevolveDialog(QDialog):
- """Dialog for revolve options."""
+ """Dialog for revolve options.
- def __init__(self, parent: Optional[QWidget] = None):
+ ``line_axis`` is ``(line_entity_id, origin, direction)`` from a line
+ selected in the sketch (see Sketch2DWidget.get_selected_revolve_axis).
+ When provided the dialog offers “Selected sketch line” as the revolve
+ axis (default); X / Y / Z world axes through the origin remain available
+ as a fallback.
+ """
+
+ def __init__(self, parent: Optional[QWidget] = None, line_axis: Optional[Tuple] = None):
super().__init__(parent)
self.setWindowTitle("Revolve Options")
- self.setMinimumWidth(300)
+ self.setMinimumWidth(340)
+ self._line_axis = line_axis # (line_id, origin, direction) or None
layout = QVBoxLayout(self)
@@ -181,6 +191,30 @@ class RevolveDialog(QDialog):
angle_layout.addWidget(self.angle_input)
layout.addLayout(angle_layout)
+ axis_layout = QHBoxLayout()
+ axis_layout.addWidget(QLabel("Axis:"))
+ self._axis_group = QButtonGroup(self)
+ self._axis_buttons = {}
+ for label, vec in (("X", (1, 0, 0)), ("Y", (0, 1, 0)), ("Z", (0, 0, 1))):
+ btn = QRadioButton(label)
+ btn.setProperty("axis", vec)
+ self._axis_group.addButton(btn)
+ self._axis_buttons[label] = btn
+ axis_layout.addWidget(btn)
+ if line_axis is not None:
+ self._line_button = QRadioButton("Selected line")
+ self._line_button.setToolTip(
+ "Revolve around the line selected in the sketch (origin = its start point)"
+ )
+ self._axis_group.addButton(self._line_button)
+ axis_layout.addWidget(self._line_button)
+ self._line_button.setChecked(True)
+ else:
+ self._line_button = None
+ self._axis_buttons["Z"].setChecked(True) # backward compatible default
+ axis_layout.addStretch()
+ layout.addLayout(axis_layout)
+
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
@@ -195,6 +229,25 @@ class RevolveDialog(QDialog):
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
+ def get_values(self) -> Tuple[float, Tuple[float, float, float], Tuple[float, float, float], bool]:
+ """Return ``(angle_deg, axis_vector, origin, use_line)``.
+
+ ``use_line`` is True when the revolve should use the sketch line that
+ was selected before the dialog opened (axis/origin from the line).
+ Otherwise axis is one of the X / Y / Z unit vectors and origin is
+ ``(0, 0, 0)``.
+ """
+ checked = self._axis_group.checkedButton()
+ if checked is not None and checked is self._line_button and self._line_axis is not None:
+ _, origin, direction = self._line_axis
+ return self.angle_input.value(), tuple(direction), tuple(origin), True
+ axis = (0, 0, 1)
+ if checked is not None:
+ vec = checked.property("axis")
+ if vec:
+ axis = tuple(float(v) for v in vec)
+ return self.angle_input.value(), axis, (0.0, 0.0, 0.0), False
+
class OffsetDialog(QDialog):
"""Dialog for 2D sketch offset options.
@@ -270,6 +323,324 @@ class OffsetDialog(QDialog):
return (self.distance_input.value(), self.inward_checkbox.isChecked())
+class ArrayDialog(QDialog):
+ """Dialog for the array (pattern) tool: linear or circular repeats.
+
+ Asks the user how many repeats (the total item count, original
+ included) plus the pattern geometry:
+
+ - **Linear**: spacing between adjacent items and the direction
+ (X / Y / Z presets or a custom 3D vector — negative spacing
+ flips the direction).
+ - **Circular**: total angular span (default 360°) over which the
+ copies are evenly distributed, the rotation axis (X / Y / Z or
+ custom vector) and the axis origin point.
+
+ A live-preview callback (``set_preview_callback``) fires on every
+ change so the host can show the repeated copies plus a direction /
+ axis indicator in the 3D view before committing.
+
+ ``get_values()`` returns a dict:
+ ``{"pattern_type", "count", "spacing", "direction", "angle",
+ "axis", "origin"}``.
+ """
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self.setWindowTitle("Array Options")
+ self.setMinimumWidth(400)
+
+ self._preview_callback: Optional[Callable[[Any], None]] = None
+
+ layout = QVBoxLayout(self)
+
+ # ── Pattern type ──
+ type_layout = QHBoxLayout()
+ type_layout.addWidget(QLabel("Pattern type:"))
+ self.linear_radio = QRadioButton("Linear")
+ self.linear_radio.setChecked(True)
+ self.circular_radio = QRadioButton("Circular")
+ self.type_group = QButtonGroup(self)
+ self.type_group.addButton(self.linear_radio)
+ self.type_group.addButton(self.circular_radio)
+ type_layout.addWidget(self.linear_radio)
+ type_layout.addWidget(self.circular_radio)
+ type_layout.addStretch()
+ layout.addLayout(type_layout)
+
+ # ── Repeats (shared by both types) ──
+ count_layout = QHBoxLayout()
+ count_layout.addWidget(QLabel("Repeats (items, incl. original):"))
+ self.count_input = QSpinBox()
+ self.count_input.setRange(1, 1000)
+ self.count_input.setValue(3)
+ self.count_input.setToolTip(
+ "Total number of items in the array, including the original body."
+ )
+ count_layout.addWidget(self.count_input)
+ count_layout.addStretch()
+ layout.addLayout(count_layout)
+
+ # ── Linear group ──
+ self.linear_group = QGroupBox("Linear Pattern")
+ lin = QVBoxLayout(self.linear_group)
+
+ spacing_layout = QHBoxLayout()
+ spacing_layout.addWidget(QLabel("Spacing (mm):"))
+ self.spacing_input = QDoubleSpinBox()
+ self.spacing_input.setDecimals(2)
+ self.spacing_input.setRange(-100000.0, 100000.0)
+ self.spacing_input.setValue(10.0)
+ self.spacing_input.setSingleStep(1.0)
+ self.spacing_input.setToolTip(
+ "Distance between adjacent items. Negative flips the direction."
+ )
+ spacing_layout.addWidget(self.spacing_input)
+ spacing_layout.addStretch()
+ lin.addLayout(spacing_layout)
+
+ lin.addWidget(QLabel("Direction:"))
+ dir_btns = QHBoxLayout()
+ self.dir_x_radio = QRadioButton("X")
+ self.dir_x_radio.setChecked(True)
+ self.dir_y_radio = QRadioButton("Y")
+ self.dir_z_radio = QRadioButton("Z")
+ self.dir_custom_radio = QRadioButton("Custom")
+ self.dir_group = QButtonGroup(self)
+ for b in (self.dir_x_radio, self.dir_y_radio, self.dir_z_radio, self.dir_custom_radio):
+ self.dir_group.addButton(b)
+ dir_btns.addWidget(b)
+ dir_btns.addStretch()
+ lin.addLayout(dir_btns)
+
+ self.dir_custom_row = QHBoxLayout()
+ self.dir_custom_row.addWidget(QLabel("Vector:"))
+ self.dir_x_input = self._vector_spin()
+ self.dir_y_input = self._vector_spin()
+ self.dir_z_input = self._vector_spin()
+ self.dir_custom_row.addWidget(self.dir_x_input)
+ self.dir_custom_row.addWidget(self.dir_y_input)
+ self.dir_custom_row.addWidget(self.dir_z_input)
+ self.dir_custom_row.addStretch()
+ lin.addLayout(self.dir_custom_row)
+ self._set_custom_enabled(self.dir_x_input, self.dir_y_input, self.dir_z_input, enabled=False)
+ layout.addWidget(self.linear_group)
+
+ # ── Circular group ──
+ self.circular_group = QGroupBox("Circular Pattern")
+ circ = QVBoxLayout(self.circular_group)
+
+ angle_layout = QHBoxLayout()
+ angle_layout.addWidget(QLabel("Total angle (°):"))
+ self.angle_input = QDoubleSpinBox()
+ self.angle_input.setDecimals(1)
+ self.angle_input.setRange(-3600.0, 3600.0)
+ self.angle_input.setValue(360.0)
+ self.angle_input.setSingleStep(15.0)
+ self.angle_input.setSuffix("°")
+ self.angle_input.setToolTip(
+ "Total angular span over which the copies are evenly distributed. "
+ "360° gives a full evenly-spaced ring (step = angle / count)."
+ )
+ angle_layout.addWidget(self.angle_input)
+ angle_layout.addStretch()
+ circ.addLayout(angle_layout)
+
+ circ.addWidget(QLabel("Rotation axis:"))
+ axis_btns = QHBoxLayout()
+ self.axis_z_radio = QRadioButton("Z")
+ self.axis_z_radio.setChecked(True)
+ self.axis_x_radio = QRadioButton("X")
+ self.axis_y_radio = QRadioButton("Y")
+ self.axis_custom_radio = QRadioButton("Custom")
+ self.axis_group = QButtonGroup(self)
+ for b in (self.axis_x_radio, self.axis_y_radio, self.axis_z_radio, self.axis_custom_radio):
+ self.axis_group.addButton(b)
+ axis_btns.addWidget(b)
+ axis_btns.addStretch()
+ circ.addLayout(axis_btns)
+
+ self.axis_custom_row = QHBoxLayout()
+ self.axis_custom_row.addWidget(QLabel("Vector:"))
+ self.axis_x_input = self._vector_spin()
+ self.axis_y_input = self._vector_spin()
+ self.axis_z_input = self._vector_spin()
+ self.axis_custom_row.addWidget(self.axis_x_input)
+ self.axis_custom_row.addWidget(self.axis_y_input)
+ self.axis_custom_row.addWidget(self.axis_z_input)
+ self.axis_custom_row.addStretch()
+ circ.addLayout(self.axis_custom_row)
+ self._set_custom_enabled(self.axis_x_input, self.axis_y_input, self.axis_z_input, enabled=False)
+
+ origin_layout = QHBoxLayout()
+ origin_layout.addWidget(QLabel("Axis origin (mm):"))
+ self.origin_x_input = self._vector_spin(-100000, 100000)
+ self.origin_y_input = self._vector_spin(-100000, 100000)
+ self.origin_z_input = self._vector_spin(-100000, 100000)
+ origin_layout.addWidget(self.origin_x_input)
+ origin_layout.addWidget(self.origin_y_input)
+ origin_layout.addWidget(self.origin_z_input)
+ origin_layout.addStretch()
+ circ.addLayout(origin_layout)
+ layout.addWidget(self.circular_group)
+
+ # ── Buttons ──
+ line = QFrame()
+ line.setFrameShape(QFrame.Shape.HLine)
+ line.setFrameShadow(QFrame.Shadow.Sunken)
+ layout.addWidget(line)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("Apply Array")
+ 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)
+
+ # ── Signals / live preview ──
+ self.linear_radio.toggled.connect(self._on_type_changed)
+ self.circular_radio.toggled.connect(self._on_type_changed)
+ self.count_input.valueChanged.connect(self._emit_preview)
+ self.spacing_input.valueChanged.connect(self._emit_preview)
+ self.angle_input.valueChanged.connect(self._emit_preview)
+ self.dir_group.buttonToggled.connect(self._on_direction_changed)
+ self.axis_group.buttonToggled.connect(self._on_axis_changed)
+ for w in (
+ self.dir_x_input,
+ self.dir_y_input,
+ self.dir_z_input,
+ self.axis_x_input,
+ self.axis_y_input,
+ self.axis_z_input,
+ self.origin_x_input,
+ self.origin_y_input,
+ self.origin_z_input,
+ ):
+ w.valueChanged.connect(self._emit_preview)
+
+ # ── Widget builders ──
+
+ @staticmethod
+ def _vector_spin(lo: float = -100000.0, hi: float = 100000.0) -> QDoubleSpinBox:
+ """A compact spinbox for a vector component (axis/direction/origin)."""
+ spin = QDoubleSpinBox()
+ spin.setDecimals(2)
+ spin.setRange(lo, hi)
+ spin.setValue(0.0)
+ spin.setSingleStep(1.0)
+ spin.setFixedWidth(80)
+ return spin
+
+ @staticmethod
+ def _set_custom_enabled(*spins: QDoubleSpinBox, enabled: bool) -> None:
+ for s in spins:
+ s.setEnabled(enabled)
+
+ # ── State handling ──
+
+ def _on_type_changed(self) -> None:
+ """Enable only the group matching the selected pattern type."""
+ linear = self.linear_radio.isChecked()
+ self.linear_group.setEnabled(linear)
+ self.circular_group.setEnabled(not linear)
+ self._emit_preview()
+
+ def _on_direction_changed(self, *args: Any) -> None:
+ custom = self.dir_custom_radio.isChecked()
+ self._set_custom_enabled(self.dir_x_input, self.dir_y_input, self.dir_z_input, enabled=custom)
+ self._emit_preview()
+
+ def _on_axis_changed(self, *args: Any) -> None:
+ custom = self.axis_custom_radio.isChecked()
+ self._set_custom_enabled(self.axis_x_input, self.axis_y_input, self.axis_z_input, enabled=custom)
+ self._emit_preview()
+
+ def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
+ """Install the live-preview callback; fires immediately with defaults."""
+ self._preview_callback = callback
+ self._emit_preview()
+
+ def _emit_preview(self, *args: Any) -> None:
+ if self._preview_callback is None:
+ return
+ try:
+ self._preview_callback(self.get_values())
+ except Exception as exc:
+ logger.debug("array preview callback raised: %s", exc)
+
+ def hideEvent(self, event: Any) -> None:
+ if self._preview_callback is not None:
+ try:
+ self._preview_callback(None)
+ except Exception:
+ pass
+ super().hideEvent(event)
+
+ # ── Accessors ──
+
+ def _selected_vector(
+ self,
+ x_radio: QRadioButton,
+ y_radio: QRadioButton,
+ z_radio: QRadioButton,
+ custom_radio: QRadioButton,
+ x_in: QDoubleSpinBox,
+ y_in: QDoubleSpinBox,
+ z_in: QDoubleSpinBox,
+ ) -> Tuple[float, float, float]:
+ """Return the vector from the preset/custom radio selection."""
+ if x_radio.isChecked():
+ return (1.0, 0.0, 0.0)
+ if y_radio.isChecked():
+ return (0.0, 1.0, 0.0)
+ if z_radio.isChecked():
+ return (0.0, 0.0, 1.0)
+ if custom_radio.isChecked():
+ return (x_in.value(), y_in.value(), z_in.value())
+ return (1.0, 0.0, 0.0)
+
+ def get_values(self) -> Dict[str, Any]:
+ """Return the current pattern parameters as a dict.
+
+ Keys: ``pattern_type``, ``count``, ``spacing``, ``direction``,
+ ``angle``, ``axis``, ``origin``.
+ """
+ linear = self.linear_radio.isChecked()
+ direction = self._selected_vector(
+ self.dir_x_radio,
+ self.dir_y_radio,
+ self.dir_z_radio,
+ self.dir_custom_radio,
+ self.dir_x_input,
+ self.dir_y_input,
+ self.dir_z_input,
+ )
+ axis = self._selected_vector(
+ self.axis_x_radio,
+ self.axis_y_radio,
+ self.axis_z_radio,
+ self.axis_custom_radio,
+ self.axis_x_input,
+ self.axis_y_input,
+ self.axis_z_input,
+ )
+ return {
+ "pattern_type": "linear" if linear else "circular",
+ "count": self.count_input.value(),
+ "spacing": self.spacing_input.value(),
+ "direction": direction,
+ "angle": self.angle_input.value(),
+ "axis": axis,
+ "origin": (
+ self.origin_x_input.value(),
+ self.origin_y_input.value(),
+ self.origin_z_input.value(),
+ ),
+ }
+
+
class WorkplaneOrientationDialog(QDialog):
"""Modal dialog to choose the orientation of a new workplane.
@@ -509,6 +880,197 @@ class WorkplaneOrientationDialog(QDialog):
)
+# ── Metric thread data (coarse pitch) ──────────────────────────────────
+METRIC_THREADS = {
+ "M1": (1.0, 0.25),
+ "M1.2": (1.2, 0.25),
+ "M1.4": (1.4, 0.30),
+ "M1.6": (1.6, 0.35),
+ "M1.8": (1.8, 0.35),
+ "M2": (2.0, 0.40),
+ "M2.5": (2.5, 0.45),
+ "M3": (3.0, 0.50),
+ "M3.5": (3.5, 0.60),
+ "M4": (4.0, 0.70),
+ "M5": (5.0, 0.80),
+ "M6": (6.0, 1.00),
+ "M7": (7.0, 1.00),
+ "M8": (8.0, 1.25),
+ "M10": (10.0, 1.50),
+ "M12": (12.0, 1.75),
+ "M14": (14.0, 2.00),
+ "M16": (16.0, 2.00),
+ "M18": (18.0, 2.50),
+ "M20": (20.0, 2.50),
+ "M22": (22.0, 2.50),
+ "M24": (24.0, 3.00),
+ "M27": (27.0, 3.00),
+ "M30": (30.0, 3.50),
+ "M32": (32.0, 3.50),
+}
+
+
+def _closest_metric_thread(diameter_mm: float) -> Optional[Tuple[str, float, float]]:
+ """Return the closest metric thread ``(name, nominal_dia, pitch)``
+ for a cylinder of *diameter_mm*, or *None* if no close match.
+ """
+ best: Optional[Tuple[str, float, float, float]] = None # name, dia, pitch, diff
+ for name, (dia, pitch) in METRIC_THREADS.items():
+ diff = abs(dia - diameter_mm)
+ if best is None or diff < best[3]:
+ best = (name, dia, pitch, diff)
+ if best is None:
+ return None
+ return (best[0], best[1], best[2])
+
+
+class ThreadDialog(QDialog):
+ """Dialog for applying an ISO metric thread to a cylindrical face.
+
+ The user picks a metric size (M1–M32) and optionally overrides the
+ pitch. When the dialog was opened with a detected cylinder diameter
+ the closest size is pre-selected.
+ """
+
+ def __init__(
+ self,
+ parent: Optional[QWidget] = None,
+ detected_diameter: Optional[float] = None,
+ ):
+ super().__init__(parent)
+ self.setWindowTitle("Thread Options")
+ self.setMinimumWidth(340)
+
+ self._preview_callback: Optional[Callable[[Any], None]] = None
+
+ layout = QVBoxLayout(self)
+
+ # ── Size selection ──
+ layout.addWidget(QLabel("Thread Size:"))
+
+ self.size_combo = QComboBox()
+ self.size_combo.setToolTip("Select the metric thread size.")
+ for name in METRIC_THREADS:
+ dia, pitch = METRIC_THREADS[name]
+ self.size_combo.addItem(f"{name} (Ø{dia:g} mm, pitch {pitch:g} mm)", name)
+ layout.addWidget(self.size_combo)
+
+ # ── Pitch override ──
+ pitch_row = QHBoxLayout()
+ pitch_row.addWidget(QLabel("Pitch (mm):"))
+ self.pitch_input = QDoubleSpinBox()
+ self.pitch_input.setDecimals(2)
+ self.pitch_input.setRange(0.1, 10.0)
+ self.pitch_input.setValue(1.0)
+ self.pitch_input.setSingleStep(0.05)
+ self.pitch_input.setToolTip("Override the standard pitch if needed.")
+ pitch_row.addWidget(self.pitch_input)
+ layout.addLayout(pitch_row)
+
+ # ── Thread type ──
+ type_row = QHBoxLayout()
+ type_row.addWidget(QLabel("Type:"))
+ self.external_radio = QRadioButton("External (shaft)")
+ self.external_radio.setChecked(True)
+ self.internal_radio = QRadioButton("Internal (hole)")
+ type_row.addWidget(self.external_radio)
+ type_row.addWidget(self.internal_radio)
+ layout.addLayout(type_row)
+
+ # ── Length ──
+ len_row = QHBoxLayout()
+ len_row.addWidget(QLabel("Thread Length (mm):"))
+ self.length_input = QDoubleSpinBox()
+ self.length_input.setDecimals(2)
+ self.length_input.setRange(0.1, 10000.0)
+ self.length_input.setValue(20.0)
+ self.length_input.setToolTip(
+ "Length of the threaded section. 0 = use full cylinder height."
+ )
+ len_row.addWidget(self.length_input)
+ layout.addLayout(len_row)
+
+ # ── Cylinder info ──
+ self.info_label = QLabel("")
+ self.info_label.setStyleSheet("color: #8a8a8a;")
+ layout.addWidget(self.info_label)
+
+ if detected_diameter is not None:
+ closest = _closest_metric_thread(detected_diameter)
+ if closest is not None:
+ name, dia, pitch = closest
+ idx = self.size_combo.findData(name)
+ if idx >= 0:
+ self.size_combo.setCurrentIndex(idx)
+ self.pitch_input.setValue(pitch)
+ self.length_input.setValue(dia * 3.0) # sensible default length
+ self.info_label.setText(
+ f"Detected cylinder Ø ≈ {detected_diameter:.2f} mm → closest: {name}"
+ )
+
+ # ── Signals ──
+ self.size_combo.currentIndexChanged.connect(self._on_size_changed)
+ self.pitch_input.valueChanged.connect(self._emit_preview)
+ self.external_radio.toggled.connect(self._emit_preview)
+ self.internal_radio.toggled.connect(self._emit_preview)
+ self.length_input.valueChanged.connect(self._emit_preview)
+
+ line = QFrame()
+ line.setFrameShape(QFrame.Shape.HLine)
+ line.setFrameShadow(QFrame.Shadow.Sunken)
+ layout.addWidget(line)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("Apply Thread")
+ 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)
+
+ def _on_size_changed(self) -> None:
+ """Update pitch when the user picks a different size."""
+ name = self.size_combo.currentData()
+ if name and name in METRIC_THREADS:
+ _dia, pitch = METRIC_THREADS[name]
+ self.pitch_input.setValue(pitch)
+ self._emit_preview()
+
+ def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
+ """Install a live-preview callback; fires immediately."""
+ self._preview_callback = callback
+ self._emit_preview()
+
+ def _emit_preview(self, *args: Any) -> None:
+ if self._preview_callback is None:
+ return
+ try:
+ self._preview_callback(self.get_values())
+ except Exception as exc:
+ logger.debug("thread preview callback raised: %s", exc)
+
+ def hideEvent(self, event: Any) -> None:
+ if self._preview_callback is not None:
+ try:
+ self._preview_callback(None)
+ except Exception:
+ pass
+ super().hideEvent(event)
+
+ def get_values(self) -> Tuple[str, float, float, bool, float]:
+ """Return ``(size_name, nominal_diameter, pitch, internal, length)``."""
+ name = self.size_combo.currentData()
+ dia, _std_pitch = METRIC_THREADS.get(name, (0.0, 0.0))
+ return (
+ name or "M6",
+ dia,
+ self.pitch_input.value(),
+ self.internal_radio.isChecked(),
+ self.length_input.value(),
+ )
+
+
class FilletDialog(QDialog):
"""Dialog for fillet options — the common settings from CAD fillet tools.
@@ -653,3 +1215,134 @@ class FilletDialog(QDialog):
self.tangent_checkbox.isChecked(),
"all" if self.scope_all_radio.isChecked() else "selected",
)
+
+
+class ChamferDialog(QDialog):
+ """Dialog for chamfer options — shown after the user picks two faces.
+
+ The user picks the two faces whose shared edges will be beveled
+ (same flow as the fillet tool). This dialog offers:
+
+ - chamfer **size** (mm) — the equal distance cut from each face
+ along the shared edge (BRepFilletAPI_MakeChamfer.Add(size, edge)
+ cuts the same amount on both faces),
+ - edge **scope** (only the edges between the two picked faces vs
+ every edge of the body),
+ - **tangent propagation** (extend the chamfer along tangent-
+ connected edges, like the fillet tool),
+ - a live 3D preview (``set_preview_callback``).
+
+ ``get_values()`` returns ``(size, tangent_propagation, scope)`` where
+ *scope* is ``"selected"`` or ``"all"``.
+ """
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self.setWindowTitle("Chamfer Options")
+ self.setMinimumWidth(360)
+
+ self._preview_callback: Optional[Callable[[Any], None]] = None
+
+ layout = QVBoxLayout(self)
+
+ # ── Size ──
+ size_row = QHBoxLayout()
+ size_row.addWidget(QLabel("Size (mm):"))
+
+ self.size_input = QDoubleSpinBox()
+ self.size_input.setDecimals(2)
+ self.size_input.setRange(0.01, 100000.0)
+ self.size_input.setValue(2.0)
+ self.size_input.setSingleStep(0.5)
+ self.size_input.setSuffix(" mm")
+ self.size_input.setToolTip(
+ "Chamfer size: the equal distance cut from each face along "
+ "the shared edge (a symmetric 45\u00b0 bevel)."
+ )
+ size_row.addWidget(self.size_input)
+ layout.addLayout(size_row)
+
+ # ── Edge scope ──
+ self.scope_group = QButtonGroup(self)
+ scope_layout = QGridLayout()
+ self.scope_selected_radio = QRadioButton("Edges between faces")
+ self.scope_selected_radio.setChecked(True)
+ self.scope_selected_radio.setToolTip("Bevel only the edges shared by the two picked faces.")
+ self.scope_all_radio = QRadioButton("All edges of body")
+ self.scope_all_radio.setToolTip(
+ "Bevel every edge of the body (the picked faces only choose which body is modified)."
+ )
+ self.scope_group.addButton(self.scope_selected_radio)
+ self.scope_group.addButton(self.scope_all_radio)
+ scope_layout.addWidget(self.scope_selected_radio, 0, 0)
+ scope_layout.addWidget(self.scope_all_radio, 1, 0)
+ layout.addLayout(scope_layout)
+
+ # ── Tangent propagation ──
+ self.tangent_checkbox = QCheckBox("Tangent propagation")
+ self.tangent_checkbox.setChecked(True)
+ self.tangent_checkbox.setToolTip(
+ "Extend the chamfer along edges that are tangent to the picked "
+ "ones (e.g. a smooth chain of lines and arcs). Off = only the "
+ "exact edges between the two faces."
+ )
+ layout.addWidget(self.tangent_checkbox)
+
+ # ── Edge count feedback ──
+ self.edge_label = QLabel("")
+ self.edge_label.setStyleSheet("color: #8a8a8a;")
+ layout.addWidget(self.edge_label)
+
+ line = QFrame()
+ line.setFrameShape(QFrame.Shape.HLine)
+ line.setFrameShadow(QFrame.Shadow.Sunken)
+ layout.addWidget(line)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("Apply Chamfer")
+ 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 change ──
+ self.size_input.valueChanged.connect(self._emit_preview)
+ self.scope_selected_radio.toggled.connect(self._emit_preview)
+ self.tangent_checkbox.stateChanged.connect(self._emit_preview)
+
+ def set_edge_count(self, count: int) -> None:
+ """Show how many edges the current scope will bevel."""
+ self.edge_label.setText(
+ f"Chamfers {count} edge{'s' if count != 1 else ''} between the picked faces."
+ )
+
+ def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
+ """Install a live-preview callback; fires immediately with defaults."""
+ self._preview_callback = callback
+ self._emit_preview()
+
+ def _emit_preview(self, *args: Any) -> 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("chamfer preview callback raised: %s", exc)
+
+ def hideEvent(self, event: Any) -> None:
+ 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, str]:
+ """Return ``(size, tangent_propagation, scope)``."""
+ return (
+ self.size_input.value(),
+ self.tangent_checkbox.isChecked(),
+ "all" if self.scope_all_radio.isChecked() else "selected",
+ )
diff --git a/src/fluency/ui/gui_ui.py b/src/fluency/ui/gui_ui.py
index d2346b7..4131d5a 100644
--- a/src/fluency/ui/gui_ui.py
+++ b/src/fluency/ui/gui_ui.py
@@ -892,7 +892,7 @@ class Ui_fluencyCAD(object):
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
- self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
+ self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 9c78af2..2960572 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -47,10 +47,13 @@ from fluency.io.project_io import load_project, project_zip_path, save_project
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane, Feature
from fluency.ui.dialogs import (
+ ArrayDialog,
+ ChamferDialog,
ExtrudeDialog,
FilletDialog,
OffsetDialog,
RevolveDialog,
+ ThreadDialog,
WorkplaneOrientationDialog,
)
from fluency.ui.sketch_widget import Sketch2DWidget
@@ -435,14 +438,17 @@ def _project_body_to_workplane(
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.
+) -> List[Union[List[Tuple[float, float]], Dict[str, Any]]]:
+ """Project ALL edges of a 3D body onto a workplane.
+
+ Returns a mix of:
+ - Polylines: lists of (u, v) points for linear or general curves
+ - Circle dicts: {"type": "circle", "center": (u, v), "radius": r} for projected circles
*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.
+ workplane normal).
This lets the user see the body's silhouette from the workplane's
perspective and draw sketches precisely aligned to the body's features.
@@ -452,7 +458,7 @@ def _project_body_to_workplane(
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
- from OCP.GeomAbs import GeomAbs_Line
+ from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle
from OCP.gp import gp_Pnt
origin = np.asarray(workplane[0], dtype=float)
@@ -467,7 +473,34 @@ def _project_body_to_workplane(
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]]] = []
+ def project_circle(crv, f, l) -> Dict[str, Any]:
+ """Project a 3D circle onto the workplane and return its center + radius."""
+ # Get circle parameters from the BRepAdaptor
+ c = crv.Circle()
+ # gp_Circ has axis and radius; center is at axis.Location()
+ loc = c.Axis().Location()
+ center_3d = gp_Pnt(loc.X(), loc.Y(), loc.Z())
+ radius_3d = c.Radius()
+
+ # Project center to UV
+ center_uv = world_to_uv(center_3d)
+
+ # Project a point on the circle (at parameter 0.5) to get the projected radius
+ pt_on_circle = crv.Value(f + 0.5 * (l - f))
+ uv_on_circle = world_to_uv(pt_on_circle)
+
+ # Distance from center is the projected radius
+ projected_radius = np.sqrt(
+ (uv_on_circle[0] - center_uv[0]) ** 2 + (uv_on_circle[1] - center_uv[1]) ** 2
+ )
+
+ return {
+ "type": "circle",
+ "center": list(center_uv),
+ "radius": float(projected_radius),
+ }
+
+ results = [] # List of polylines and/or circle dicts
# Iterate every face of the body, then each wire/edge within.
face_expl = TopExp_Explorer(body_shape, TopAbs_FACE)
@@ -483,21 +516,162 @@ def _project_body_to_workplane(
crv = BRepAdaptor_Curve(edge)
f = crv.FirstParameter()
l = crv.LastParameter()
- is_line = crv.GetType() == GeomAbs_Line
- if is_line:
+ geom_type = crv.GetType()
+
+ if geom_type == GeomAbs_Line:
+ # Linear edge: exact endpoints
pts = [crv.Value(f), crv.Value(l)]
+ poly = [world_to_uv(p) for p in pts]
+ results.append(poly)
+ elif geom_type == GeomAbs_Circle:
+ # An OCC ``GeomAbs_Circle`` edge is a *portion* of
+ # a full circle — only when the parameter range
+ # covers the whole 2π sweep is it truly a full
+ # circle. Fillet / rounded-corner edges are the
+ # same curve type but with a smaller range, so
+ # emit them as arc dicts so the importer can
+ # build a proper arc entity (sampling as a
+ # polyline would show as a cluster of dot
+ # entities on the small fillet).
+ param_span = l - f
+ if abs(param_span - 2.0 * math.pi) < 1e-3:
+ # Full circle: project as proper circle dict
+ circle_data = project_circle(crv, f, l)
+ results.append(circle_data)
+ else:
+ # Arc segment (e.g. fillet): emit arc dict
+ c = crv.Circle()
+ loc = c.Axis().Location()
+ center_3d = gp_Pnt(loc.X(), loc.Y(), loc.Z())
+ center_uv = list(world_to_uv(center_3d))
+ start_3d = crv.Value(f)
+ end_3d = crv.Value(l)
+ start_uv = list(world_to_uv(start_3d))
+ end_uv = list(world_to_uv(end_3d))
+ mid_3d = crv.Value(f + 0.5 * param_span)
+ mid_uv = world_to_uv(mid_3d)
+ projected_radius = math.sqrt(
+ (mid_uv[0] - center_uv[0]) ** 2
+ + (mid_uv[1] - center_uv[1]) ** 2
+ )
+ results.append(
+ {
+ "type": "arc",
+ "center": center_uv,
+ "start": start_uv,
+ "end": end_uv,
+ "radius": float(projected_radius),
+ }
+ )
else:
- # Sample 24 segments \u2014 enough for smooth curves.
+ # General curve: sample 24 segments for smooth projection
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)
+ poly = [world_to_uv(p) for p in pts]
+ results.append(poly)
except Exception:
pass
edge_expl.Next()
wire_expl.Next()
face_expl.Next()
- return polylines
+ return results
+
+
+def _sketch_uv_bounds(occ_sketch) -> Optional[Tuple[float, float, float, float]]:
+ """Bounding box of the sketch's USER geometry in UV space.
+
+ Returns ``(u_min, v_min, u_max, v_max)`` considering only non-external,
+ non-construction point/line entities plus circle extents (a circle's
+ bounds reach beyond its centre point, so its radius is added). Underlay
+ reference geometry and centerlines are excluded so the box reflects the
+ actual drawn profile. Returns None for an empty sketch.
+ """
+ import math
+
+ u_min = v_min = math.inf
+ u_max = v_max = -math.inf
+
+ def _grow(u: float, v: float) -> None:
+ nonlocal u_min, v_min, u_max, v_max
+ u_min = min(u_min, u)
+ u_max = max(u_max, u)
+ v_min = min(v_min, v)
+ v_max = max(v_max, v)
+
+ try:
+ for ent in occ_sketch._entities.values():
+ if ent.is_external or ent.is_construction:
+ continue
+ g = ent.geometry
+ if not g:
+ continue
+ if ent.entity_type == "point":
+ _grow(float(g[0]), float(g[1]))
+ elif ent.entity_type == "line" and len(g) == 2:
+ p1, p2 = g
+ if p1:
+ _grow(float(p1[0]), float(p1[1]))
+ if p2:
+ _grow(float(p2[0]), float(p2[1]))
+ # Circles: expand by radius around the centre point.
+ for eid, (center_id, radius) in occ_sketch._circles.items():
+ if eid in occ_sketch._external_entity_ids:
+ continue
+ ent = occ_sketch._entities.get(eid)
+ if ent is not None and ent.is_construction:
+ continue
+ cent = occ_sketch._entities.get(center_id)
+ if cent is not None and cent.geometry:
+ _grow(float(cent.geometry[0]) - radius, float(cent.geometry[1]) - radius)
+ _grow(float(cent.geometry[0]) + radius, float(cent.geometry[1]) + radius)
+ except Exception:
+ pass
+
+ if math.isinf(u_min):
+ return None
+ return (u_min, v_min, u_max, v_max)
+
+
+def _uv_to_world(
+ origin: Tuple[float, float, float],
+ normal: Tuple[float, float, float],
+ x_dir: Tuple[float, float, float],
+ u: float,
+ v: float,
+) -> Tuple[float, float, float]:
+ """Map a sketch UV point to world coordinates on the sketch plane.
+
+ P = origin + u·x_dir + v·(normal × x_dir), with normal/x_dir
+ orthonormalised first.
+ """
+ import numpy as np
+
+ o = np.asarray(origin, dtype=float)
+ 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
+ xn = np.linalg.norm(x)
+ if xn < 1e-9:
+ fb = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
+ x = fb - np.dot(fb, n) * n
+ xn = np.linalg.norm(x)
+ x = x / xn
+ y = np.cross(n, x)
+ p = o + u * x + v * y
+ return (float(p[0]), float(p[1]), float(p[2]))
+
+
+# Human-readable labels for world sketch gizmo part kinds.
+_SKETCH_GIZMO_LABELS = {
+ "center": "Center point",
+ "axis_x": "X axis",
+ "axis_y": "Y axis",
+ "axis_z": "Z axis",
+ "plane_xy": "XY plane",
+ "plane_yz": "YZ plane",
+ "plane_xz": "XZ plane",
+}
def _offset_polygon(
@@ -857,7 +1031,10 @@ def _replay_body_features(
"""
geom: Optional[Any] = None
for feat in features:
- if feat.operation == "base":
+ if feat.operation == "base" or feat.operation == "thread":
+ # Frozen geometry snapshot: use as-is (thread stores its final
+ # result because re-creating a helical sweep from scratch on
+ # topology-changed geometry is unreliable).
geom = feat.geometry
continue
@@ -887,6 +1064,48 @@ def _replay_body_features(
return None
continue
+ if feat.operation == "chamfer":
+ # Chamfer needs no sketch — it bevels edges of the running solid.
+ if geom is None:
+ logger.warning(f"Body '{body.name}': chamfer feature has no base, replay aborted")
+ return None
+ if feat.radius is None:
+ logger.warning(f"Body '{body.name}': chamfer feature has no size, replay aborted")
+ return None
+ if feat.scope == "all" or not feat.edge_refs:
+ edges = None # bevel every edge
+ else:
+ edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
+ if not edges:
+ logger.warning(
+ f"Body '{body.name}': chamfer edge refs unresolved after rebuild, "
+ "replay aborted"
+ )
+ return None
+ geom = kernel.chamfer(geom, feat.radius, edges=edges)
+ if geom is None:
+ return None
+ continue
+
+ if feat.operation in ("array", "pattern"):
+ # Pattern needs no sketch — it repeats the running solid.
+ if geom is None:
+ logger.warning(f"Body '{body.name}': array feature has no base, replay aborted")
+ return None
+ geom = kernel.pattern(
+ geom,
+ pattern_type=feat.pattern_type if feat.pattern_type else "linear",
+ count=max(1, int(feat.count)) if feat.count else 2,
+ direction=feat.direction if feat.direction is not None else (1, 0, 0),
+ spacing=feat.spacing if feat.spacing is not None else 10.0,
+ axis=feat.axis if feat.axis is not None else (0, 0, 1),
+ origin=feat.origin if feat.origin is not None else (0.0, 0.0, 0.0),
+ angle=feat.angle if feat.angle is not None else 360.0,
+ )
+ if geom is None:
+ return None
+ continue
+
sketch = feat.sketch
if sketch is None or sketch.occ_sketch is None:
logger.warning(
@@ -906,7 +1125,15 @@ def _replay_body_features(
return None
if feat.operation == "revolve":
- geom = kernel.revolve(face_geom, feat.angle)
+ axis = feat.axis if feat.axis is not None else (0, 0, 1)
+ origin = feat.origin if feat.origin is not None else (0.0, 0.0, 0.0)
+ # Prefer re-deriving the axis from the sketch line, so dragging the
+ # revolve line (e.g. a construction centerline) updates the body.
+ if feat.axis_line_id is not None:
+ line_axis = sketch.occ_sketch.get_line_axis(feat.axis_line_id)
+ if line_axis is not None:
+ origin, axis = line_axis
+ geom = kernel.revolve(face_geom, feat.angle, axis=axis, origin=origin)
if geom is None:
return None
continue
@@ -954,6 +1181,210 @@ def _replay_body_features(
return geom
+# ── Array / pattern helpers ──────────────────────────────────────────────────
+
+
+def _unit_vector(v: Tuple[float, float, float]) -> Tuple[float, float, float]:
+ """Normalize a 3D vector; falls back to (1, 0, 0) for zero length."""
+ x, y, z = float(v[0]), float(v[1]), float(v[2])
+ n = math.sqrt(x * x + y * y + z * z)
+ if n < 1e-12:
+ return (1.0, 0.0, 0.0)
+ return (x / n, y / n, z / n)
+
+
+def _make_arrow_shape(
+ start: Tuple[float, float, float],
+ direction: Tuple[float, float, float],
+ length: float,
+) -> Any:
+ """Build an arrow (shaft edge + cone head) for the array preview.
+
+ Returns a ``TopoDS_Compound`` containing a thin line from *start*
+ along *direction* and a cone arrowhead at the tip, so the live 3D
+ preview shows where the pattern is heading.
+ """
+ from OCP.BRep import BRep_Builder
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeCone
+ from OCP.TopoDS import TopoDS_Compound
+ from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
+
+ d = _unit_vector(direction)
+ length = max(float(length), 1e-6)
+ tip = (start[0] + d[0] * length, start[1] + d[1] * length, start[2] + d[2] * length)
+ head_len = max(2.0, length * 0.15)
+ head_r = max(1.0, length * 0.06)
+
+ builder = BRep_Builder()
+ comp = TopoDS_Compound()
+ builder.MakeCompound(comp)
+
+ shaft = BRepBuilderAPI_MakeEdge(gp_Pnt(*start), gp_Pnt(*tip)).Edge()
+ builder.Add(comp, shaft)
+
+ # Cone head: base sits at the shaft tip, apex points further along *d*.
+ cone = BRepPrimAPI_MakeCone(gp_Ax2(gp_Pnt(*tip), gp_Dir(*d)), head_r, 0.0, head_len).Shape()
+ builder.Add(comp, cone)
+ return comp
+
+
+def _make_rotation_path(
+ on_axis: Tuple[float, float, float],
+ axis: Tuple[float, float, float],
+ radius: float,
+) -> Any:
+ """Build a circle edge for the circular-pattern rotation path preview."""
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+ from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
+
+ circ = gp_Circ(gp_Ax2(gp_Pnt(*on_axis), gp_Dir(*axis)), max(float(radius), 1e-6))
+ return BRepBuilderAPI_MakeEdge(circ).Edge()
+
+
+def _bbox_center_diag(kernel: OCGeometryKernel, geom: Any) -> Tuple[Tuple[float, float, float], float]:
+ """Return the bounding-box center and diagonal length of *geom*."""
+ lo, hi = kernel.get_bounding_box(geom)
+ center = ((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0, (lo.z + hi.z) / 2.0)
+ diag = math.sqrt(
+ (hi.x - lo.x) ** 2 + (hi.y - lo.y) ** 2 + (hi.z - lo.z) ** 2
+ )
+ return center, diag if diag > 1e-9 else 1.0
+
+
+def _array_copies_box(
+ kernel: OCGeometryKernel,
+ geom: Any,
+ values: Dict[str, Any],
+) -> Optional[Any]:
+ """Bounding box (``Bnd_Box``) of the original body plus all copies.
+
+ Unlike the preview compound, this deliberately EXCLUDES the direction
+ arrow / axis / rotation-path indicators, so the camera fits exactly
+ the space the pattern elements occupy. Returns *None* if the base
+ geometry can't be read.
+ """
+ from OCP.Bnd import Bnd_Box
+ from OCP.BRepBndLib import BRepBndLib
+
+ base_shape = kernel._get_shape(geom)
+ if base_shape is None:
+ return None
+
+ box = Bnd_Box()
+
+ def _add(shape: Any) -> None:
+ if shape is not None:
+ BRepBndLib.AddClose_s(shape, box)
+
+ _add(base_shape)
+ pattern_type = values.get("pattern_type", "linear")
+ count = max(1, int(values.get("count", 2)))
+ if pattern_type == "circular":
+ axis = _unit_vector(tuple(values.get("axis", (0, 0, 1))))
+ origin = tuple(values.get("origin", (0.0, 0.0, 0.0)))
+ step = math.radians(float(values.get("angle", 360.0))) / count
+ for i in range(1, count):
+ _add(kernel._get_shape(kernel.rotate(geom, axis, step * i, origin)))
+ else:
+ direction = _unit_vector(tuple(values.get("direction", (1, 0, 0))))
+ spacing = float(values.get("spacing", 10.0))
+ for i in range(1, count):
+ _add(
+ kernel._get_shape(
+ kernel.translate(
+ geom,
+ (direction[0] * spacing * i, direction[1] * spacing * i, direction[2] * spacing * i),
+ )
+ )
+ )
+ return box
+
+
+def _build_array_preview(
+ kernel: OCGeometryKernel,
+ geom: Any,
+ values: Dict[str, Any],
+) -> Optional[Any]:
+ """Build a ``TopoDS_Compound`` showing the array copies + indicators.
+
+ The original body plus every copy is added, mirroring exactly the
+ transforms ``kernel.pattern`` applies. For linear patterns a
+ direction arrow is drawn from the body's bounding-box center; for
+ circular patterns the rotation axis line and the rotation-path
+ circle are drawn. Returns *None* when the geometry can't be read.
+ """
+ from OCP.BRep import BRep_Builder
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+ from OCP.TopoDS import TopoDS_Compound
+ from OCP.gp import gp_Pnt
+
+ pattern_type = values.get("pattern_type", "linear")
+ count = max(1, int(values.get("count", 2)))
+ base_shape = kernel._get_shape(geom)
+ if base_shape is None:
+ return None
+
+ builder = BRep_Builder()
+ comp = TopoDS_Compound()
+ builder.MakeCompound(comp)
+ builder.Add(comp, base_shape)
+
+ center, diag = _bbox_center_diag(kernel, geom)
+
+ if pattern_type == "circular":
+ axis = _unit_vector(tuple(values.get("axis", (0, 0, 1))))
+ origin = tuple(values.get("origin", (0.0, 0.0, 0.0)))
+ angle = float(values.get("angle", 360.0))
+ step = math.radians(angle) / count
+ for i in range(1, count):
+ inst = kernel.rotate(geom, axis, step * i, origin)
+ s = kernel._get_shape(inst)
+ if s is not None:
+ builder.Add(comp, s)
+
+ # Axis line through the point on the axis nearest the body center.
+ oc = (center[0] - origin[0], center[1] - origin[1], center[2] - origin[2])
+ t = oc[0] * axis[0] + oc[1] * axis[1] + oc[2] * axis[2]
+ on_axis = (origin[0] + axis[0] * t, origin[1] + axis[1] * t, origin[2] + axis[2] * t)
+ radius = math.sqrt(sum((c - o) ** 2 for c, o in zip(center, on_axis)))
+ radius = max(radius, diag * 0.25)
+
+ axis_len = diag * 1.2
+ mid = (
+ on_axis[0] - axis[0] * axis_len / 2.0,
+ on_axis[1] - axis[1] * axis_len / 2.0,
+ on_axis[2] - axis[2] * axis_len / 2.0,
+ )
+ axis_edge = BRepBuilderAPI_MakeEdge(
+ gp_Pnt(*mid), gp_Pnt(mid[0] + axis[0] * axis_len, mid[1] + axis[1] * axis_len, mid[2] + axis[2] * axis_len)
+ ).Edge()
+ builder.Add(comp, axis_edge)
+ builder.Add(comp, _make_rotation_path(on_axis, axis, radius))
+ else:
+ direction = _unit_vector(tuple(values.get("direction", (1, 0, 0))))
+ spacing = float(values.get("spacing", 10.0))
+ for i in range(1, count):
+ inst = kernel.translate(
+ geom, (direction[0] * spacing * i, direction[1] * spacing * i, direction[2] * spacing * i)
+ )
+ s = kernel._get_shape(inst)
+ if s is not None:
+ builder.Add(comp, s)
+
+ span = (count - 1) * abs(spacing)
+ arrow_len = max(span, diag * 0.4)
+ eff_dir = direction if spacing >= 0 else (-direction[0], -direction[1], -direction[2])
+ start = (
+ center[0] - eff_dir[0] * diag * 0.3,
+ center[1] - eff_dir[1] * diag * 0.3,
+ center[2] - eff_dir[2] * diag * 0.3,
+ )
+ builder.Add(comp, _make_arrow_shape(start, eff_dir, arrow_len))
+
+ return comp
+
+
class MainWindow(QMainWindow):
"""Main application window."""
@@ -967,6 +1398,11 @@ class MainWindow(QMainWindow):
self._current_component: Optional[Component] = None
self._current_sketch: Optional[Sketch] = None
+ # Last selection made with the world-space sketch gizmo (triad at
+ # the sketch midpoint). Dict with keys: kind, label, position,
+ # direction, normal, x_dir. Consumed by operations that need a
+ # selectable axis / centre / plane from the 3D viewport.
+ self._sketch_gizmo_selection: Optional[dict] = None
self._selected_body: Optional[Body] = None
# Fillet tool: two-face pick flow (face 1 → face 2 → options dialog).
@@ -975,6 +1411,17 @@ class MainWindow(QMainWindow):
self._fillet_face2: Optional[Any] = None
self._fillet_body: Optional[Body] = None
+ # Chamfer tool: same two-face pick flow as fillet.
+ self._chamfer_pick_active: bool = False
+ self._chamfer_face1: Optional[Any] = None
+ self._chamfer_face2: Optional[Any] = None
+ self._chamfer_body: Optional[Body] = None
+
+ # Thread tool: pick cylindrical face flow.
+ self._thread_pick_active: bool = False
+ self._thread_face: Optional[Any] = None
+ self._thread_body: Optional[Body] = None
+
self._component_buttons: List[QPushButton] = []
self._component_group: Optional[QButtonGroup] = None
@@ -1289,6 +1736,10 @@ class MainWindow(QMainWindow):
self._btn_array = ui.pb_arrayop
self._btn_fillet = ui.pb_fillet_op
self._btn_fillet.setCheckable(True)
+ self._btn_chamfer = ui.pb_chamfer
+ self._btn_chamfer.setCheckable(True)
+ self._btn_thread = ui.pb_thread
+ self._btn_thread.setCheckable(True)
# ── Export ──
self._btn_export_stl = ui.pushButton_2
self._btn_export_step = ui.pb_export_step
@@ -1408,6 +1859,14 @@ class MainWindow(QMainWindow):
self._viewer_3d.filletFacePicked.connect(self._on_fillet_face_picked)
self._viewer_3d.filletPickCancelled.connect(self._cancel_fillet_pick)
+ self._btn_chamfer.clicked.connect(self._on_chamfer_button_clicked)
+ self._viewer_3d.chamferFacePicked.connect(self._on_chamfer_face_picked)
+ self._viewer_3d.chamferPickCancelled.connect(self._cancel_chamfer_pick)
+
+ self._btn_thread.clicked.connect(self._on_thread_button_clicked)
+ self._viewer_3d.threadFacePicked.connect(self._on_thread_face_picked)
+ self._viewer_3d.threadPickCancelled.connect(self._cancel_thread_pick)
+
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)
@@ -1415,6 +1874,12 @@ class MainWindow(QMainWindow):
self._viewer_3d.facePicked.connect(self._on_face_picked)
self._viewer_3d.pickFaceCancelled.connect(lambda: self._btn_wp_face.setChecked(False))
+ # World sketch reference gizmo (selectable axis / centre / plane
+ # triad at the sketch midpoint).
+ self._viewer_3d.sketchGizmoPicked.connect(self._on_sketch_gizmo_picked)
+ self._viewer_3d.sketchGizmoHover.connect(self._on_sketch_gizmo_hover)
+ self._viewer_3d.sketchGizmoCancelled.connect(self._on_sketch_gizmo_cancelled)
+
self._btn_new_compo.clicked.connect(self._new_component)
self._btn_del_compo.clicked.connect(self._delete_component)
@@ -1657,6 +2122,9 @@ class MainWindow(QMainWindow):
if body.id in affected:
body.needs_update = True
self._refresh_lists()
+ # The sketch geometry moved — keep the world triad anchored to the
+ # new midpoint of the edited sketch.
+ self._sync_sketch_gizmo()
# Update undo/redo menu actions
self._update_undo_redo_actions()
@@ -1712,6 +2180,8 @@ class MainWindow(QMainWindow):
self._refresh_lists()
logger.info(f"Created component: {comp.name}")
+ # No sketch in the fresh component — drop the world triad.
+ self._sync_sketch_gizmo()
def _delete_component(self):
idx = self._get_active_component_index()
@@ -1784,11 +2254,37 @@ class MainWindow(QMainWindow):
return f"{index + 1}. Union"
if op == "revolve":
angle = feat.angle if feat.angle is not None else 360.0
- return f"{index + 1}. Revolve {angle:g}°"
+ if feat.axis_line_id is not None:
+ return f"{index + 1}. Revolve {angle:g}° (around sketch line)"
+ axis = feat.axis if feat.axis is not None else (0, 0, 1)
+ axis_name = {0: "X", 1: "Y", 2: "Z"}.get(
+ next((i for i, v in enumerate(axis) if v), -1), "?"
+ )
+ return f"{index + 1}. Revolve {angle:g}° (around {axis_name} axis)"
if op == "fillet":
radius = feat.radius if feat.radius is not None else 0.0
scope = " (all edges)" if feat.scope == "all" else ""
return f"{index + 1}. Fillet r={radius:g} mm{scope}"
+ if op == "chamfer":
+ size = feat.radius if feat.radius is not None else 0.0
+ scope = " (all edges)" if feat.scope == "all" else ""
+ return f"{index + 1}. Chamfer {size:g} mm{scope}"
+ if op in ("array", "pattern"):
+ ptype = feat.pattern_type if feat.pattern_type else "linear"
+ count = feat.count if feat.count else 2
+ if ptype == "circular":
+ angle = feat.angle if feat.angle is not None else 360.0
+ axis = feat.axis if feat.axis is not None else (0, 0, 1)
+ axis_name = {0: "X", 1: "Y", 2: "Z"}.get(
+ next((i for i, v in enumerate(axis) if v), -1), "?"
+ )
+ return f"{index + 1}. Circular pattern {count}× ({angle:g}° around {axis_name} axis)"
+ spacing = feat.spacing if feat.spacing is not None else 10.0
+ direction = feat.direction if feat.direction is not None else (1, 0, 0)
+ axis_name = {0: "X", 1: "Y", 2: "Z"}.get(
+ next((i for i, v in enumerate(direction) if v), -1), "?"
+ )
+ return f"{index + 1}. Linear pattern {count}× ({spacing:g} mm along {axis_name})"
if op == "base":
return f"{index + 1}. Base (baked geometry)"
return f"{index + 1}. {op.title()}"
@@ -2157,6 +2653,87 @@ class MainWindow(QMainWindow):
)
self._viewer_3d.fit_camera()
+ # Re-anchor the world sketch triad (scene clear above removed it).
+ self._sync_sketch_gizmo()
+
+ # ────────────────────────────────────────────────────────────────────
+ # World sketch reference gizmo (selectable axis / centre / plane triad)
+ # ────────────────────────────────────────────────────────────────────
+
+ def _sync_sketch_gizmo(self) -> None:
+ """Show/update the world-space reference triad at the sketch midpoint.
+
+ The triad tracks the bounding-box centre of the current sketch's
+ user geometry (mapped from UV onto the sketch workplane) so it stays
+ in sync as the sketch is edited — every re-solve re-anchors it. It
+ is hidden when no sketch is active, when the sketch no longer
+ belongs to the current component, or while the assembly view is
+ showing.
+ """
+ if self._assembly_view_active or self._current_component is None:
+ self._viewer_3d.remove_sketch_gizmo()
+ return
+ sketch = self._current_sketch
+ if sketch is None or sketch.occ_sketch is None:
+ self._viewer_3d.remove_sketch_gizmo()
+ return
+ if sketch.id not in self._current_component.sketches:
+ self._viewer_3d.remove_sketch_gizmo()
+ return
+ occ = sketch.occ_sketch
+ try:
+ wp = occ.get_workplane()
+ origin, normal, x_dir = wp[0], wp[1], wp[2]
+ except Exception:
+ self._viewer_3d.remove_sketch_gizmo()
+ return
+ bounds = _sketch_uv_bounds(occ)
+ if bounds is None:
+ u = v = 0.0
+ size = 25.0
+ else:
+ u_min, v_min, u_max, v_max = bounds
+ u = (u_min + u_max) / 2.0
+ v = (v_min + v_max) / 2.0
+ import math as _math
+
+ diag = _math.hypot(u_max - u_min, v_max - v_min)
+ size = max(diag * 0.4, 15.0)
+ size = min(size, 150.0)
+ mid = _uv_to_world(origin, normal, x_dir, u, v)
+ self._viewer_3d.show_sketch_gizmo(mid, normal, x_dir, size)
+
+ def _on_sketch_gizmo_hover(self, kind) -> None:
+ """Status-bar hint while hovering a gizmo part."""
+ if kind:
+ label = _SKETCH_GIZMO_LABELS.get(kind, kind)
+ self.statusBar().showMessage(f"{label} — click to select", 2500)
+
+ def _on_sketch_gizmo_picked(
+ self, kind, position, direction, normal, x_dir
+ ) -> None:
+ """Record a gizmo selection for downstream operations.
+
+ The selection is stored on ``self._sketch_gizmo_selection`` with
+ kind / label / position / direction / normal / x_dir so tools that
+ need a selectable axis, centre point or datum plane from the 3D
+ viewport can consume it. Also announced in the status bar.
+ """
+ label = _SKETCH_GIZMO_LABELS.get(kind, kind)
+ self._sketch_gizmo_selection = {
+ "kind": kind,
+ "label": label,
+ "position": tuple(position),
+ "direction": tuple(direction),
+ "normal": tuple(normal),
+ "x_dir": tuple(x_dir),
+ }
+ self.statusBar().showMessage(f"Selected: {label}", 4000)
+ logger.info("Sketch gizmo picked: %s", self._sketch_gizmo_selection)
+
+ def _on_sketch_gizmo_cancelled(self) -> None:
+ """Explicit gizmo-pick mode was cancelled with Esc."""
+ self.statusBar().showMessage("Sketch gizmo picking cancelled", 3000)
# ────────────────────────────────────────────────────────────────────
# Assembly methods
@@ -3582,43 +4159,91 @@ class MainWindow(QMainWindow):
# Project edges of all bodies onto the workplane.
workplane_data = (origin, normal, x_dir)
- all_polylines: List[List[Tuple[float, float]]] = []
+ all_entities = [] # Mix of polylines and circle dicts
for shape in body_shapes:
try:
- polys = _project_body_to_workplane(shape, workplane_data)
- all_polylines.extend(polys)
+ entities = _project_body_to_workplane(shape, workplane_data)
+ all_entities.extend(entities)
except Exception as exc:
logger.debug("body projection failed for a shape: %s", exc)
- if not all_polylines:
+ if not all_entities:
return
- # Import the polylines as external/underlay entities in the sketch.
+ # Import the projected entities into 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)
+ for entity in all_entities:
+ if isinstance(entity, dict):
+ etype = entity.get("type")
+ if etype == "circle":
+ # Add projected circle as a proper circle entity
+ try:
+ center_uv = tuple(float(c) for c in entity["center"])
+ radius = float(entity["radius"])
+ center_pt = occ_sketch.add_point(
+ float(center_uv[0]), float(center_uv[1])
+ )
+ _, circle = occ_sketch.add_circle(
+ center_pt, float(radius)
+ )
+ imported_count += 1
+ except Exception as exc:
+ logger.debug("circle import failed: %s", exc)
+ elif etype == "arc":
+ # Add projected arc (fillet etc.) as a proper arc entity.
+ # Reuse existing external corners (created by the
+ # polyline import above) so the arc connects to the
+ # adjacent lines at shared point entities.
+ try:
+ center_uv = (float(entity["center"][0]), float(entity["center"][1]))
+ start_uv = (float(entity["start"][0]), float(entity["start"][1]))
+ end_uv = (float(entity["end"][0]), float(entity["end"][1]))
+ radius = float(entity["radius"])
+ _MERGE_TOL = 1e-3 # generous to absorb projection float drift
+ def _find_pt(u: float, v: float):
+ best, best_d = None, _MERGE_TOL
+ for eid, ent in occ_sketch._entities.items():
+ if not getattr(ent, "is_external", False):
+ continue
+ if ent.entity_type != "point" or ent.geometry is None:
+ continue
+ d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
+ if d <= best_d:
+ best_d, best = d, ent
+ return best
+ center_pt = occ_sketch.add_point(center_uv[0], center_uv[1])
+ start_pt = _find_pt(start_uv[0], start_uv[1])
+ if start_pt is None:
+ start_pt = occ_sketch.add_point(start_uv[0], start_uv[1])
+ end_pt = _find_pt(end_uv[0], end_uv[1])
+ if end_pt is None:
+ end_pt = occ_sketch.add_point(end_uv[0], end_uv[1])
+ # sweep=None → renderer computes shortest-path arc
+ occ_sketch.add_arc(
+ center_pt, radius, start_pt, end_pt, sweep=None
+ )
+ imported_count += 1
+ except Exception as exc:
+ logger.debug("arc import failed: %s", exc)
+ elif isinstance(entity, list) and len(entity) >= 2:
+ # Polyline: add as external construction lines
+ if len(entity) < 2:
+ continue
+ try:
+ points = [(float(u), float(v)) for (u, v) in entity]
+ _, lines = occ_sketch.add_external_polyline(points)
+ 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 %d body projection entities (polylines + circles) from 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.
+ # Refresh the 2D widget's entity tracking.
self._sketch_widget._rebuild_from_sketch()
self._sketch_widget._source_workplane = workplane_data
self._sketch_widget._source_underlay_uv = []
@@ -3663,8 +4288,142 @@ class MainWindow(QMainWindow):
except Exception as e:
QMessageBox.critical(self, "Error", f"Translation failed: {e}")
- def _pattern_array(self):
- logger.info("Pattern array not yet implemented")
+ def _pattern_array(self) -> None:
+ """Array the selected body — linear or circular pattern.
+
+ Opens the ArrayDialog asking how many repeats plus the pattern
+ geometry (direction / spacing for linear, axis / origin / angle
+ for circular), with a live preview showing the copies and a
+ direction / axis indicator in the 3D view. On OK the body's
+ geometry is replaced by the union of all copies and an ``array``
+ feature is appended to its feature history so Update Body and
+ save/load replay it parametrically.
+ """
+ body = self._selected_body
+ if body is None or body.geometry is None:
+ QMessageBox.warning(
+ self,
+ "No Body",
+ "Select a body first — the array repeats the selected body.",
+ )
+ return
+
+ dialog = ArrayDialog(self)
+ # Camera auto-fit: frame the pattern's extent when the dialog
+ # opens (first preview fires immediately with defaults), then
+ # re-fit only when the user grows the pattern and copies move
+ # outside the viewport.
+ camera_fitted: Dict[str, bool] = {"done": False}
+
+ def _fit_camera(values: Dict[str, Any]) -> None:
+ try:
+ box = _array_copies_box(self._kernel, body.geometry, values)
+ except Exception:
+ logger.debug("array camera fit compute failed", exc_info=True)
+ return
+ if box is None or box.IsVoid():
+ return
+ if not camera_fitted["done"] or not self._viewer_3d.box_fully_visible(box):
+ self._viewer_3d.fit_camera_to_box(box)
+ camera_fitted["done"] = True
+
+ def _preview(values: Any) -> None:
+ if values is None:
+ self._viewer_3d.clear_preview()
+ return
+ try:
+ shape = _build_array_preview(self._kernel, body.geometry, values)
+ if shape is not None:
+ self._viewer_3d.show_preview(shape)
+ _fit_camera(values)
+ else:
+ self._viewer_3d.clear_preview()
+ except Exception:
+ logger.debug("array preview failed", exc_info=True)
+ self._viewer_3d.clear_preview()
+
+ dialog.set_preview_callback(_preview)
+
+ if dialog.exec():
+ values = dialog.get_values()
+ self._apply_array(body, values)
+
+ # Preview already cleared by the dialog's hideEvent; clear again
+ # in case the dialog was rejected programmatically.
+ self._viewer_3d.clear_preview()
+
+ def _apply_array(self, body: Body, values: Dict[str, Any]) -> None:
+ """Apply the array pattern to *body* and record it as a feature.
+
+ The body's geometry is replaced by the union (compound) of the
+ original solid and all copies; an ``array`` feature is appended
+ to the feature history so the pattern replays on Update Body and
+ survives save/load.
+ """
+ pattern_type = values.get("pattern_type", "linear")
+ count = max(1, int(values.get("count", 2)))
+ spacing = float(values.get("spacing", 10.0))
+ direction = tuple(values.get("direction", (1, 0, 0)))
+ axis = tuple(values.get("axis", (0, 0, 1)))
+ origin = tuple(values.get("origin", (0.0, 0.0, 0.0)))
+ angle = float(values.get("angle", 360.0))
+
+ try:
+ result_geom = self._kernel.pattern(
+ body.geometry,
+ pattern_type=pattern_type,
+ count=count,
+ direction=direction,
+ spacing=spacing,
+ axis=axis,
+ origin=origin,
+ angle=angle,
+ )
+ except Exception as exc:
+ logger.exception(f"Array failed: {exc}")
+ QMessageBox.critical(
+ self, "Array Failed", f"Could not create the array: {exc}"
+ )
+ return
+ if result_geom is None:
+ QMessageBox.critical(self, "Array Failed", "The array operation produced no geometry.")
+ return
+
+ # Record the operation in the feature history so it replays on
+ # Update Body and survives save/load.
+ features = _ensure_feature_history(body)
+ if not features and body.geometry is not None:
+ # Imported / baked body: freeze current geometry as the base.
+ features.append(Feature(operation="base", geometry=body.geometry))
+ features.append(
+ Feature(
+ operation="array",
+ pattern_type=pattern_type,
+ count=count,
+ spacing=spacing,
+ direction=direction,
+ axis=axis,
+ origin=origin,
+ angle=angle,
+ )
+ )
+
+ body.geometry = result_geom
+ body.needs_update = False
+ body.modified_at = datetime.now()
+ self._mark_dirty()
+
+ # Re-render the body in place (replace its AIS object).
+ if body.render_object is not None:
+ self._viewer_3d.remove_mesh(body.render_object)
+ new_shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(new_shape, body.color, body.name)
+
+ self._refresh_lists()
+ self._update_component_thumbnail(self._get_active_component_index())
+ kind = "Circular" if pattern_type == "circular" else "Linear"
+ self.statusBar().showMessage(f"{kind} array applied — {count} item(s)", 5000)
+ logger.info(f"Applied {kind.lower()} array ({count} items) to '{body.name}'")
# ─── Offset sketch ─────────────────────────────────────────────────────
@@ -4085,6 +4844,8 @@ class MainWindow(QMainWindow):
self._btn_underlay.setChecked(True)
self._btn_clr_face.setEnabled(True)
self._btn_to_sketch.setEnabled(True)
+ # Anchor the world triad at the sketch just placed on the face.
+ self._sync_sketch_gizmo()
def _on_underlay_toggled(self, checked: bool) -> None:
"""Show or hide the underlay construction lines in the 2D view.
@@ -4176,6 +4937,7 @@ class MainWindow(QMainWindow):
self._current_sketch = sketch
self._refresh_lists()
self._sketch_widget.set_mode(None)
+ self._sync_sketch_gizmo()
logger.info(f"Added sketch: {sketch.name}")
logger.info(f"=== SKETCH ADDED: {sketch.name} ===")
@@ -4224,6 +4986,8 @@ class MainWindow(QMainWindow):
self._btn_line.setChecked(True)
logger.info(f"Editing sketch: {name}")
break
+ # Anchor the world triad at the sketch being edited.
+ self._sync_sketch_gizmo()
def _on_sketch_selected(self, current, previous):
"""When sketch is selected in list, load it for editing."""
@@ -4239,6 +5003,8 @@ class MainWindow(QMainWindow):
):
self._sketch_widget.set_sketch(sketch.occ_sketch)
break
+ # Keep the world triad anchored to the newly selected sketch.
+ self._sync_sketch_gizmo()
def _delete_sketch(self):
selected = self._sketch_list.currentItem()
@@ -4265,6 +5031,8 @@ class MainWindow(QMainWindow):
if sketch.name == name:
self._current_sketch = sketch
break
+ # Keep the world triad anchored to the currently selected sketch.
+ self._sync_sketch_gizmo()
def _on_body_list_changed(self, current, previous):
if current and self._current_component:
@@ -4859,12 +5627,14 @@ class MainWindow(QMainWindow):
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity
- dialog = RevolveDialog(self)
+ line_axis = self._sketch_widget.get_selected_revolve_axis()
+ dialog = RevolveDialog(self, line_axis=line_axis)
if not dialog.exec():
logger.info("Revolve dialog cancelled")
return
- angle = dialog.angle_input.value()
+ angle, axis, origin, use_line = dialog.get_values()
+ axis_line_id = line_axis[0] if (use_line and line_axis is not None) else None
try:
face_geom = self._sketch_widget.get_selected_face_geometry()
@@ -4876,7 +5646,7 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
return
- body_geometry = self._kernel.revolve(geometry, angle)
+ body_geometry = self._kernel.revolve(geometry, angle, axis=axis, origin=origin)
body = self._current_component.add_body(
Body(
name=f"Revolution_{len(self._current_component.bodies) + 1}",
@@ -4888,6 +5658,9 @@ class MainWindow(QMainWindow):
operation="revolve",
sketch=sketch,
angle=angle,
+ axis=axis,
+ origin=origin,
+ axis_line_id=axis_line_id,
face_index=self._sketch_widget.get_selected_face_index(),
)
],
@@ -5053,9 +5826,10 @@ class MainWindow(QMainWindow):
self._fillet_body = None
self._btn_fillet.setChecked(True)
self._viewer_3d.set_fillet_pick_mode(True)
- # Disarm the sketch-on-surface picker if it was active.
+ # Disarm the other pick flows (sketch-on-surface, chamfer).
self._btn_wp_face.setChecked(False)
self._viewer_3d.set_pick_face_mode(False)
+ self._cancel_chamfer_pick()
self.statusBar().showMessage("Fillet: pick the FIRST face", 6000)
def _cancel_fillet_pick(self) -> None:
@@ -5069,6 +5843,207 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_faces_highlight()
self._viewer_3d.clear_preview()
+ # ── Thread (helical thread on cylindrical face) ────────────────────────
+
+ def _on_thread_button_clicked(self) -> None:
+ """Toggle the thread face-pick flow from the toolbar button."""
+ if self._thread_pick_active:
+ self._cancel_thread_pick()
+ else:
+ self._start_thread_pick()
+
+ def _start_thread_pick(self) -> None:
+ """Enter thread pick mode: pick a cylindrical face."""
+ if not self._current_component or not self._current_component.bodies:
+ QMessageBox.warning(
+ self,
+ "No Body",
+ "Create or import a body with a cylindrical face first, "
+ "then pick the cylinder to apply a thread.",
+ )
+ self._btn_thread.setChecked(False)
+ self._thread_pick_active = False
+ return
+ self._thread_pick_active = True
+ self._thread_face = None
+ self._thread_body = None
+ self._btn_thread.setChecked(True)
+ self._viewer_3d.set_thread_pick_mode(True)
+ # Disarm other pick modes.
+ self._btn_wp_face.setChecked(False)
+ self._viewer_3d.set_pick_face_mode(False)
+ self._btn_fillet.setChecked(False)
+ self._viewer_3d.set_fillet_pick_mode(False)
+ self._fillet_pick_active = False
+ self._cancel_chamfer_pick()
+ self.statusBar().showMessage("Thread: pick a CYLINDRICAL face on a body", 6000)
+
+ def _cancel_thread_pick(self) -> None:
+ """Abort the thread flow."""
+ self._thread_pick_active = False
+ self._thread_face = None
+ self._thread_body = None
+ self._viewer_3d.set_thread_pick_mode(False)
+ self._btn_thread.setChecked(False)
+ self._viewer_3d.clear_face_highlight()
+ self._viewer_3d.clear_preview()
+
+ def _thread_body_for_owner(self, owner_obj_id: Optional[str]) -> Optional[Body]:
+ """Resolve the body owning a picked face."""
+ if not self._current_component:
+ return None
+ if owner_obj_id:
+ for bid, body in self._current_component.bodies.items():
+ if body.render_object == owner_obj_id:
+ return body
+ if len(self._current_component.bodies) == 1:
+ return next(iter(self._current_component.bodies.values()))
+ return None
+
+ def _on_thread_face_picked(self, face: Any) -> None:
+ """Handle the thread face pick: verify it's cylindrical, then open dialog."""
+ if not self._thread_pick_active:
+ return
+ owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
+ body = self._thread_body_for_owner(owner_obj_id)
+ if body is None or body.geometry is None:
+ QMessageBox.warning(
+ self,
+ "Pick a Body Face",
+ "The picked face doesn't belong to a body in the current "
+ "component. Pick a cylindrical face on a body.",
+ )
+ return
+
+ # Check that the face is cylindrical.
+ cyl_info = self._kernel.detect_cylindrical_face(face)
+ if cyl_info is None:
+ QMessageBox.warning(
+ self,
+ "Not a Cylinder",
+ "The picked face is not cylindrical. Pick a cylindrical face "
+ "(e.g. the side of a shaft or hole).",
+ )
+ return
+
+ self._thread_face = face
+ self._thread_body = body
+ self._viewer_3d.highlight_face(face)
+ self._open_thread_dialog(cyl_info)
+
+ def _open_thread_dialog(self, cyl_info: Dict[str, Any]) -> None:
+ """Show the thread options dialog with live preview, then apply."""
+ body = self._thread_body
+ if body is None or body.geometry is None or self._thread_face is None:
+ self._cancel_thread_pick()
+ return
+
+ detected_diameter = cyl_info.get("diameter")
+ dialog = ThreadDialog(self, detected_diameter=detected_diameter)
+
+ def _preview(values: Any) -> None:
+ if values is None:
+ self._viewer_3d.clear_preview()
+ return
+ _name, nominal_dia, pitch, internal, length = values
+ try:
+ result = self._kernel.create_thread(
+ body.geometry,
+ self._thread_face,
+ nominal_diameter=nominal_dia,
+ pitch=pitch,
+ thread_length=length if length > 0 else None,
+ internal=internal,
+ )
+ if result is not None:
+ self._viewer_3d.show_preview(self._kernel._get_shape(result))
+ else:
+ self._viewer_3d.clear_preview()
+ except Exception:
+ self._viewer_3d.clear_preview()
+
+ dialog.set_preview_callback(_preview)
+
+ if dialog.exec():
+ _name, nominal_dia, pitch, internal, length = dialog.get_values()
+ self._apply_thread(nominal_dia, pitch, internal, length)
+
+ self._cancel_thread_pick()
+
+ def _apply_thread(
+ self,
+ nominal_diameter: float,
+ pitch: float,
+ internal: bool,
+ thread_length: float,
+ ) -> None:
+ """Apply the thread to the body and update the view."""
+ body = self._thread_body
+ if body is None or body.geometry is None or self._thread_face is None:
+ return
+
+ try:
+ result_geom = self._kernel.create_thread(
+ body.geometry,
+ self._thread_face,
+ nominal_diameter=nominal_diameter,
+ pitch=pitch,
+ thread_length=thread_length if thread_length > 0 else None,
+ internal=internal,
+ )
+ except Exception as exc:
+ logger.exception(f"Thread creation failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Thread Failed",
+ f"Could not create the thread: {exc}\n\n"
+ "Try a different size or check that the cylinder diameter "
+ "matches the thread nominal diameter.",
+ )
+ return
+
+ if result_geom is None:
+ QMessageBox.critical(
+ self,
+ "Thread Failed",
+ "The thread operation produced no result. "
+ "Check that the cylinder diameter matches the thread nominal diameter.",
+ )
+ return
+
+ # Record the operation in the feature history.
+ features = _ensure_feature_history(body)
+ if not features and body.geometry is not None:
+ features.append(Feature(operation="base", geometry=body.geometry))
+ features.append(
+ Feature(
+ operation="thread",
+ radius=pitch / 2.0, # reuse radius field for pitch
+ geometry=result_geom,
+ )
+ )
+
+ body.geometry = result_geom
+ body.needs_update = False
+ body.modified_at = datetime.now()
+ self._mark_dirty()
+
+ # Re-render the body.
+ if body.render_object is not None:
+ self._viewer_3d.remove_mesh(body.render_object)
+ new_shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(new_shape, body.color, body.name)
+
+ self._refresh_lists()
+ self._update_component_thumbnail(self._get_active_component_index())
+ type_str = "internal" if internal else "external"
+ self.statusBar().showMessage(
+ f"M{nominal_diameter:g} {type_str} thread applied (pitch {pitch:g} mm)", 5000
+ )
+ logger.info(
+ f"Thread M{nominal_diameter:g} ({type_str}) applied to '{body.name}'"
+ )
+
def _fillet_body_for_owner(self, owner_obj_id: Optional[str]) -> Optional[Body]:
"""Resolve the body owning a picked face (by render object id).
@@ -5226,6 +6201,185 @@ class MainWindow(QMainWindow):
)
logger.info(f"Fillet applied to '{body.name}' (radius {radius})")
+ # ── Chamfer (two-face pick → options dialog) ───────────────────────────
+
+ def _on_chamfer_button_clicked(self) -> None:
+ """Toggle the chamfer face-pick flow from the toolbar button."""
+ if self._chamfer_pick_active:
+ self._cancel_chamfer_pick()
+ else:
+ self._start_chamfer_pick()
+
+ def _start_chamfer_pick(self) -> None:
+ """Enter face-pick mode: the user picks face 1, then face 2."""
+ if not self._current_component or not self._current_component.bodies:
+ QMessageBox.warning(
+ self,
+ "No Body",
+ "Create or import a body first, then pick two of its faces "
+ "to chamfer the edge between them.",
+ )
+ # Undo the checkable toggle: we never entered pick mode.
+ self._btn_chamfer.setChecked(False)
+ self._chamfer_pick_active = False
+ return
+ self._chamfer_pick_active = True
+ self._chamfer_face1 = None
+ self._chamfer_face2 = None
+ self._chamfer_body = None
+ self._btn_chamfer.setChecked(True)
+ self._viewer_3d.set_chamfer_pick_mode(True)
+ # Disarm the other pick flows (sketch-on-surface, fillet, thread).
+ self._btn_wp_face.setChecked(False)
+ self._viewer_3d.set_pick_face_mode(False)
+ self._cancel_fillet_pick()
+ self._cancel_thread_pick()
+ self.statusBar().showMessage("Chamfer: pick the FIRST face", 6000)
+
+ def _cancel_chamfer_pick(self) -> None:
+ """Abort the chamfer flow (button toggle, Esc, or after applying)."""
+ self._chamfer_pick_active = False
+ self._chamfer_face1 = None
+ self._chamfer_face2 = None
+ self._chamfer_body = None
+ self._viewer_3d.set_chamfer_pick_mode(False)
+ self._btn_chamfer.setChecked(False)
+ self._viewer_3d.clear_faces_highlight()
+ self._viewer_3d.clear_preview()
+
+ def _on_chamfer_face_picked(self, face: Any) -> None:
+ """Handle one chamfer face pick: first face, then second → dialog."""
+ if not self._chamfer_pick_active:
+ return
+ owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
+ body = self._fillet_body_for_owner(owner_obj_id)
+ if body is None or body.geometry is None:
+ QMessageBox.warning(
+ self,
+ "Pick a Body Face",
+ "The picked face doesn't belong to a body in the current "
+ "component. Pick a face on a body.",
+ )
+ return
+
+ if self._chamfer_face1 is None:
+ self._chamfer_face1 = face
+ self._chamfer_body = body
+ self._viewer_3d.highlight_faces([face])
+ self.statusBar().showMessage("Chamfer: pick the SECOND face", 6000)
+ return
+
+ # Second face: same body, and it must share an edge with face 1.
+ if body is not self._chamfer_body:
+ QMessageBox.warning(
+ self,
+ "Different Bodies",
+ "Both faces must belong to the SAME body. Pick the second face again.",
+ )
+ return
+ shape = self._kernel._get_shape(body.geometry)
+ seed_edges = _shared_edges_between_faces(shape, self._chamfer_face1, face)
+ if not seed_edges:
+ QMessageBox.warning(
+ self,
+ "No Shared Edge",
+ "These faces don't share an edge. Pick the second face again.",
+ )
+ return
+
+ self._chamfer_face2 = face
+ self._viewer_3d.highlight_faces([self._chamfer_face1, face])
+ self._open_chamfer_dialog(seed_edges)
+
+ def _open_chamfer_dialog(self, seed_edges: List[Any]) -> None:
+ """Show the chamfer options dialog with a live preview, then apply."""
+ body = self._chamfer_body
+ if body is None or body.geometry is None:
+ self._cancel_chamfer_pick()
+ return
+ shape = self._kernel._get_shape(body.geometry)
+
+ dialog = ChamferDialog(self)
+ dialog.set_edge_count(len(seed_edges))
+
+ def _preview(values: Any) -> None:
+ if values is None:
+ self._viewer_3d.clear_preview()
+ return
+ size, tangent, scope = values
+ try:
+ edges = _resolve_fillet_edges(shape, seed_edges, tangent, scope)
+ result = self._kernel.chamfer(body.geometry, size, edges=edges)
+ self._viewer_3d.show_preview(self._kernel._get_shape(result))
+ except Exception:
+ self._viewer_3d.clear_preview()
+
+ dialog.set_preview_callback(_preview)
+
+ if dialog.exec():
+ size, tangent, scope = dialog.get_values()
+ self._apply_chamfer(shape, seed_edges, size, tangent, scope)
+
+ self._cancel_chamfer_pick()
+
+ def _apply_chamfer(
+ self,
+ shape: Any,
+ seed_edges: List[Any],
+ size: float,
+ tangent_propagation: bool,
+ scope: str,
+ ) -> None:
+ """Chamfer the body in place and record the operation as a feature."""
+ body = self._chamfer_body
+ if body is None or body.geometry is None:
+ return
+ try:
+ edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope)
+ result_geom = self._kernel.chamfer(body.geometry, size, edges=edges)
+ except Exception as exc:
+ logger.exception(f"Chamfer failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Chamfer Failed",
+ f"Could not chamfer these edges: {exc}\n\n"
+ "Try a smaller size or a different pair of faces.",
+ )
+ return
+
+ # Record the operation in the feature history so it replays on
+ # Update Body and survives save/load.
+ features = _ensure_feature_history(body)
+ if not features and body.geometry is not None:
+ features.append(Feature(operation="base", geometry=body.geometry))
+ features.append(
+ Feature(
+ operation="chamfer",
+ radius=size, # reuse radius field for chamfer size
+ tangent_propagation=tangent_propagation,
+ scope=scope,
+ edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
+ )
+ )
+
+ body.geometry = result_geom
+ body.needs_update = False
+ body.modified_at = datetime.now()
+ self._mark_dirty()
+
+ # Re-render the body in place (replace its AIS object).
+ if body.render_object is not None:
+ self._viewer_3d.remove_mesh(body.render_object)
+ new_shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(new_shape, body.color, body.name)
+
+ self._refresh_lists()
+ self._update_component_thumbnail(self._get_active_component_index())
+ self.statusBar().showMessage(
+ f"Chamfer applied — size {size:g} mm on {len(edges or [])} edge(s)", 5000
+ )
+ logger.info(f"Chamfer applied to '{body.name}' (size {size})")
+
def _delete_body(self):
selected = self._body_list.currentItem()
if not selected or not self._current_component:
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index c73460e..29367a7 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -48,7 +48,7 @@ def _project_face_to_uv(
from OCP.TopAbs import TopAbs_EDGE, TopAbs_WIRE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
- from OCP.GeomAbs import GeomAbs_Line
+ from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle
from OCP.gp import gp_Pnt
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
@@ -77,13 +77,84 @@ def _project_face_to_uv(
f = crv.FirstParameter()
l = crv.LastParameter()
is_line = crv.GetType() == GeomAbs_Line
- if is_line:
+ is_circle = crv.GetType() == GeomAbs_Circle
+ if is_circle:
+ # An OCC ``GeomAbs_Circle`` edge is a *portion* of a
+ # full circle — only when the parameter range covers
+ # the whole 2π sweep is it truly a full circle. A
+ # fillet is the same curve type but with a small
+ # parameter range (e.g. π/2 for a quarter-round).
+ # We must NOT mistake a fillet for a full circle,
+ # otherwise the projected underlay would draw a
+ # huge disc over the small fillet arc.
+ param_span = l - f
+ is_full_circle = abs(param_span - 2.0 * math.pi) < 1e-3
+ if is_full_circle:
+ # Project a full circle: get center + radius.
+ c = crv.Circle()
+ loc = c.Axis().Location()
+ center_3d = gp_Pnt(loc.X(), loc.Y(), loc.Z())
+ center_uv = world_to_uv(center_3d)
+ pt_on_circle = crv.Value(f + 0.5 * param_span)
+ uv_on_circle = world_to_uv(pt_on_circle)
+ projected_radius = np.sqrt(
+ (uv_on_circle[0] - center_uv[0]) ** 2
+ + (uv_on_circle[1] - center_uv[1]) ** 2
+ )
+ polylines.append(
+ {
+ "type": "circle",
+ "center": list(center_uv),
+ "radius": float(projected_radius),
+ }
+ )
+ else:
+ # Arc segment of a circle (e.g. a fillet).
+ # Emit it as an arc dict so the importer can
+ # create a real arc entity with start, end, and
+ # centre points — sampling it as a polyline
+ # would show as a cluster of 33 dot entities
+ # on the small fillet.
+ c = crv.Circle()
+ loc = c.Axis().Location()
+ center_3d = gp_Pnt(loc.X(), loc.Y(), loc.Z())
+ center_uv = list(world_to_uv(center_3d))
+ start_3d = crv.Value(f)
+ end_3d = crv.Value(l)
+ start_uv = list(world_to_uv(start_3d))
+ end_uv = list(world_to_uv(end_3d))
+ # Radius from a mid-param point keeps the
+ # projected value correct when the workplane
+ # isn't perfectly axis-aligned to the 3D circle.
+ mid_3d = crv.Value(f + 0.5 * param_span)
+ mid_uv = world_to_uv(mid_3d)
+ projected_radius = math.sqrt(
+ (mid_uv[0] - center_uv[0]) ** 2
+ + (mid_uv[1] - center_uv[1]) ** 2
+ )
+ # NOTE: we do NOT emit the OCC parameter sweep
+ # because it may point the wrong way around the
+ # circle (rendering would draw the complement
+ # arc). The renderer infers the shortest-path
+ # angular span from start/end/centre geometry.
+ polylines.append(
+ {
+ "type": "arc",
+ "center": center_uv,
+ "start": start_uv,
+ "end": end_uv,
+ "radius": float(projected_radius),
+ }
+ )
+ elif is_line:
pts = [crv.Value(f), crv.Value(l)]
+ poly = [world_to_uv(p) for p in pts]
+ polylines.append(poly)
else:
# Sample 32 segments across the parameter range.
pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)]
- poly = [world_to_uv(p) for p in pts]
- polylines.append(poly)
+ poly = [world_to_uv(p) for p in pts]
+ polylines.append(poly)
except Exception:
pass
edge_expl.Next()
@@ -382,6 +453,15 @@ class Sketch2DWidget(QWidget):
fixed via ``dragged``, so a user drag of a related entity never moves
the underlay.
+ Circle dicts (``{"type": "circle", ...}``) become proper external
+ circle entities so circular face boundaries round-trip as circles
+ with center points, not as 32-segment polylines.
+
+ Arc dicts (``{"type": "arc", "center", "start", "end", "radius", "sweep"}``)
+ become proper external arc entities so fillets show as smooth
+ fillet curves instead of clusters of dot entities (which is what
+ happens when an arc is sampled as a polyline).
+
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.
"""
@@ -390,20 +470,89 @@ class Sketch2DWidget(QWidget):
# 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()
- # Import ALL polylines in one call so corners shared between edges
- # become a single external point entity (one connection hub per
- # corner) instead of stacked duplicates.
- polys = [
- [(float(u), float(v)) for (u, v) in poly]
- for poly in self._source_underlay_uv
- if len(poly) >= 2
- ]
+ # Split polylines, circle dicts, and arc dicts so each can be
+ # imported with the right sketch method.
+ polys: List[List[Tuple[float, float]]] = []
+ circles: List[Dict[str, Any]] = []
+ arcs: List[Dict[str, Any]] = []
+ for entry in self._source_underlay_uv:
+ if isinstance(entry, dict):
+ etype = entry.get("type")
+ if etype == "circle":
+ circles.append(entry)
+ elif etype == "arc":
+ arcs.append(entry)
+ elif isinstance(entry, list) and len(entry) >= 2:
+ polys.append([(float(u), float(v)) for (u, v) in entry])
imported = 0
try:
_, lines = self._sketch.add_external_polylines(polys)
- imported = len(lines)
+ imported += len(lines)
except Exception as exc:
logger.debug("underlay polyline import failed: %s", exc)
+ # Import circles as proper external circle entities so the user
+ # sees a real circle with a center point (not a 32-segment polyline).
+ for c in circles:
+ try:
+ center_uv = tuple(float(v) for v in c["center"])
+ radius = float(c["radius"])
+ center_pt = self._sketch.add_external_point(
+ float(center_uv[0]), float(center_uv[1])
+ )
+ self._sketch.add_circle(center_pt, radius)
+ imported += 1
+ except Exception as exc:
+ logger.debug("underlay circle import failed: %s", exc)
+ # Import arcs as proper external arc entities so fillets show as
+ # smooth arcs (centre + 2 endpoints), not as clusters of dots.
+ #
+ # IMPORTANT: arc start/end must share the same external point
+ # entities that ``add_external_polylines`` already created for the
+ # adjacent line corners. Otherwise the arc endpoints exist as
+ # separate entities floating near (but not coincident with) the
+ # polyline corners, and the arc visually doesn't connect to the
+ # lines. We look up existing external points by UV position with
+ # a small tolerance so corners are a single shared entity.
+ _MERGE_TOL = 1e-3 # generous to absorb projection float drift
+ def _find_pt(u: float, v: float):
+ """Return an existing external point within tolerance, or None."""
+ best, best_d = None, _MERGE_TOL
+ for eid, ent in self._sketch._entities.items():
+ if not getattr(ent, "is_external", False):
+ continue
+ if ent.entity_type != "point" or ent.geometry is None:
+ continue
+ d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
+ if d <= best_d:
+ best_d, best = d, ent
+ return best
+ for a in arcs:
+ try:
+ center_uv = (float(a["center"][0]), float(a["center"][1]))
+ start_uv = (float(a["start"][0]), float(a["start"][1]))
+ end_uv = (float(a["end"][0]), float(a["end"][1]))
+ radius = float(a["radius"])
+
+ center_pt = self._sketch.add_external_point(
+ center_uv[0], center_uv[1]
+ )
+ start_pt = _find_pt(start_uv[0], start_uv[1])
+ if start_pt is None:
+ start_pt = self._sketch.add_external_point(
+ start_uv[0], start_uv[1]
+ )
+ end_pt = _find_pt(end_uv[0], end_uv[1])
+ if end_pt is None:
+ end_pt = self._sketch.add_external_point(
+ end_uv[0], end_uv[1]
+ )
+ # sweep=None → renderer computes shortest-path arc
+ self._sketch.add_arc(
+ center_pt, radius, start_pt, end_pt, sweep=None
+ )
+ imported += 1
+ except Exception as exc:
+ logger.debug("underlay arc 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.
@@ -413,8 +562,23 @@ class Sketch2DWidget(QWidget):
"""Centre & scale the 2D view to fit the source face's UV bounds."""
if not self._source_underlay_uv:
return
- # Collect all UV points across all cached polylines.
- all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
+ # Collect UV points across polyline + circle + arc entries so a
+ # circular or arc boundary is included in the fit bounds.
+ all_pts: List[Tuple[float, float]] = []
+ for entry in self._source_underlay_uv:
+ if isinstance(entry, dict):
+ etype = entry.get("type")
+ if etype == "circle":
+ cu, cv = entry["center"]
+ r = float(entry["radius"])
+ all_pts.append((cu - r, cv - r))
+ all_pts.append((cu + r, cv + r))
+ elif etype == "arc":
+ all_pts.append(tuple(entry["center"]))
+ all_pts.append(tuple(entry["start"]))
+ all_pts.append(tuple(entry["end"]))
+ elif isinstance(entry, list):
+ all_pts.extend(entry)
if not all_pts:
return
us = [p[0] for p in all_pts]
@@ -444,6 +608,25 @@ class Sketch2DWidget(QWidget):
return self._sketch.build_face_geometry(self._selected_face)
return None
+ def get_selected_revolve_axis(self) -> Optional[Tuple[int, Tuple, Tuple]]:
+ """Return ``(line_entity_id, origin, direction)`` for the selected line.
+
+ The first selected line entity (regular or construction) is used as the
+ revolve axis: origin = its start point, direction = normalized
+ start→end, both in world coordinates. Returns ``None`` when no usable
+ line is selected. External (underlay) reference lines are ignored.
+ """
+ if not self._sketch or not self._selected_entities:
+ return None
+ for ent in self._selected_entities:
+ if ent.entity_type != "line" or ent.is_external:
+ continue
+ axis = self._sketch.get_line_axis(ent.id)
+ if axis is None:
+ continue
+ return (ent.id, axis[0], axis[1])
+ return None
+
def get_selected_face_index(self) -> Optional[int]:
"""Return the index of the selected face in detect_faces(), or None."""
if self._selected_face is None or self._sketch is None:
@@ -3689,16 +3872,39 @@ class Sketch2DWidget(QWidget):
# 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))))
- for (u, v) in self._source_underlay_uv[0]
- ]
- )
- painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
- painter.setPen(Qt.NoPen)
- painter.drawPolygon(fill_poly)
+ # Tint-fill the first polyline (or a polyline-shaped entry) so
+ # the projected face reads as a region in 2D. Circle-only
+ # faces (just one circle entry, no polylines) get a filled
+ # disc instead so a circular face still shows the fill.
+ filled = False
+ for entry in self._source_underlay_uv:
+ if isinstance(entry, list) and len(entry) >= 3:
+ fill_poly = QPolygonF(
+ [
+ self._world_to_screen(QPoint(int(round(u)), int(round(v))))
+ for (u, v) in entry
+ ]
+ )
+ painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
+ painter.setPen(Qt.NoPen)
+ painter.drawPolygon(fill_poly)
+ filled = True
+ break
+ if not filled:
+ for entry in self._source_underlay_uv:
+ if isinstance(entry, dict) and entry.get("type") == "circle":
+ cu, cv = entry["center"]
+ r = float(entry["radius"])
+ center_screen = self._world_to_screen(
+ QPoint(int(round(cu)), int(round(cv)))
+ )
+ # Convert world radius to screen pixels using the
+ # current zoom so the disc scales with the view.
+ r_screen = int(round(r * self._zoom))
+ painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
+ painter.setPen(Qt.NoPen)
+ painter.drawEllipse(center_screen, r_screen, r_screen)
+ break
# ── Points ──
for entity in self._points:
@@ -3752,6 +3958,46 @@ class Sketch2DWidget(QWidget):
painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387")))
painter.drawEllipse(screen_pos, 4, 4)
+ # Draw external arcs (underlay fillets etc.) in orange dashed
+ # so they match the underlay style instead of the bright blue
+ # used for user-drawn arcs.
+ for center_ent, radius in self._circles:
+ if not self._is_external(center_ent):
+ continue
+ if not center_ent.geometry:
+ continue
+ cx, cy = center_ent.geometry
+ sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = int(radius * self._zoom)
+ painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(sc, sr, sr)
+ for arc_item in self._arcs:
+ center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
+ if not self._is_external(center_ent):
+ continue
+ if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
+ continue
+ cx, cy = center_ent.geometry
+ sx, sy = start_ent.geometry
+ ex, ey = end_ent.geometry
+ sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = int(radius * self._zoom)
+ if sweep is None:
+ sa = math.atan2(sy - cy, sx - cx)
+ ea = math.atan2(ey - cy, ex - cx)
+ sweep = ea - sa
+ while sweep > math.pi:
+ sweep -= 2 * math.pi
+ while sweep < -math.pi:
+ sweep += 2 * math.pi
+ start_angle = math.atan2(sy - cy, sx - cx)
+ start_deg_16 = int(math.degrees(start_angle) * 16)
+ span_deg_16 = int(math.degrees(sweep) * 16)
+ rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
+ painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawArc(rect, start_deg_16, span_deg_16)
# ── Lines ──
for p1_ent, p2_ent in self._lines:
@@ -3823,6 +4069,10 @@ class Sketch2DWidget(QWidget):
# ── Circles ──
for center_ent, radius in self._circles:
+ # External (underlay) circles are drawn in the underlay block
+ # above; skip here to avoid double-drawing them in blue.
+ if self._is_external(center_ent):
+ continue
if center_ent.geometry:
cx, cy = center_ent.geometry
sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
@@ -3834,6 +4084,10 @@ class Sketch2DWidget(QWidget):
# ── Arcs ──
for arc_item in self._arcs:
center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
+ # External (underlay) arcs are drawn in the underlay block
+ # above; skip here to avoid double-drawing them in blue.
+ if self._is_external(center_ent):
+ continue
if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
continue
cx, cy = center_ent.geometry
diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py
index e2fe135..20d2628 100644
--- a/src/fluency/ui/viewer_widget.py
+++ b/src/fluency/ui/viewer_widget.py
@@ -27,6 +27,17 @@ class Viewer3DWidget(QWidget):
# Emitted when fillet pick mode is cancelled (Esc).
filletPickCancelled = Signal()
+ # Emitted when the user picks a face for the chamfer tool (ANY face,
+ # planar or curved). Payload: the raw TopoDS_Face.
+ chamferFacePicked = Signal(object)
+ # Emitted when chamfer pick mode is cancelled (Esc).
+ chamferPickCancelled = Signal()
+
+ # Emitted when the user picks a cylindrical face for the thread tool.
+ threadFacePicked = Signal(object)
+ # Emitted when thread pick mode is cancelled (Esc).
+ threadPickCancelled = Signal()
+
# Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
@@ -48,6 +59,22 @@ class Viewer3DWidget(QWidget):
# Payload: (eye, at, up) — each is a tuple of 3 floats.
cameraChanged = Signal(tuple, tuple, tuple)
+ # Emitted when the user clicks a part of the world-space sketch gizmo
+ # (the triad at the sketch midpoint). Payload:
+ # kind — "center" | "axis_x" | "axis_y" | "axis_z" |
+ # "plane_xy" | "plane_yz" | "plane_xz"
+ # position — picked world point (triad origin for axes/center,
+ # plane quad centre for planes)
+ # direction — axis unit vector / plane normal / (0,0,0) for center
+ # normal — sketch workplane normal
+ # x_dir — sketch workplane x direction
+ sketchGizmoPicked = Signal(str, tuple, tuple, tuple, tuple)
+ # Hover feedback: part kind string under the cursor, or None when the
+ # cursor left the gizmo.
+ sketchGizmoHover = Signal(object)
+ # Emitted when gizmo pick mode is cancelled (Esc) so the host can reset.
+ sketchGizmoCancelled = Signal()
+
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
@@ -73,6 +100,10 @@ class Viewer3DWidget(QWidget):
self._pick_face_mode: bool = False
# When True, a left-click picks ANY face for the fillet tool.
self._fillet_pick_mode: bool = False
+ # When True, a left-click picks ANY face for the chamfer tool.
+ self._chamfer_pick_mode: bool = False
+ # When True, a left-click picks a cylindrical face for the thread tool.
+ self._thread_pick_mode: bool = False
# When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
@@ -96,6 +127,16 @@ class Viewer3DWidget(QWidget):
# target a cut/union extrude against the body the sketch was
# projected onto).
self._last_pick_owner_obj_id: Optional[str] = None
+ # World-space sketch reference gizmo (triad at the sketch midpoint).
+ # ``_sketch_gizmo_frame`` is (origin, normal, x_dir) of the gizmo;
+ # None while no triad is shown.
+ self._sketch_gizmo_frame: Optional[Tuple[tuple, tuple, tuple]] = None
+ # When True, a left-click picks gizmo parts only (no orbit); Esc
+ # cancels. Otherwise the gizmo is pickable implicitly during normal
+ # navigation (Fusion-style) whenever it is shown.
+ self._sketch_gizmo_pick_mode: bool = False
+ # Currently hovered gizmo part kind (for highlight bookkeeping).
+ self._sketch_gizmo_hover_kind: Optional[str] = None
def _init_renderer(self) -> None:
"""Create the best available renderer."""
@@ -284,6 +325,35 @@ class Viewer3DWidget(QWidget):
self._renderer.fit_camera()
self._renderer.render()
+ def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
+ """Fit the camera to a specific 3D bounding box (``Bnd_Box``).
+
+ Used by the array tool so the dialog preview frames exactly the
+ space the pattern copies occupy. Falls back to fitting the whole
+ scene on renderers without box fitting (e.g. the Pygfx fallback).
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "fit_camera_to_box", None)
+ if fn is not None:
+ fn(bnd_box, padding)
+ self._renderer.render()
+ return
+ self.fit_camera()
+
+ def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
+ """True when the box's 8 corners all project inside the viewport.
+
+ The array preview uses this to decide when a grown pattern has
+ moved copies off-screen and the camera needs re-fitting. On
+ renderers without the check (Pygfx fallback) it returns True so
+ no re-fit is forced.
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "box_fully_visible", None)
+ if fn is not None:
+ return bool(fn(bnd_box, margin))
+ return True
+
# ─── Workplane visualization ───────────────────────────────────────────
def show_workplane(
@@ -328,6 +398,14 @@ class Viewer3DWidget(QWidget):
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_fillet_face_pick(event)
return
+ # Chamfer pick mode: a left-click selects any face (planar or curved).
+ if self._chamfer_pick_mode and event.button() == Qt.MouseButton.LeftButton:
+ self._handle_chamfer_face_pick(event)
+ return
+ # Thread pick mode: a left-click selects a cylindrical face.
+ if self._thread_pick_mode and event.button() == Qt.MouseButton.LeftButton:
+ self._handle_thread_face_pick(event)
+ return
# Connector pick mode: a left-click selects a face for a connection point.
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_connector_pick(event)
@@ -336,6 +414,20 @@ class Viewer3DWidget(QWidget):
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_assembly_move_press(event)
return
+ # World sketch gizmo: a click on a part selects it (Fusion-style),
+ # even during normal navigation. In explicit gizmo-pick mode an
+ # off-gizmo click does nothing (no orbit); otherwise it falls
+ # through to orbit/pan below.
+ if event.button() == Qt.MouseButton.LeftButton and self._sketch_gizmo_enabled():
+ fn = getattr(self._renderer, "pick_sketch_gizmo", None)
+ if fn is not None:
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ kind = fn(pos.x(), pos.y())
+ if kind is not None:
+ self._handle_sketch_gizmo_pick(event, kind)
+ return
+ if self._sketch_gizmo_pick_mode:
+ return # explicit mode: off-gizmo clicks do not orbit
self._renderer.handle_mouse_press(event)
super().mousePressEvent(event)
@@ -365,11 +457,29 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
+ # In chamfer pick mode, keep dynamic highlighting too.
+ if self._chamfer_pick_mode:
+ if hasattr(self._renderer, "handle_mouse_move"):
+ self._renderer.handle_mouse_move(event)
+ super().mouseMoveEvent(event)
+ return
+ # In thread pick mode, keep dynamic highlighting.
+ if self._thread_pick_mode:
+ if hasattr(self._renderer, "handle_mouse_move"):
+ self._renderer.handle_mouse_move(event)
+ super().mouseMoveEvent(event)
+ return
# Active drag in assembly move mode.
if self._move_drag_active:
self._handle_assembly_move_move(event)
super().mouseMoveEvent(event)
return
+ # World sketch gizmo hover: highlight the part under the cursor.
+ if self._sketch_gizmo_enabled():
+ self._handle_sketch_gizmo_hover(event)
+ if self._sketch_gizmo_pick_mode:
+ super().mouseMoveEvent(event)
+ return
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
@@ -456,6 +566,127 @@ class Viewer3DWidget(QWidget):
return self._renderer.get_camera_fov()
return 45.0
+ # ─── World sketch reference gizmo (triad at the sketch midpoint) ────────
+
+ def show_sketch_gizmo(
+ self,
+ origin: Tuple[float, float, float],
+ normal: Tuple[float, float, float],
+ x_dir: Tuple[float, float, float],
+ size: float = 40.0,
+ ) -> None:
+ """Show the selectable X/Y/Z triad in the 3D world at *origin*.
+
+ *origin* should be the midpoint of the active sketch's geometry and
+ the triad is aligned to the sketch's workplane frame. Call again
+ with a new origin to keep it in sync as the sketch is edited.
+ No-op on renderers without gizmo support (Pygfx fallback).
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "show_sketch_gizmo", None)
+ if fn is None:
+ return
+ fn(origin, normal, x_dir, size)
+ self._sketch_gizmo_frame = (tuple(origin), tuple(normal), tuple(x_dir))
+ self._sketch_gizmo_hover_kind = None
+ self._renderer.render()
+
+ def remove_sketch_gizmo(self) -> None:
+ """Hide the world sketch triad, if any."""
+ if self._initialized and self._renderer is not None:
+ fn = getattr(self._renderer, "remove_sketch_gizmo", None)
+ if fn is not None:
+ fn()
+ self._renderer.render()
+ self._sketch_gizmo_frame = None
+ self._sketch_gizmo_hover_kind = None
+
+ def set_sketch_gizmo_pick_mode(self, enabled: bool) -> None:
+ """Toggle explicit gizmo-pick mode.
+
+ When enabled, left-clicks select gizmo parts only (the camera does
+ not orbit) and Esc exits the mode. When disabled, the gizmo is
+ still pickable implicitly during normal navigation (Fusion-style)
+ whenever it is shown. Mutually exclusive with the other pick modes.
+ """
+ self._sketch_gizmo_pick_mode = bool(enabled)
+ if enabled:
+ self._pick_face_mode = False
+ self._fillet_pick_mode = False
+ self._chamfer_pick_mode = False
+ self._thread_pick_mode = False
+ self._connector_pick_mode = False
+ self._assembly_move_mode = False
+ self._move_drag_active = False
+ self.setCursor(Qt.CursorShape.CrossCursor)
+ self.setFocus()
+ elif not (
+ self._pick_face_mode
+ or self._fillet_pick_mode
+ or self._chamfer_pick_mode
+ or self._thread_pick_mode
+ or self._connector_pick_mode
+ ):
+ self.unsetCursor()
+
+ def is_sketch_gizmo_pick_mode(self) -> bool:
+ return self._sketch_gizmo_pick_mode
+
+ def get_sketch_gizmo_frame(self) -> Optional[Tuple[tuple, tuple, tuple]]:
+ """Return the (origin, normal, x_dir) of the shown triad, or None."""
+ return self._sketch_gizmo_frame
+
+ def _sketch_gizmo_enabled(self) -> bool:
+ """True when the triad is shown AND no other mode owns the pointer."""
+ if self._sketch_gizmo_frame is None:
+ return False
+ if self._sketch_gizmo_pick_mode:
+ return True
+ if self._assembly_move_mode or self._move_drag_active:
+ return False
+ return not (
+ self._pick_face_mode
+ or self._fillet_pick_mode
+ or self._chamfer_pick_mode
+ or self._thread_pick_mode
+ or self._connector_pick_mode
+ )
+
+ def _handle_sketch_gizmo_hover(self, event) -> None:
+ """Highlight the gizmo part under the cursor and emit hover signal."""
+ fn = getattr(self._renderer, "pick_sketch_gizmo", None)
+ if fn is None:
+ return
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ kind = fn(pos.x(), pos.y())
+ if kind == self._sketch_gizmo_hover_kind:
+ return
+ self._sketch_gizmo_hover_kind = kind
+ if kind is not None:
+ hl = getattr(self._renderer, "highlight_sketch_gizmo_part", None)
+ if hl is not None:
+ hl(kind)
+ else:
+ cl = getattr(self._renderer, "clear_sketch_gizmo_highlight", None)
+ if cl is not None:
+ cl()
+ self.sketchGizmoHover.emit(kind)
+
+ def _handle_sketch_gizmo_pick(self, event, kind: str) -> None:
+ """Emit sketchGizmoPicked for the clicked part with world metadata."""
+ frame = self._sketch_gizmo_frame
+ normal = tuple(frame[1]) if frame else (0.0, 0.0, 1.0)
+ x_dir = tuple(frame[2]) if frame else (1.0, 0.0, 0.0)
+ position = tuple(frame[0]) if frame else (0.0, 0.0, 0.0)
+ direction = (0.0, 0.0, 0.0)
+ info_fn = getattr(self._renderer, "sketch_gizmo_pick_info", None)
+ if info_fn is not None:
+ info = info_fn(kind)
+ if info:
+ position = tuple(info["position"])
+ direction = tuple(info["direction"])
+ self.sketchGizmoPicked.emit(kind, position, direction, normal, x_dir)
+
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None:
@@ -466,6 +697,7 @@ class Viewer3DWidget(QWidget):
"""
self._pick_face_mode = bool(enabled)
if enabled:
+ self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.CrossCursor)
else:
self.unsetCursor()
@@ -484,19 +716,59 @@ class Viewer3DWidget(QWidget):
"""
self._fillet_pick_mode = bool(enabled)
if enabled:
+ self._sketch_gizmo_pick_mode = False
# Pick modes are mutually exclusive — entering fillet mode
- # disables sketch-on-surface / connector / assembly modes.
+ # disables chamfer / sketch-on-surface / connector / assembly modes.
+ self._chamfer_pick_mode = False
self._pick_face_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
- elif not self._pick_face_mode and not self._connector_pick_mode:
+ elif not self._pick_face_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_fillet_pick_mode(self) -> bool:
return self._fillet_pick_mode
+ # ─── Chamfer pick mode (any-face picking) ──────────────────────────────
+
+ def set_chamfer_pick_mode(self, enabled: bool) -> None:
+ """Toggle chamfer face-pick mode (any face — planar or curved).
+
+ The cursor selects faces for the chamfer tool instead of orbiting
+ the camera. Mutually exclusive with the other pick modes.
+ """
+ self._chamfer_pick_mode = bool(enabled)
+ if enabled:
+ self._sketch_gizmo_pick_mode = False
+ self._fillet_pick_mode = False
+ self._pick_face_mode = False
+ self._connector_pick_mode = False
+ self._assembly_move_mode = False
+ self._move_drag_active = False
+ self.setCursor(Qt.CursorShape.CrossCursor)
+ elif not self._pick_face_mode and not self._fillet_pick_mode and not self._connector_pick_mode:
+ self.unsetCursor()
+
+ def is_chamfer_pick_mode(self) -> bool:
+ return self._chamfer_pick_mode
+
+ def _handle_chamfer_face_pick(self, event: Any) -> None:
+ """Detect any face under the click and emit chamferFacePicked."""
+ self._ensure_initialized()
+ picker = getattr(self._renderer, "pick_face", None)
+ if picker is None:
+ logger.warning("Renderer has no pick_face support")
+ return
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ info = picker(pos.x(), pos.y())
+ if info is None:
+ logger.info("Chamfer face pick: no face under cursor")
+ return
+ self._last_pick_owner_obj_id = info.get("owner_obj_id")
+ self.chamferFacePicked.emit(info["face"])
+
def highlight_faces(self, faces: List[Any]) -> None:
"""Tint all faces in *faces* so both fillet picks stay visible."""
self._ensure_initialized()
@@ -535,6 +807,45 @@ class Viewer3DWidget(QWidget):
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.filletFacePicked.emit(info["face"])
+ # ─── Thread pick mode ─────────────────────────────────────────────────
+
+ def set_thread_pick_mode(self, enabled: bool) -> None:
+ """Toggle thread face-pick mode (cylindrical face only).
+
+ When enabled, a left-click picks a cylindrical face for the thread
+ tool. Mutually exclusive with other pick modes.
+ """
+ self._thread_pick_mode = bool(enabled)
+ if enabled:
+ self._sketch_gizmo_pick_mode = False
+ self._pick_face_mode = False
+ self._fillet_pick_mode = False
+ self._chamfer_pick_mode = False
+ self._connector_pick_mode = False
+ self._assembly_move_mode = False
+ self._move_drag_active = False
+ self.setCursor(Qt.CursorShape.CrossCursor)
+ elif not self._pick_face_mode and not self._fillet_pick_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
+ self.unsetCursor()
+
+ def is_thread_pick_mode(self) -> bool:
+ return self._thread_pick_mode
+
+ def _handle_thread_face_pick(self, event: Any) -> None:
+ """Detect any face under the click and emit threadFacePicked."""
+ self._ensure_initialized()
+ picker = getattr(self._renderer, "pick_face", None)
+ if picker is None:
+ logger.warning("Renderer has no pick_face support")
+ return
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ info = picker(pos.x(), pos.y())
+ if info is None:
+ logger.info("Thread face pick: no face under cursor")
+ return
+ self._last_pick_owner_obj_id = info.get("owner_obj_id")
+ self.threadFacePicked.emit(info["face"])
+
def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized()
@@ -570,6 +881,7 @@ class Viewer3DWidget(QWidget):
"""
self._connector_pick_mode = bool(enabled)
if enabled:
+ self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting.
@@ -795,6 +1107,7 @@ class Viewer3DWidget(QWidget):
"""
self._assembly_move_mode = bool(enabled)
if enabled:
+ self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor()
@@ -964,6 +1277,16 @@ class Viewer3DWidget(QWidget):
self.set_fillet_pick_mode(False)
self.filletPickCancelled.emit()
return
+ # Esc cancels chamfer pick mode.
+ if self._chamfer_pick_mode and event.key() == Qt.Key.Key_Escape:
+ self.set_chamfer_pick_mode(False)
+ self.chamferPickCancelled.emit()
+ return
+ # Esc cancels thread pick mode.
+ if self._thread_pick_mode and event.key() == Qt.Key.Key_Escape:
+ self.set_thread_pick_mode(False)
+ self.threadPickCancelled.emit()
+ return
# Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_connector_pick_mode(False)
@@ -973,6 +1296,11 @@ class Viewer3DWidget(QWidget):
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
self.set_assembly_move_mode(False)
return
+ # Esc cancels explicit sketch-gizmo pick mode.
+ if self._sketch_gizmo_pick_mode and event.key() == Qt.Key.Key_Escape:
+ self.set_sketch_gizmo_pick_mode(False)
+ self.sketchGizmoCancelled.emit()
+ return
# Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset).
self._ensure_initialized()