- arc improvements, fillets, operations, bodys
This commit is contained in:
Generated
+22
-3
@@ -6,9 +6,20 @@
|
|||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
|
<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$/.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/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/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/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>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -78,7 +89,7 @@
|
|||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
@@ -93,8 +104,8 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
<recent name="$PROJECT_DIR$/modules" />
|
<recent name="$PROJECT_DIR$/modules" />
|
||||||
@@ -463,7 +474,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1785701099363</updated>
|
<updated>1785701099363</updated>
|
||||||
</task>
|
</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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|||||||
@@ -1180,7 +1180,7 @@
|
|||||||
<item row="2" column="0">
|
<item row="2" column="0">
|
||||||
<widget class="QPushButton" name="pb_arrayop">
|
<widget class="QPushButton" name="pb_arrayop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Arry</string>
|
<string>Array</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
@@ -1220,9 +1220,9 @@
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="3" column="1">
|
<item row="3" column="1">
|
||||||
<widget class="QPushButton" name="pb_face_op">
|
<widget class="QPushButton" name="pb_chamfer">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Phase</string>
|
<string>Chamfer</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -663,10 +663,10 @@ class Ui_fluencyCAD(object):
|
|||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||||
|
|
||||||
self.pb_face_op = QPushButton(self.groupBox)
|
self.pb_chamfer = QPushButton(self.groupBox)
|
||||||
self.pb_face_op.setObjectName(u"pb_face_op")
|
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 = QPushButton(self.groupBox)
|
||||||
self.pb_thread.setObjectName(u"pb_thread")
|
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.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", 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_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
|
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
|
||||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", 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.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
||||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the geometry kernel
|
|||||||
using OCP (OpenCASCADE Python bindings).
|
using OCP (OpenCASCADE Python bindings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import List, Tuple, Optional, Any, Dict
|
from typing import List, Tuple, Optional, Any, Dict
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ from fluency.geometry.base import (
|
|||||||
Point3D,
|
Point3D,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OCCGeometryObject(GeometryObject):
|
class OCCGeometryObject(GeometryObject):
|
||||||
"""Geometry object wrapper for OpenCASCADE shapes."""
|
"""Geometry object wrapper for OpenCASCADE shapes."""
|
||||||
@@ -480,10 +483,11 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
else:
|
else:
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
while explorer.More():
|
while explorer.More():
|
||||||
chamfer.Add(size, explorer.Current())
|
chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
chamfer.Build()
|
chamfer.Build()
|
||||||
@@ -576,6 +580,67 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
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:
|
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||||
"""Export to STEP format."""
|
"""Export to STEP format."""
|
||||||
try:
|
try:
|
||||||
@@ -868,3 +933,363 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
cg = props.CentreOfMass()
|
cg = props.CentreOfMass()
|
||||||
return Point3D(cg.X(), cg.Y(), cg.Z())
|
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
|
||||||
|
|||||||
@@ -1779,6 +1779,39 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
return points
|
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]:
|
def get_polygon_points(self) -> List[Point2D]:
|
||||||
"""Get ordered polygon points from connected lines (uses solved positions).
|
"""Get ordered polygon points from connected lines (uses solved positions).
|
||||||
|
|
||||||
|
|||||||
@@ -219,10 +219,17 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
|
|||||||
"cut_all_bodies": bool(feat.cut_all_bodies),
|
"cut_all_bodies": bool(feat.cut_all_bodies),
|
||||||
"face_index": feat.face_index,
|
"face_index": feat.face_index,
|
||||||
"angle": _to_float(feat.angle, 360.0),
|
"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,
|
"radius": feat.radius,
|
||||||
"tangent_propagation": bool(feat.tangent_propagation),
|
"tangent_propagation": bool(feat.tangent_propagation),
|
||||||
"scope": feat.scope,
|
"scope": feat.scope,
|
||||||
"edge_refs": list(feat.edge_refs),
|
"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)),
|
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
|
||||||
face_index=data.get("face_index"),
|
face_index=data.get("face_index"),
|
||||||
angle=_to_float(data.get("angle"), 360.0),
|
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"),
|
radius=data.get("radius"),
|
||||||
tangent_propagation=bool(data.get("tangent_propagation", False)),
|
tangent_propagation=bool(data.get("tangent_propagation", False)),
|
||||||
scope=data.get("scope", "selected"),
|
scope=data.get("scope", "selected"),
|
||||||
edge_refs=list(data.get("edge_refs") or []),
|
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")
|
sid = data.get("sketch_id")
|
||||||
if sid and sid in sketches:
|
if sid and sid in sketches:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from fluency.ui.dialogs import (
|
|||||||
ExtrudeDialog,
|
ExtrudeDialog,
|
||||||
OffsetDialog,
|
OffsetDialog,
|
||||||
RevolveDialog,
|
RevolveDialog,
|
||||||
|
ThreadDialog,
|
||||||
WorkplaneOrientationDialog,
|
WorkplaneOrientationDialog,
|
||||||
)
|
)
|
||||||
from fluency.ui.main_window import MainWindow
|
from fluency.ui.main_window import MainWindow
|
||||||
@@ -37,6 +38,7 @@ __all__ = [
|
|||||||
"ExtrudeDialog",
|
"ExtrudeDialog",
|
||||||
"RevolveDialog",
|
"RevolveDialog",
|
||||||
"OffsetDialog",
|
"OffsetDialog",
|
||||||
|
"ThreadDialog",
|
||||||
"WorkplaneOrientationDialog",
|
"WorkplaneOrientationDialog",
|
||||||
"main",
|
"main",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -226,6 +226,17 @@ class Feature:
|
|||||||
- "fillet": round a set of edges of the running geometry
|
- "fillet": round a set of edges of the running geometry
|
||||||
(``radius``, ``tangent_propagation``, ``scope``,
|
(``radius``, ``tangent_propagation``, ``scope``,
|
||||||
``edge_refs`` — see below)
|
``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
|
- "base": frozen geometry snapshot (``geometry`` field) — used
|
||||||
to migrate legacy bodies whose original base feature
|
to migrate legacy bodies whose original base feature
|
||||||
is unknown. Never the result of a user operation.
|
is unknown. Never the result of a user operation.
|
||||||
@@ -242,20 +253,34 @@ class Feature:
|
|||||||
cut_all_bodies: bool = False
|
cut_all_bodies: bool = False
|
||||||
face_index: Optional[int] = None # which sketch face was selected
|
face_index: Optional[int] = None # which sketch face was selected
|
||||||
angle: float = 360.0 # revolve only (degrees)
|
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.
|
# "base" features only: frozen pre-feature geometry snapshot.
|
||||||
geometry: Optional[OCCGeometryObject] = None
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
|
||||||
# "fillet" features only: radius (mm) of the round, whether the fillet
|
# "fillet" / "chamfer" features only: radius (mm) of the round or the
|
||||||
# should extend along edges tangent to the picked ones, the edge scope
|
# chamfer size, whether the op should extend along edges tangent to the
|
||||||
# ("selected" = edges between the two picked faces, "all" = every edge
|
# picked ones, the edge scope ("selected" = edges between the two
|
||||||
# of the body), and stable fingerprints of the selected edges so the
|
# picked faces, "all" = every edge of the body), and stable
|
||||||
# replay can re-find them after the base geometry is rebuilt.
|
# fingerprints of the selected edges so the replay can re-find them
|
||||||
|
# after the base geometry is rebuilt.
|
||||||
radius: Optional[float] = None
|
radius: Optional[float] = None
|
||||||
tangent_propagation: bool = False
|
tangent_propagation: bool = False
|
||||||
scope: str = "selected"
|
scope: str = "selected"
|
||||||
edge_refs: List[str] = field(default_factory=list)
|
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)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,35 @@ def _compute_viewport_aligned_xdir(
|
|||||||
return (1.0, 0.0, 0.0)
|
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
|
@dataclass
|
||||||
class OCCRenderObject(RenderObject):
|
class OCCRenderObject(RenderObject):
|
||||||
"""Internal object state for the OCC renderer."""
|
"""Internal object state for the OCC renderer."""
|
||||||
@@ -133,6 +162,12 @@ class OCCRenderer(Renderer):
|
|||||||
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
|
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
|
||||||
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
|
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
|
||||||
self._gizmo_objects: Dict[str, Any] = {}
|
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:
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
||||||
@@ -574,6 +609,8 @@ class OCCRenderer(Renderer):
|
|||||||
self.clear_preview()
|
self.clear_preview()
|
||||||
self.clear_face_highlight()
|
self.clear_face_highlight()
|
||||||
self.clear_entity_gizmo()
|
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
|
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
||||||
# path; fall back to iterating the displayed list if unavailable.
|
# path; fall back to iterating the displayed list if unavailable.
|
||||||
try:
|
try:
|
||||||
@@ -784,6 +821,59 @@ class OCCRenderer(Renderer):
|
|||||||
self._view.FitAll(margin)
|
self._view.FitAll(margin)
|
||||||
self._view.ZFitAll()
|
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:
|
def set_view_orientation(self, orientation: str = "iso") -> None:
|
||||||
"""Snap the camera to a standard CAD view.
|
"""Snap the camera to a standard CAD view.
|
||||||
|
|
||||||
@@ -1792,6 +1882,341 @@ class OCCRenderer(Renderer):
|
|||||||
if had_any and self._view is not None:
|
if had_any and self._view is not None:
|
||||||
self._view.Update()
|
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(
|
def show_entity_gizmo(
|
||||||
self,
|
self,
|
||||||
entity_type: str,
|
entity_type: str,
|
||||||
|
|||||||
@@ -13,16 +13,58 @@ import unittest
|
|||||||
# Allow running this file directly: ``python tests/test_project_io.py``.
|
# 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"))
|
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 (
|
from fluency.models.data_model import (
|
||||||
Project,
|
Project,
|
||||||
Component,
|
Component,
|
||||||
Body,
|
Body,
|
||||||
|
Sketch,
|
||||||
Workplane,
|
Workplane,
|
||||||
Assembly,
|
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):
|
class TestProjectIO(unittest.TestCase):
|
||||||
"""Round-trip the same project through save/load and check equivalence."""
|
"""Round-trip the same project through save/load and check equivalence."""
|
||||||
|
|
||||||
|
|||||||
+696
-3
@@ -14,11 +14,13 @@ from PySide6.QtWidgets import (
|
|||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QRadioButton,
|
QRadioButton,
|
||||||
|
QSpinBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
@@ -162,12 +164,20 @@ class ExtrudeDialog(QDialog):
|
|||||||
|
|
||||||
|
|
||||||
class RevolveDialog(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)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Revolve Options")
|
self.setWindowTitle("Revolve Options")
|
||||||
self.setMinimumWidth(300)
|
self.setMinimumWidth(340)
|
||||||
|
self._line_axis = line_axis # (line_id, origin, direction) or None
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
@@ -181,6 +191,30 @@ class RevolveDialog(QDialog):
|
|||||||
angle_layout.addWidget(self.angle_input)
|
angle_layout.addWidget(self.angle_input)
|
||||||
layout.addLayout(angle_layout)
|
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 = QFrame()
|
||||||
line.setFrameShape(QFrame.Shape.HLine)
|
line.setFrameShape(QFrame.Shape.HLine)
|
||||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
@@ -195,6 +229,25 @@ class RevolveDialog(QDialog):
|
|||||||
button_layout.addWidget(cancel_button)
|
button_layout.addWidget(cancel_button)
|
||||||
layout.addLayout(button_layout)
|
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):
|
class OffsetDialog(QDialog):
|
||||||
"""Dialog for 2D sketch offset options.
|
"""Dialog for 2D sketch offset options.
|
||||||
@@ -270,6 +323,324 @@ class OffsetDialog(QDialog):
|
|||||||
return (self.distance_input.value(), self.inward_checkbox.isChecked())
|
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):
|
class WorkplaneOrientationDialog(QDialog):
|
||||||
"""Modal dialog to choose the orientation of a new workplane.
|
"""Modal dialog to choose the orientation of a new workplane.
|
||||||
|
|
||||||
@@ -509,6 +880,197 @@ class WorkplaneOrientationDialog(QDialog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Metric thread data (coarse pitch) ──────────────────────────────────
|
||||||
|
METRIC_THREADS = {
|
||||||
|
"M1": (1.0, 0.25),
|
||||||
|
"M1.2": (1.2, 0.25),
|
||||||
|
"M1.4": (1.4, 0.30),
|
||||||
|
"M1.6": (1.6, 0.35),
|
||||||
|
"M1.8": (1.8, 0.35),
|
||||||
|
"M2": (2.0, 0.40),
|
||||||
|
"M2.5": (2.5, 0.45),
|
||||||
|
"M3": (3.0, 0.50),
|
||||||
|
"M3.5": (3.5, 0.60),
|
||||||
|
"M4": (4.0, 0.70),
|
||||||
|
"M5": (5.0, 0.80),
|
||||||
|
"M6": (6.0, 1.00),
|
||||||
|
"M7": (7.0, 1.00),
|
||||||
|
"M8": (8.0, 1.25),
|
||||||
|
"M10": (10.0, 1.50),
|
||||||
|
"M12": (12.0, 1.75),
|
||||||
|
"M14": (14.0, 2.00),
|
||||||
|
"M16": (16.0, 2.00),
|
||||||
|
"M18": (18.0, 2.50),
|
||||||
|
"M20": (20.0, 2.50),
|
||||||
|
"M22": (22.0, 2.50),
|
||||||
|
"M24": (24.0, 3.00),
|
||||||
|
"M27": (27.0, 3.00),
|
||||||
|
"M30": (30.0, 3.50),
|
||||||
|
"M32": (32.0, 3.50),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _closest_metric_thread(diameter_mm: float) -> Optional[Tuple[str, float, float]]:
|
||||||
|
"""Return the closest metric thread ``(name, nominal_dia, pitch)``
|
||||||
|
for a cylinder of *diameter_mm*, or *None* if no close match.
|
||||||
|
"""
|
||||||
|
best: Optional[Tuple[str, float, float, float]] = None # name, dia, pitch, diff
|
||||||
|
for name, (dia, pitch) in METRIC_THREADS.items():
|
||||||
|
diff = abs(dia - diameter_mm)
|
||||||
|
if best is None or diff < best[3]:
|
||||||
|
best = (name, dia, pitch, diff)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
return (best[0], best[1], best[2])
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadDialog(QDialog):
|
||||||
|
"""Dialog for applying an ISO metric thread to a cylindrical face.
|
||||||
|
|
||||||
|
The user picks a metric size (M1–M32) and optionally overrides the
|
||||||
|
pitch. When the dialog was opened with a detected cylinder diameter
|
||||||
|
the closest size is pre-selected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: Optional[QWidget] = None,
|
||||||
|
detected_diameter: Optional[float] = None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Thread Options")
|
||||||
|
self.setMinimumWidth(340)
|
||||||
|
|
||||||
|
self._preview_callback: Optional[Callable[[Any], None]] = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ── Size selection ──
|
||||||
|
layout.addWidget(QLabel("Thread Size:"))
|
||||||
|
|
||||||
|
self.size_combo = QComboBox()
|
||||||
|
self.size_combo.setToolTip("Select the metric thread size.")
|
||||||
|
for name in METRIC_THREADS:
|
||||||
|
dia, pitch = METRIC_THREADS[name]
|
||||||
|
self.size_combo.addItem(f"{name} (Ø{dia:g} mm, pitch {pitch:g} mm)", name)
|
||||||
|
layout.addWidget(self.size_combo)
|
||||||
|
|
||||||
|
# ── Pitch override ──
|
||||||
|
pitch_row = QHBoxLayout()
|
||||||
|
pitch_row.addWidget(QLabel("Pitch (mm):"))
|
||||||
|
self.pitch_input = QDoubleSpinBox()
|
||||||
|
self.pitch_input.setDecimals(2)
|
||||||
|
self.pitch_input.setRange(0.1, 10.0)
|
||||||
|
self.pitch_input.setValue(1.0)
|
||||||
|
self.pitch_input.setSingleStep(0.05)
|
||||||
|
self.pitch_input.setToolTip("Override the standard pitch if needed.")
|
||||||
|
pitch_row.addWidget(self.pitch_input)
|
||||||
|
layout.addLayout(pitch_row)
|
||||||
|
|
||||||
|
# ── Thread type ──
|
||||||
|
type_row = QHBoxLayout()
|
||||||
|
type_row.addWidget(QLabel("Type:"))
|
||||||
|
self.external_radio = QRadioButton("External (shaft)")
|
||||||
|
self.external_radio.setChecked(True)
|
||||||
|
self.internal_radio = QRadioButton("Internal (hole)")
|
||||||
|
type_row.addWidget(self.external_radio)
|
||||||
|
type_row.addWidget(self.internal_radio)
|
||||||
|
layout.addLayout(type_row)
|
||||||
|
|
||||||
|
# ── Length ──
|
||||||
|
len_row = QHBoxLayout()
|
||||||
|
len_row.addWidget(QLabel("Thread Length (mm):"))
|
||||||
|
self.length_input = QDoubleSpinBox()
|
||||||
|
self.length_input.setDecimals(2)
|
||||||
|
self.length_input.setRange(0.1, 10000.0)
|
||||||
|
self.length_input.setValue(20.0)
|
||||||
|
self.length_input.setToolTip(
|
||||||
|
"Length of the threaded section. 0 = use full cylinder height."
|
||||||
|
)
|
||||||
|
len_row.addWidget(self.length_input)
|
||||||
|
layout.addLayout(len_row)
|
||||||
|
|
||||||
|
# ── Cylinder info ──
|
||||||
|
self.info_label = QLabel("")
|
||||||
|
self.info_label.setStyleSheet("color: #8a8a8a;")
|
||||||
|
layout.addWidget(self.info_label)
|
||||||
|
|
||||||
|
if detected_diameter is not None:
|
||||||
|
closest = _closest_metric_thread(detected_diameter)
|
||||||
|
if closest is not None:
|
||||||
|
name, dia, pitch = closest
|
||||||
|
idx = self.size_combo.findData(name)
|
||||||
|
if idx >= 0:
|
||||||
|
self.size_combo.setCurrentIndex(idx)
|
||||||
|
self.pitch_input.setValue(pitch)
|
||||||
|
self.length_input.setValue(dia * 3.0) # sensible default length
|
||||||
|
self.info_label.setText(
|
||||||
|
f"Detected cylinder Ø ≈ {detected_diameter:.2f} mm → closest: {name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Signals ──
|
||||||
|
self.size_combo.currentIndexChanged.connect(self._on_size_changed)
|
||||||
|
self.pitch_input.valueChanged.connect(self._emit_preview)
|
||||||
|
self.external_radio.toggled.connect(self._emit_preview)
|
||||||
|
self.internal_radio.toggled.connect(self._emit_preview)
|
||||||
|
self.length_input.valueChanged.connect(self._emit_preview)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("Apply Thread")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
def _on_size_changed(self) -> None:
|
||||||
|
"""Update pitch when the user picks a different size."""
|
||||||
|
name = self.size_combo.currentData()
|
||||||
|
if name and name in METRIC_THREADS:
|
||||||
|
_dia, pitch = METRIC_THREADS[name]
|
||||||
|
self.pitch_input.setValue(pitch)
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
|
||||||
|
"""Install a live-preview callback; fires immediately."""
|
||||||
|
self._preview_callback = callback
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args: Any) -> None:
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._preview_callback(self.get_values())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("thread preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event: Any) -> None:
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def get_values(self) -> Tuple[str, float, float, bool, float]:
|
||||||
|
"""Return ``(size_name, nominal_diameter, pitch, internal, length)``."""
|
||||||
|
name = self.size_combo.currentData()
|
||||||
|
dia, _std_pitch = METRIC_THREADS.get(name, (0.0, 0.0))
|
||||||
|
return (
|
||||||
|
name or "M6",
|
||||||
|
dia,
|
||||||
|
self.pitch_input.value(),
|
||||||
|
self.internal_radio.isChecked(),
|
||||||
|
self.length_input.value(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FilletDialog(QDialog):
|
class FilletDialog(QDialog):
|
||||||
"""Dialog for fillet options — the common settings from CAD fillet tools.
|
"""Dialog for fillet options — the common settings from CAD fillet tools.
|
||||||
|
|
||||||
@@ -653,3 +1215,134 @@ class FilletDialog(QDialog):
|
|||||||
self.tangent_checkbox.isChecked(),
|
self.tangent_checkbox.isChecked(),
|
||||||
"all" if self.scope_all_radio.isChecked() else "selected",
|
"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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -892,7 +892,7 @@ class Ui_fluencyCAD(object):
|
|||||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", 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_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
|||||||
+1191
-37
File diff suppressed because it is too large
Load Diff
+269
-15
@@ -48,7 +48,7 @@ def _project_face_to_uv(
|
|||||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_WIRE
|
from OCP.TopAbs import TopAbs_EDGE, TopAbs_WIRE
|
||||||
from OCP.TopoDS import TopoDS
|
from OCP.TopoDS import TopoDS
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
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
|
from OCP.gp import gp_Pnt
|
||||||
|
|
||||||
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
|
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
|
||||||
@@ -77,8 +77,79 @@ def _project_face_to_uv(
|
|||||||
f = crv.FirstParameter()
|
f = crv.FirstParameter()
|
||||||
l = crv.LastParameter()
|
l = crv.LastParameter()
|
||||||
is_line = crv.GetType() == GeomAbs_Line
|
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)]
|
pts = [crv.Value(f), crv.Value(l)]
|
||||||
|
poly = [world_to_uv(p) for p in pts]
|
||||||
|
polylines.append(poly)
|
||||||
else:
|
else:
|
||||||
# Sample 32 segments across the parameter range.
|
# Sample 32 segments across the parameter range.
|
||||||
pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)]
|
pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)]
|
||||||
@@ -382,6 +453,15 @@ class Sketch2DWidget(QWidget):
|
|||||||
fixed via ``dragged``, so a user drag of a related entity never moves
|
fixed via ``dragged``, so a user drag of a related entity never moves
|
||||||
the underlay.
|
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
|
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.
|
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
|
# Clear any prior external entities before importing fresh ones so a
|
||||||
# repeated face pick doesn't pile up duplicate construction lines.
|
# repeated face pick doesn't pile up duplicate construction lines.
|
||||||
self._sketch.remove_external_entities()
|
self._sketch.remove_external_entities()
|
||||||
# Import ALL polylines in one call so corners shared between edges
|
# Split polylines, circle dicts, and arc dicts so each can be
|
||||||
# become a single external point entity (one connection hub per
|
# imported with the right sketch method.
|
||||||
# corner) instead of stacked duplicates.
|
polys: List[List[Tuple[float, float]]] = []
|
||||||
polys = [
|
circles: List[Dict[str, Any]] = []
|
||||||
[(float(u), float(v)) for (u, v) in poly]
|
arcs: List[Dict[str, Any]] = []
|
||||||
for poly in self._source_underlay_uv
|
for entry in self._source_underlay_uv:
|
||||||
if len(poly) >= 2
|
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
|
imported = 0
|
||||||
try:
|
try:
|
||||||
_, lines = self._sketch.add_external_polylines(polys)
|
_, lines = self._sketch.add_external_polylines(polys)
|
||||||
imported = len(lines)
|
imported += len(lines)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("underlay polyline import failed: %s", 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)
|
logger.info("Imported %d construction-line segments from source face", imported)
|
||||||
# Pull the new external entities into the UI lists so they're
|
# Pull the new external entities into the UI lists so they're
|
||||||
# snap/hover/paint targets.
|
# snap/hover/paint targets.
|
||||||
@@ -413,8 +562,23 @@ class Sketch2DWidget(QWidget):
|
|||||||
"""Centre & scale the 2D view to fit the source face's UV bounds."""
|
"""Centre & scale the 2D view to fit the source face's UV bounds."""
|
||||||
if not self._source_underlay_uv:
|
if not self._source_underlay_uv:
|
||||||
return
|
return
|
||||||
# Collect all UV points across all cached polylines.
|
# Collect UV points across polyline + circle + arc entries so a
|
||||||
all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
|
# 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:
|
if not all_pts:
|
||||||
return
|
return
|
||||||
us = [p[0] for p in all_pts]
|
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 self._sketch.build_face_geometry(self._selected_face)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_selected_revolve_axis(self) -> Optional[Tuple[int, Tuple, Tuple]]:
|
||||||
|
"""Return ``(line_entity_id, origin, direction)`` for the selected line.
|
||||||
|
|
||||||
|
The first selected line entity (regular or construction) is used as the
|
||||||
|
revolve axis: origin = its start point, direction = normalized
|
||||||
|
start→end, both in world coordinates. Returns ``None`` when no usable
|
||||||
|
line is selected. External (underlay) reference lines are ignored.
|
||||||
|
"""
|
||||||
|
if not self._sketch or not self._selected_entities:
|
||||||
|
return None
|
||||||
|
for ent in self._selected_entities:
|
||||||
|
if ent.entity_type != "line" or ent.is_external:
|
||||||
|
continue
|
||||||
|
axis = self._sketch.get_line_axis(ent.id)
|
||||||
|
if axis is None:
|
||||||
|
continue
|
||||||
|
return (ent.id, axis[0], axis[1])
|
||||||
|
return None
|
||||||
|
|
||||||
def get_selected_face_index(self) -> Optional[int]:
|
def get_selected_face_index(self) -> Optional[int]:
|
||||||
"""Return the index of the selected face in detect_faces(), or None."""
|
"""Return the index of the selected face in detect_faces(), or None."""
|
||||||
if self._selected_face is None or self._sketch is 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
|
# drawn from this cache any more — that would double-paint the
|
||||||
# underlay on top of the entity-based lines.
|
# underlay on top of the entity-based lines.
|
||||||
if self._source_underlay_uv and self._underlay_visible:
|
if self._source_underlay_uv and self._underlay_visible:
|
||||||
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
|
# 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(
|
fill_poly = QPolygonF(
|
||||||
[
|
[
|
||||||
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
|
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
|
||||||
for (u, v) in self._source_underlay_uv[0]
|
for (u, v) in entry
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
|
painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
|
||||||
painter.setPen(Qt.NoPen)
|
painter.setPen(Qt.NoPen)
|
||||||
painter.drawPolygon(fill_poly)
|
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 ──
|
# ── Points ──
|
||||||
for entity in self._points:
|
for entity in self._points:
|
||||||
@@ -3752,6 +3958,46 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.setPen(QPen(QColor("#fab387"), 1))
|
painter.setPen(QPen(QColor("#fab387"), 1))
|
||||||
painter.setBrush(QBrush(QColor("#fab387")))
|
painter.setBrush(QBrush(QColor("#fab387")))
|
||||||
painter.drawEllipse(screen_pos, 4, 4)
|
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 ──
|
# ── Lines ──
|
||||||
for p1_ent, p2_ent in self._lines:
|
for p1_ent, p2_ent in self._lines:
|
||||||
@@ -3823,6 +4069,10 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ── Circles ──
|
# ── Circles ──
|
||||||
for center_ent, radius in self._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:
|
if center_ent.geometry:
|
||||||
cx, cy = center_ent.geometry
|
cx, cy = center_ent.geometry
|
||||||
sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
|
sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
|
||||||
@@ -3834,6 +4084,10 @@ class Sketch2DWidget(QWidget):
|
|||||||
# ── Arcs ──
|
# ── Arcs ──
|
||||||
for arc_item in self._arcs:
|
for arc_item in self._arcs:
|
||||||
center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
|
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):
|
if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
|
||||||
continue
|
continue
|
||||||
cx, cy = center_ent.geometry
|
cx, cy = center_ent.geometry
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ class Viewer3DWidget(QWidget):
|
|||||||
# Emitted when fillet pick mode is cancelled (Esc).
|
# Emitted when fillet pick mode is cancelled (Esc).
|
||||||
filletPickCancelled = Signal()
|
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).
|
# 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).
|
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||||
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
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.
|
# Payload: (eye, at, up) — each is a tuple of 3 floats.
|
||||||
cameraChanged = Signal(tuple, tuple, tuple)
|
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):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
# 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
|
self._pick_face_mode: bool = False
|
||||||
# When True, a left-click picks ANY face for the fillet tool.
|
# When True, a left-click picks ANY face for the fillet tool.
|
||||||
self._fillet_pick_mode: bool = False
|
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
|
# When True, a left-click picks an entity for a connector point
|
||||||
# (assembly component connection).
|
# (assembly component connection).
|
||||||
self._connector_pick_mode: bool = False
|
self._connector_pick_mode: bool = False
|
||||||
@@ -96,6 +127,16 @@ class Viewer3DWidget(QWidget):
|
|||||||
# target a cut/union extrude against the body the sketch was
|
# target a cut/union extrude against the body the sketch was
|
||||||
# projected onto).
|
# projected onto).
|
||||||
self._last_pick_owner_obj_id: Optional[str] = None
|
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:
|
def _init_renderer(self) -> None:
|
||||||
"""Create the best available renderer."""
|
"""Create the best available renderer."""
|
||||||
@@ -284,6 +325,35 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._renderer.fit_camera()
|
self._renderer.fit_camera()
|
||||||
self._renderer.render()
|
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 ───────────────────────────────────────────
|
# ─── Workplane visualization ───────────────────────────────────────────
|
||||||
|
|
||||||
def show_workplane(
|
def show_workplane(
|
||||||
@@ -328,6 +398,14 @@ class Viewer3DWidget(QWidget):
|
|||||||
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||||
self._handle_fillet_face_pick(event)
|
self._handle_fillet_face_pick(event)
|
||||||
return
|
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.
|
# Connector pick mode: a left-click selects a face for a connection point.
|
||||||
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||||
self._handle_connector_pick(event)
|
self._handle_connector_pick(event)
|
||||||
@@ -336,6 +414,20 @@ class Viewer3DWidget(QWidget):
|
|||||||
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
|
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||||
self._handle_assembly_move_press(event)
|
self._handle_assembly_move_press(event)
|
||||||
return
|
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)
|
self._renderer.handle_mouse_press(event)
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
@@ -365,11 +457,29 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._renderer.handle_mouse_move(event)
|
self._renderer.handle_mouse_move(event)
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
return
|
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.
|
# Active drag in assembly move mode.
|
||||||
if self._move_drag_active:
|
if self._move_drag_active:
|
||||||
self._handle_assembly_move_move(event)
|
self._handle_assembly_move_move(event)
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
return
|
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)
|
self._renderer.handle_mouse_move(event)
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
@@ -456,6 +566,127 @@ class Viewer3DWidget(QWidget):
|
|||||||
return self._renderer.get_camera_fov()
|
return self._renderer.get_camera_fov()
|
||||||
return 45.0
|
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) ────────────────────────────────
|
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||||||
|
|
||||||
def set_pick_face_mode(self, enabled: bool) -> None:
|
def set_pick_face_mode(self, enabled: bool) -> None:
|
||||||
@@ -466,6 +697,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._pick_face_mode = bool(enabled)
|
self._pick_face_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
|
self._sketch_gizmo_pick_mode = False
|
||||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||||
else:
|
else:
|
||||||
self.unsetCursor()
|
self.unsetCursor()
|
||||||
@@ -484,19 +716,59 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._fillet_pick_mode = bool(enabled)
|
self._fillet_pick_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
|
self._sketch_gizmo_pick_mode = False
|
||||||
# Pick modes are mutually exclusive — entering fillet mode
|
# 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._pick_face_mode = False
|
||||||
self._connector_pick_mode = False
|
self._connector_pick_mode = False
|
||||||
self._assembly_move_mode = False
|
self._assembly_move_mode = False
|
||||||
self._move_drag_active = False
|
self._move_drag_active = False
|
||||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
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()
|
self.unsetCursor()
|
||||||
|
|
||||||
def is_fillet_pick_mode(self) -> bool:
|
def is_fillet_pick_mode(self) -> bool:
|
||||||
return self._fillet_pick_mode
|
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:
|
def highlight_faces(self, faces: List[Any]) -> None:
|
||||||
"""Tint all faces in *faces* so both fillet picks stay visible."""
|
"""Tint all faces in *faces* so both fillet picks stay visible."""
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
@@ -535,6 +807,45 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||||||
self.filletFacePicked.emit(info["face"])
|
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:
|
def highlight_face(self, face: Any) -> None:
|
||||||
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
@@ -570,6 +881,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._connector_pick_mode = bool(enabled)
|
self._connector_pick_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
|
self._sketch_gizmo_pick_mode = False
|
||||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||||
# Disable standard OCC selection so gizmo visuals are not
|
# Disable standard OCC selection so gizmo visuals are not
|
||||||
# interfered with by dynamic face highlighting.
|
# interfered with by dynamic face highlighting.
|
||||||
@@ -795,6 +1107,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._assembly_move_mode = bool(enabled)
|
self._assembly_move_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
|
self._sketch_gizmo_pick_mode = False
|
||||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
||||||
elif not self._pick_face_mode and not self._connector_pick_mode:
|
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||||
self.unsetCursor()
|
self.unsetCursor()
|
||||||
@@ -964,6 +1277,16 @@ class Viewer3DWidget(QWidget):
|
|||||||
self.set_fillet_pick_mode(False)
|
self.set_fillet_pick_mode(False)
|
||||||
self.filletPickCancelled.emit()
|
self.filletPickCancelled.emit()
|
||||||
return
|
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.
|
# Esc cancels connector pick mode.
|
||||||
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
|
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
|
||||||
self.set_connector_pick_mode(False)
|
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:
|
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
|
||||||
self.set_assembly_move_mode(False)
|
self.set_assembly_move_mode(False)
|
||||||
return
|
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,
|
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||||||
# P/O = perspective/orthographic, R = reset).
|
# P/O = perspective/orthographic, R = reset).
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
|
|||||||
Reference in New Issue
Block a user