- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-05 16:47:25 +02:00
parent a7e7a9f2c3
commit 1e9b0cab1c
15 changed files with 3506 additions and 92 deletions
+22 -3
View File
@@ -6,9 +6,20 @@
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -78,7 +89,7 @@
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
@@ -93,8 +104,8 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
@@ -463,7 +474,15 @@
<option name="project" value="LOCAL" />
<updated>1785701099363</updated>
</task>
<option name="localTasksCounter" value="44" />
<task id="LOCAL-00044" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785915859137</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1785915859137</updated>
</task>
<option name="localTasksCounter" value="45" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
+3 -3
View File
@@ -1180,7 +1180,7 @@
<item row="2" column="0">
<widget class="QPushButton" name="pb_arrayop">
<property name="text">
<string>Arry</string>
<string>Array</string>
</property>
</widget>
</item>
@@ -1220,9 +1220,9 @@
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="pb_face_op">
<widget class="QPushButton" name="pb_chamfer">
<property name="text">
<string>Phase</string>
<string>Chamfer</string>
</property>
</widget>
</item>
+5 -5
View File
@@ -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))
+426 -1
View File
@@ -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
+33
View File
@@ -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).
+14
View File
@@ -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:
+2
View File
@@ -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",
]
+30 -5
View File
@@ -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)
+425
View File
@@ -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,
+43 -1
View File
@@ -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."""
+696 -3
View File
@@ -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 (M1M32) 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",
)
+1 -1
View File
@@ -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))
File diff suppressed because it is too large Load Diff
+279 -25
View File
@@ -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
startend, 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
+330 -2
View File
@@ -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()