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 @@ + + + + + + + + + + + - + @@ -463,7 +474,15 @@ 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()