- arc improvements, fillets, operations, bodys
This commit is contained in:
Generated
+13
-5
@@ -6,13 +6,13 @@
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added "measurement lines"">
|
||||
<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/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.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/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.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/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -451,7 +451,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1785094789248</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="42" />
|
||||
<task id="LOCAL-00042" summary="- added "measurement lines"">
|
||||
<option name="closed" value="true" />
|
||||
<created>1785697123545</created>
|
||||
<option name="number" value="00042" />
|
||||
<option name="presentableId" value="LOCAL-00042" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1785697123545</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="43" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
||||
@@ -786,100 +786,6 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QGroupBox" name="groupBox_10">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Bodys / Operations</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QListWidget" name="body_list">
|
||||
<property name="selectionRectVisible">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_8">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Tools</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_8">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="pb_body_hide">
|
||||
<property name="text">
|
||||
<string>Hide</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pb_update_body">
|
||||
<property name="text">
|
||||
<string>Upd</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="pb_del_body">
|
||||
<property name="text">
|
||||
<string>Del</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="3" rowspan="2">
|
||||
<widget class="QGroupBox" name="joint_tools">
|
||||
<property name="minimumSize">
|
||||
@@ -1337,6 +1243,100 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3" rowspan="2">
|
||||
<widget class="QGroupBox" name="groupBox_10">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Bodys / Operations</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QListWidget" name="body_list">
|
||||
<property name="selectionRectVisible">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_8">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Tools</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_8">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="pb_body_hide">
|
||||
<property name="text">
|
||||
<string>Hide</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pb_update_body">
|
||||
<property name="text">
|
||||
<string>Upd</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="pb_del_body">
|
||||
<property name="text">
|
||||
<string>Del</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
|
||||
@@ -438,49 +438,6 @@ class Ui_fluencyCAD(object):
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_12, 4, 3, 1, 1)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_body_hide = QPushButton(self.groupBox_8)
|
||||
self.pb_body_hide.setObjectName(u"pb_body_hide")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 2, 3, 1, 1)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
@@ -726,6 +683,49 @@ class Ui_fluencyCAD(object):
|
||||
|
||||
self.gridLayout.addWidget(self.line_3, 5, 0, 1, 4)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_body_hide = QPushButton(self.groupBox_8)
|
||||
self.pb_body_hide.setObjectName(u"pb_body_hide")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 2, 3, 2, 1)
|
||||
|
||||
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(fluencyCAD)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
@@ -893,11 +893,6 @@ class Ui_fluencyCAD(object):
|
||||
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
@@ -936,6 +931,11 @@ class Ui_fluencyCAD(object):
|
||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||
self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None))
|
||||
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -455,10 +455,11 @@ class OCGeometryKernel(GeometryKernel):
|
||||
else:
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while explorer.More():
|
||||
fillet.Add(radius, explorer.Current())
|
||||
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
|
||||
fillet.Build()
|
||||
|
||||
@@ -28,6 +28,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
@@ -66,7 +67,7 @@ def _json_default(obj: Any) -> Any:
|
||||
return sorted(obj)
|
||||
if isinstance(obj, tuple):
|
||||
return list(obj)
|
||||
if is_dataclass(obj):
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
@@ -86,10 +87,37 @@ def _coerce_listlike(value: Any) -> List[Any]:
|
||||
return list(value)
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||||
"""Safely coerce a saved value to float, falling back to *default*.
|
||||
|
||||
Corrupt archives may store a string or None where a number is expected;
|
||||
the loaders must not crash on them.
|
||||
"""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _saved_id(data: Dict[str, Any]) -> str:
|
||||
"""Return a saved entity id, or a fresh UUID for corrupt/legacy data.
|
||||
|
||||
Old files always wrote an ``id``; a missing/empty value means the
|
||||
archive is damaged, and the model's uuid factory only kicks in when the
|
||||
constructor argument is omitted — so we generate here to keep ids valid
|
||||
non-empty strings.
|
||||
"""
|
||||
v = data.get("id")
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
|
||||
if value is None:
|
||||
return (0.0, 0.0, 0.0)
|
||||
try:
|
||||
if isinstance(value, np.ndarray):
|
||||
seq = value.tolist()
|
||||
else:
|
||||
@@ -97,10 +125,13 @@ def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def _to_3vec(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3-vector to a 3-element numpy array."""
|
||||
try:
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.astype(float).reshape(3)
|
||||
if value is None:
|
||||
@@ -109,10 +140,13 @@ def _to_3vec(value: Any) -> np.ndarray:
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return np.zeros(3, dtype=float)
|
||||
|
||||
|
||||
def _to_mat3(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
|
||||
try:
|
||||
if isinstance(value, np.ndarray):
|
||||
arr = value.astype(float)
|
||||
return arr.reshape(3, 3)
|
||||
@@ -122,6 +156,8 @@ def _to_mat3(value: Any) -> np.ndarray:
|
||||
if len(flat) < 9:
|
||||
flat = flat + [0.0] * (9 - len(flat))
|
||||
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return np.eye(3, dtype=float)
|
||||
|
||||
|
||||
def _parse_iso(value: Optional[str]) -> datetime:
|
||||
@@ -152,11 +188,11 @@ def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
||||
|
||||
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||
wp = Workplane(
|
||||
id=data.get("id") or None, # Workplane generates uuid if None
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Workplane"),
|
||||
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||
origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||
normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||
x_dir=_to_3tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
wp.created_at = _parse_iso(data.get("created_at"))
|
||||
@@ -182,7 +218,11 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
|
||||
"through_all": bool(feat.through_all),
|
||||
"cut_all_bodies": bool(feat.cut_all_bodies),
|
||||
"face_index": feat.face_index,
|
||||
"angle": float(feat.angle),
|
||||
"angle": _to_float(feat.angle, 360.0),
|
||||
"radius": feat.radius,
|
||||
"tangent_propagation": bool(feat.tangent_propagation),
|
||||
"scope": feat.scope,
|
||||
"edge_refs": list(feat.edge_refs),
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +230,7 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
|
||||
"""Deserialize a feature, resolving its sketch reference against the
|
||||
component's already-loaded sketches."""
|
||||
feat = Feature(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
operation=data.get("operation", "extrude"),
|
||||
length=data.get("length"),
|
||||
symmetric=bool(data.get("symmetric", False)),
|
||||
@@ -198,7 +238,11 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
|
||||
through_all=bool(data.get("through_all", False)),
|
||||
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
|
||||
face_index=data.get("face_index"),
|
||||
angle=float(data.get("angle", 360.0)),
|
||||
angle=_to_float(data.get("angle"), 360.0),
|
||||
radius=data.get("radius"),
|
||||
tangent_propagation=bool(data.get("tangent_propagation", False)),
|
||||
scope=data.get("scope", "selected"),
|
||||
edge_refs=list(data.get("edge_refs") or []),
|
||||
)
|
||||
sid = data.get("sketch_id")
|
||||
if sid and sid in sketches:
|
||||
@@ -229,7 +273,7 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||
"position": _coerce_listlike(body.position),
|
||||
"rotation": _coerce_listlike(body.rotation),
|
||||
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||
"opacity": float(body.opacity),
|
||||
"opacity": _to_float(body.opacity, 1.0),
|
||||
"visible": bool(body.visible),
|
||||
"has_geometry": body.geometry is not None,
|
||||
"geometry_ref": None, # filled in by save_project
|
||||
@@ -248,7 +292,7 @@ def _body_from_dict(
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
body = Body(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Body"),
|
||||
geometry=geometry,
|
||||
source_sketch=source_sketch,
|
||||
@@ -265,7 +309,7 @@ def _body_from_dict(
|
||||
position=_to_3vec(data.get("position")),
|
||||
rotation=_to_mat3(data.get("rotation")),
|
||||
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||
opacity=float(data.get("opacity", 1.0)),
|
||||
opacity=_to_float(data.get("opacity"), 1.0),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
body.created_at = _parse_iso(data.get("created_at"))
|
||||
@@ -315,9 +359,9 @@ def _sketch_from_dict(
|
||||
# Re-apply the workplane (from_dict already does this internally, but be
|
||||
# defensive in case the saved dict didn't carry the workplane fields).
|
||||
occ_sketch.set_workplane(
|
||||
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||
_to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||
_to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||
_to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||
)
|
||||
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
@@ -325,7 +369,7 @@ def _sketch_from_dict(
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
sk = Sketch(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Sketch"),
|
||||
occ_sketch=occ_sketch,
|
||||
geometry=geometry,
|
||||
@@ -362,7 +406,7 @@ def _component_from_dict(
|
||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Component:
|
||||
comp = Component(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Component"),
|
||||
description=data.get("description", ""),
|
||||
active_sketch=data.get("active_sketch"),
|
||||
@@ -410,8 +454,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
"position": list(conn.position),
|
||||
"normal": list(conn.normal),
|
||||
"x_dir": list(conn.x_dir),
|
||||
"axis_rotation": float(conn.axis_rotation),
|
||||
"offset": float(conn.offset),
|
||||
"axis_rotation": _to_float(conn.axis_rotation, 0.0),
|
||||
"offset": _to_float(conn.offset, 0.0),
|
||||
"assembly_component_id": conn.assembly_component_id,
|
||||
"source_obj_id": conn.source_obj_id,
|
||||
"partner_ac_id": conn.partner_ac_id,
|
||||
@@ -425,13 +469,13 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
|
||||
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||
conn = Connector(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Connector"),
|
||||
position=_to_3tuple(data.get("position")),
|
||||
normal=_to_3tuple(data.get("normal")),
|
||||
x_dir=_to_3tuple(data.get("x_dir")),
|
||||
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||
offset=float(data.get("offset", 0.0)),
|
||||
axis_rotation=_to_float(data.get("axis_rotation"), 0.0),
|
||||
offset=_to_float(data.get("offset"), 0.0),
|
||||
assembly_component_id=data.get("assembly_component_id", ""),
|
||||
source_obj_id=data.get("source_obj_id", ""),
|
||||
)
|
||||
@@ -459,7 +503,7 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
||||
|
||||
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||
ac = AssemblyComponent(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
component_id=data.get("component_id", ""),
|
||||
name=data.get("name", "Untitled Instance"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
@@ -485,7 +529,7 @@ def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
||||
|
||||
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||
conn = AssemblyConnection(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
first_ac_id=data.get("first_ac_id", ""),
|
||||
second_ac_id=data.get("second_ac_id", ""),
|
||||
first_connector_id=data.get("first_connector_id"),
|
||||
@@ -509,7 +553,7 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
||||
|
||||
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||
asm = Assembly(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Assembly"),
|
||||
active_assembly_component=data.get("active_assembly_component"),
|
||||
)
|
||||
@@ -580,7 +624,9 @@ def _read_step_bytes(
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(data)
|
||||
geom = kernel.import_step(tmp_path)
|
||||
return geom
|
||||
from typing import cast
|
||||
|
||||
return cast(OCCGeometryObject, geom)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read STEP: %s", exc)
|
||||
return None
|
||||
@@ -749,7 +795,10 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
|
||||
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||
manifest_raw = zipf.read("project.json")
|
||||
try:
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise RuntimeError(f"Corrupt project file (bad JSON): {filepath}") from exc
|
||||
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||
|
||||
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||
@@ -764,7 +813,11 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
except KeyError:
|
||||
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
logger.warning("Sketch meta corrupt in archive: %s (%s)", ref, exc)
|
||||
continue
|
||||
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||
# Workplane fields on the sketch-level file override the
|
||||
# embedded ones (source of truth lives in the sidecar).
|
||||
|
||||
@@ -60,9 +60,9 @@ class Workplane:
|
||||
x = x / x_norm
|
||||
y = np.cross(n, x)
|
||||
y = y / np.linalg.norm(y)
|
||||
self.normal = tuple(float(v) for v in n)
|
||||
self.x_dir = tuple(float(v) for v in x)
|
||||
self._y_dir = tuple(float(v) for v in y)
|
||||
self.normal = (float(n[0]), float(n[1]), float(n[2]))
|
||||
self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
||||
self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
|
||||
|
||||
@property
|
||||
def y_dir(self) -> Tuple[float, float, float]:
|
||||
@@ -223,6 +223,9 @@ class Feature:
|
||||
extruded sketch profile
|
||||
- "union": boolean union of the running geometry with the
|
||||
extruded sketch profile
|
||||
- "fillet": round a set of edges of the running geometry
|
||||
(``radius``, ``tangent_propagation``, ``scope``,
|
||||
``edge_refs`` — see below)
|
||||
- "base": frozen geometry snapshot (``geometry`` field) — used
|
||||
to migrate legacy bodies whose original base feature
|
||||
is unknown. Never the result of a user operation.
|
||||
@@ -243,6 +246,16 @@ class Feature:
|
||||
# "base" features only: frozen pre-feature geometry snapshot.
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
|
||||
# "fillet" features only: radius (mm) of the round, whether the fillet
|
||||
# should extend along edges tangent to the picked ones, the edge scope
|
||||
# ("selected" = edges between the two picked faces, "all" = every edge
|
||||
# of the body), and stable fingerprints of the selected edges so the
|
||||
# replay can re-find them after the base geometry is rebuilt.
|
||||
radius: Optional[float] = None
|
||||
tangent_propagation: bool = False
|
||||
scope: str = "selected"
|
||||
edge_refs: List[str] = field(default_factory=list)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ class OCCRenderer(Renderer):
|
||||
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
|
||||
# Persistent light-blue transparent overlay marking the selected face.
|
||||
self._highlight_ais: Any = None
|
||||
# Overlays for the fillet tool's two picked faces (one AIS per face).
|
||||
self._faces_highlight_ais: List[Any] = []
|
||||
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
||||
self._preview_ais: Any = None
|
||||
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
|
||||
@@ -137,6 +139,7 @@ class OCCRenderer(Renderer):
|
||||
self._parent_widget = parent_widget
|
||||
|
||||
import os as _os
|
||||
|
||||
if _os.environ.get("QT_QPA_PLATFORM") == "offscreen":
|
||||
logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)")
|
||||
return False
|
||||
@@ -159,16 +162,14 @@ class OCCRenderer(Renderer):
|
||||
)
|
||||
from OCP.AIS import AIS_InteractiveContext
|
||||
from OCP.Graphic3d import (
|
||||
Graphic3d_Camera,
|
||||
Graphic3d_TypeOfShadingModel,
|
||||
Graphic3d_MaterialAspect,
|
||||
Graphic3d_NameOfMaterial,
|
||||
)
|
||||
from OCP.Quantity import (
|
||||
Quantity_Color,
|
||||
Quantity_TOC_RGB,
|
||||
Quantity_NameOfColor,
|
||||
)
|
||||
|
||||
logger.info("OCCRenderer imports complete")
|
||||
|
||||
hwnd = int(parent_widget.winId())
|
||||
@@ -203,9 +204,7 @@ class OCCRenderer(Renderer):
|
||||
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
|
||||
True,
|
||||
)
|
||||
ambient = V3d_AmbientLight(
|
||||
Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)
|
||||
)
|
||||
ambient = V3d_AmbientLight(Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB))
|
||||
for light in (key, fill, rim, ambient):
|
||||
viewer.SetLightOn(light)
|
||||
|
||||
@@ -249,6 +248,7 @@ class OCCRenderer(Renderer):
|
||||
# pick preview matches the persistent selection overlay below.
|
||||
try:
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
|
||||
# Modify the existing dynamic-highlight drawer in place (per
|
||||
# OCC docs this is safer than building a fresh Prs3d_Drawer).
|
||||
hd = context.HighlightStyle()
|
||||
@@ -305,7 +305,6 @@ class OCCRenderer(Renderer):
|
||||
"""
|
||||
from OCP.AIS import AIS_Shape
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
from OCP.Prs3d import Prs3d_Drawer
|
||||
|
||||
obj_id = name or f"shape_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -345,6 +344,7 @@ class OCCRenderer(Renderer):
|
||||
# explicit pick methods (pick_entity / pick_planar_face).
|
||||
try:
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
|
||||
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
|
||||
mode = AIS_Shape.SelectionMode_s(topo)
|
||||
self._context.Activate(ais, mode)
|
||||
@@ -377,9 +377,7 @@ class OCCRenderer(Renderer):
|
||||
"""
|
||||
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
||||
|
||||
mat = Graphic3d_MaterialAspect(
|
||||
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
|
||||
)
|
||||
mat = Graphic3d_MaterialAspect(Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC)
|
||||
return mat
|
||||
|
||||
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
|
||||
@@ -582,10 +580,12 @@ class OCCRenderer(Renderer):
|
||||
self._context.RemoveAll(True)
|
||||
except Exception:
|
||||
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
|
||||
|
||||
lst = AIS_ListOfInteractive()
|
||||
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
|
||||
for ais in lst:
|
||||
self._context.Remove(ais, True)
|
||||
self._faces_highlight_ais = []
|
||||
self._objects.clear()
|
||||
|
||||
def update_mesh(
|
||||
@@ -702,10 +702,9 @@ class OCCRenderer(Renderer):
|
||||
|
||||
# Check projection type.
|
||||
from OCP.Graphic3d import Graphic3d_Camera
|
||||
|
||||
proj_type = cam.ProjectionType()
|
||||
is_orthographic = (
|
||||
proj_type == Graphic3d_Camera.Projection_Orthographic
|
||||
)
|
||||
is_orthographic = proj_type == Graphic3d_Camera.Projection_Orthographic
|
||||
|
||||
if not is_orthographic:
|
||||
# Perspective mode: use the actual eye position directly.
|
||||
@@ -726,6 +725,7 @@ class OCCRenderer(Renderer):
|
||||
# Compute scene bounding box diagonal from displayed objects.
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
try:
|
||||
for robj in self._objects.values():
|
||||
@@ -737,13 +737,7 @@ class OCCRenderer(Renderer):
|
||||
pass
|
||||
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
diag = float(
|
||||
np.sqrt(
|
||||
(xmax - xmin) ** 2
|
||||
+ (ymax - ymin) ** 2
|
||||
+ (zmax - zmin) ** 2
|
||||
)
|
||||
)
|
||||
diag = float(np.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2))
|
||||
|
||||
# Fallback: if bbox is empty (no objects or all shapes failed),
|
||||
# use the eye-to-at distance as a reasonable estimate.
|
||||
@@ -753,9 +747,8 @@ class OCCRenderer(Renderer):
|
||||
# Base distance: how far the camera must be for the bbox diagonal
|
||||
# to fill the frame at the given vertical FOV.
|
||||
import math
|
||||
base_distance = diag / (
|
||||
2.0 * math.tan(math.radians(fov_y / 2.0))
|
||||
)
|
||||
|
||||
base_distance = diag / (2.0 * math.tan(math.radians(fov_y / 2.0)))
|
||||
|
||||
# Scale factor maps inversely: larger scale (zoomed in) → closer camera.
|
||||
# Dividing by view_scale ensures that when the user zooms in (scale increases)
|
||||
@@ -886,9 +879,7 @@ class OCCRenderer(Renderer):
|
||||
def on_pick(self, callback: Any) -> None:
|
||||
pass
|
||||
|
||||
def project_to_screen(
|
||||
self, point: Tuple[float, float, float]
|
||||
) -> Tuple[float, float]:
|
||||
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[float, float]:
|
||||
return (0.0, 0.0)
|
||||
|
||||
def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None:
|
||||
@@ -898,6 +889,7 @@ class OCCRenderer(Renderer):
|
||||
if self._view is None:
|
||||
return
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
|
||||
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
||||
self._view.SetBackgroundColor(qcol)
|
||||
|
||||
@@ -931,7 +923,6 @@ class OCCRenderer(Renderer):
|
||||
from OCP.gp import gp_Pln
|
||||
from OCP.AIS import AIS_Shape
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
||||
|
||||
obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -980,6 +971,7 @@ class OCCRenderer(Renderer):
|
||||
)
|
||||
# Use gp_Pnt for the corners to make a bounded face.
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
for c in corners_3d:
|
||||
mp.Add(gp_Pnt(*c))
|
||||
@@ -1039,9 +1031,7 @@ class OCCRenderer(Renderer):
|
||||
def take_screenshot(self) -> bytes:
|
||||
return b""
|
||||
|
||||
def unproject_from_screen(
|
||||
self, x: float, y: float
|
||||
) -> Tuple[float, float, float]:
|
||||
def unproject_from_screen(self, x: float, y: float) -> Tuple[float, float, float]:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# ─── Face picking (for sketch-on-surface) ────────────────────────────
|
||||
@@ -1061,12 +1051,8 @@ class OCCRenderer(Renderer):
|
||||
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Plane
|
||||
from OCP.TopoDS import TopoDS_Face, TopoDS
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.gp import gp_Pln, gp_Dir, gp_Pnt
|
||||
import numpy as np
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Pln
|
||||
|
||||
# Detect what's under the cursor.
|
||||
self._context.MoveTo(x, y, self._view, True)
|
||||
@@ -1107,6 +1093,7 @@ class OCCRenderer(Renderer):
|
||||
# default (non-inverted) extrude would punch back into the body
|
||||
# instead of building outward on top of it.
|
||||
from OCP.TopAbs import TopAbs_REVERSED
|
||||
|
||||
n = pln.Axis().Direction()
|
||||
if face.Orientation() == TopAbs_REVERSED:
|
||||
n = n.Reversed()
|
||||
@@ -1115,6 +1102,7 @@ class OCCRenderer(Renderer):
|
||||
# plane, so the UV frame is centred on the face (nicer for sketching).
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(face, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
@@ -1157,6 +1145,56 @@ class OCCRenderer(Renderer):
|
||||
"owner_obj_id": owner_obj_id,
|
||||
}
|
||||
|
||||
def pick_face(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||
"""Pick ANY face under screen pixel (x, y) — planar or curved.
|
||||
|
||||
Returns ``{"face": TopoDS_Face, "owner_obj_id": str}`` or *None*.
|
||||
Unlike :meth:`pick_planar_face` (which requires a planar face so it
|
||||
can derive a UV frame for sketching) this accepts cylindrical /
|
||||
spherical / spline faces too — the fillet tool only needs the face
|
||||
shape and its owning body.
|
||||
"""
|
||||
if self._view is None or self._context is None:
|
||||
return None
|
||||
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
|
||||
self._context.MoveTo(x, y, self._view, True)
|
||||
if not self._context.HasDetected():
|
||||
return None
|
||||
|
||||
shape = self._context.DetectedShape()
|
||||
if shape is None:
|
||||
return None
|
||||
|
||||
face = None
|
||||
try:
|
||||
candidate = TopoDS.Face_s(shape)
|
||||
# Verify it really is a face by building an adaptor (throws for
|
||||
# edges / vertices).
|
||||
_ = BRepAdaptor_Surface(candidate)
|
||||
face = candidate
|
||||
except Exception:
|
||||
face = None
|
||||
if face is None:
|
||||
return None
|
||||
|
||||
# Identify the displayed body that owns this face (same match used
|
||||
# by pick_planar_face).
|
||||
owner_obj_id: Optional[str] = None
|
||||
try:
|
||||
owner_ais = self._context.DetectedInteractive()
|
||||
except Exception:
|
||||
owner_ais = None
|
||||
if owner_ais is not None:
|
||||
for oid, robj in self._objects.items():
|
||||
if robj.ais_shape is owner_ais:
|
||||
owner_obj_id = oid
|
||||
break
|
||||
|
||||
return {"face": face, "owner_obj_id": owner_obj_id}
|
||||
|
||||
def highlight_face(self, face: Any) -> None:
|
||||
"""Overlay a persistent, mostly-transparent light-blue tint on *face*.
|
||||
|
||||
@@ -1205,6 +1243,58 @@ class OCCRenderer(Renderer):
|
||||
logger.debug("clear_face_highlight remove failed", exc_info=True)
|
||||
self._highlight_ais = None
|
||||
|
||||
# ─── Multi-face highlight (fillet face picking) ─────────────────────────
|
||||
|
||||
def highlight_faces(self, faces: List[Any]) -> None:
|
||||
"""Tint every face in *faces* with the selection overlay.
|
||||
|
||||
Unlike :meth:`highlight_face` (single face, used by sketch-on-
|
||||
surface) this keeps one overlay per face so the fillet tool can
|
||||
show BOTH picked faces at once. Replaces any previous multi-face
|
||||
overlay; independent of the single-face highlight.
|
||||
"""
|
||||
if self._context is None:
|
||||
return
|
||||
self.clear_faces_highlight()
|
||||
if not faces:
|
||||
return
|
||||
from OCP.AIS import AIS_Shape
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
|
||||
for face in faces:
|
||||
ais = AIS_Shape(face)
|
||||
try:
|
||||
ais.SetMaterial(self._default_material())
|
||||
except Exception:
|
||||
logger.debug("faces highlight material set failed", exc_info=True)
|
||||
ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
|
||||
ais.SetDisplayMode(1) # shaded
|
||||
try:
|
||||
ais.SetTransparency(0.78)
|
||||
except Exception:
|
||||
logger.debug("faces highlight transparency set failed", exc_info=True)
|
||||
try:
|
||||
# Bias the overlay toward the camera so it draws on top of
|
||||
# the coincident face surface without z-fighting.
|
||||
ais.SetPolygonOffsets(3, 1.0, -0.5)
|
||||
except Exception:
|
||||
logger.debug("faces highlight polygon offset failed", exc_info=True)
|
||||
self._context.Display(ais, True)
|
||||
self._faces_highlight_ais.append(ais)
|
||||
if self._view is not None:
|
||||
self._view.Update()
|
||||
|
||||
def clear_faces_highlight(self) -> None:
|
||||
"""Remove the multi-face fillet-pick overlays, if any."""
|
||||
if self._context is None or not self._faces_highlight_ais:
|
||||
return
|
||||
for ais in self._faces_highlight_ais:
|
||||
try:
|
||||
self._context.Remove(ais, True)
|
||||
except Exception:
|
||||
logger.debug("clear_faces_highlight remove failed", exc_info=True)
|
||||
self._faces_highlight_ais = []
|
||||
|
||||
# ─── General entity picking (for assembly connectors / snaps) ───────────
|
||||
|
||||
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||
@@ -1246,12 +1336,13 @@ class OCCRenderer(Renderer):
|
||||
except Exception:
|
||||
pass
|
||||
if eye is not None:
|
||||
results.sort(key=lambda c: float(np.linalg.norm(
|
||||
np.array(c["position"]) - eye)))
|
||||
results.sort(key=lambda c: float(np.linalg.norm(np.array(c["position"]) - eye)))
|
||||
return results[0]
|
||||
|
||||
def _classify_detected_shape(
|
||||
self, shape: Any, owner_obj_id: Optional[str] = None,
|
||||
self,
|
||||
shape: Any,
|
||||
owner_obj_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Classify a detected OCC sub-shape into snap-candidate dicts.
|
||||
|
||||
@@ -1267,17 +1358,14 @@ class OCCRenderer(Renderer):
|
||||
if shape is None:
|
||||
return []
|
||||
|
||||
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
|
||||
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE
|
||||
from OCP.gp import gp_Pnt, gp_Dir
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
from OCP.TopExp import TopExp
|
||||
import numpy as np
|
||||
|
||||
# Helper: find owner object id if not supplied.
|
||||
@@ -1309,6 +1397,7 @@ class OCCRenderer(Renderer):
|
||||
pln = adaptor.Plane()
|
||||
n = pln.Axis().Direction()
|
||||
from OCP.TopAbs import TopAbs_REVERSED
|
||||
|
||||
if face.Orientation() == TopAbs_REVERSED:
|
||||
n = n.Reversed()
|
||||
nx, ny, nz = n.X(), n.Y(), n.Z()
|
||||
@@ -1319,20 +1408,26 @@ class OCCRenderer(Renderer):
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
|
||||
pln_origin = pln.Location()
|
||||
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
|
||||
d = (
|
||||
(cx - pln_origin.X()) * nx
|
||||
+ (cy - pln_origin.Y()) * ny
|
||||
+ (cz - pln_origin.Z()) * nz
|
||||
)
|
||||
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
|
||||
|
||||
# x_dir: viewport-aligned so connector gizmo matches screen.
|
||||
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
|
||||
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
"type": "planar_face",
|
||||
"position": origin,
|
||||
"normal": (nx, ny, nz),
|
||||
"x_dir": x_dir,
|
||||
"face": face,
|
||||
"owner_obj_id": owner_obj_id,
|
||||
}]
|
||||
}
|
||||
]
|
||||
|
||||
elif stype == GeomAbs_Cylinder:
|
||||
cyl = adaptor.Cylinder()
|
||||
@@ -1356,9 +1451,11 @@ class OCCRenderer(Renderer):
|
||||
if curve_adaptor.GetType() == GeomAbs_Circle:
|
||||
circ = curve_adaptor.Circle()
|
||||
center_pnt = circ.Location()
|
||||
circle_centers.append(np.array([
|
||||
center_pnt.X(), center_pnt.Y(), center_pnt.Z()
|
||||
], dtype=float))
|
||||
circle_centers.append(
|
||||
np.array(
|
||||
[center_pnt.X(), center_pnt.Y(), center_pnt.Z()], dtype=float
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
edge_explorer.Next()
|
||||
@@ -1368,9 +1465,7 @@ class OCCRenderer(Renderer):
|
||||
if len(circle_centers) >= 2:
|
||||
# Project each center onto the axis direction to get a
|
||||
# scalar "height" value. Cluster into two groups.
|
||||
ax_dir_np = np.array([
|
||||
ax_dir.X(), ax_dir.Y(), ax_dir.Z()
|
||||
], dtype=float)
|
||||
ax_dir_np = np.array([ax_dir.X(), ax_dir.Y(), ax_dir.Z()], dtype=float)
|
||||
heights = [np.dot(c, ax_dir_np) for c in circle_centers]
|
||||
# Sort by height (scalar) and split roughly in half.
|
||||
indexed = list(enumerate(heights))
|
||||
@@ -1391,16 +1486,22 @@ class OCCRenderer(Renderer):
|
||||
# No circular edges found — fall back to parameter-based.
|
||||
vmin = adaptor.FirstVParameter()
|
||||
vmax = adaptor.LastVParameter()
|
||||
c0 = np.array([
|
||||
c0 = np.array(
|
||||
[
|
||||
ax_pos.X() + ax_dir.X() * vmin,
|
||||
ax_pos.Y() + ax_dir.Y() * vmin,
|
||||
ax_pos.Z() + ax_dir.Z() * vmin,
|
||||
], dtype=float)
|
||||
c1 = np.array([
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
c1 = np.array(
|
||||
[
|
||||
ax_pos.X() + ax_dir.X() * vmax,
|
||||
ax_pos.Y() + ax_dir.Y() * vmax,
|
||||
ax_pos.Z() + ax_dir.Z() * vmax,
|
||||
], dtype=float)
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
# Normal = the cylinder axis direction. This is the "bolt
|
||||
# axis": the direction a bolt would travel INTO the hole.
|
||||
@@ -1423,7 +1524,8 @@ class OCCRenderer(Renderer):
|
||||
results: List[Dict[str, Any]] = []
|
||||
for end_center in [c0, c1]:
|
||||
origin = (float(end_center[0]), float(end_center[1]), float(end_center[2]))
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
"type": "cylindrical_face",
|
||||
"position": origin,
|
||||
"normal": normal,
|
||||
@@ -1431,7 +1533,8 @@ class OCCRenderer(Renderer):
|
||||
"face": face,
|
||||
"owner_obj_id": owner_obj_id,
|
||||
"radius": radius,
|
||||
})
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
# Try edge.
|
||||
@@ -1475,14 +1578,16 @@ class OCCRenderer(Renderer):
|
||||
x = x / xlen
|
||||
x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
||||
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
"type": "edge",
|
||||
"position": position,
|
||||
"normal": tangent,
|
||||
"x_dir": x_dir,
|
||||
"edge": edge,
|
||||
"owner_obj_id": owner_obj_id,
|
||||
}]
|
||||
}
|
||||
]
|
||||
|
||||
# Try vertex.
|
||||
vertex = None
|
||||
@@ -1490,21 +1595,26 @@ class OCCRenderer(Renderer):
|
||||
vertex = TopoDS.Vertex_s(shape)
|
||||
p = BRep_Tool.Pnt_s(vertex)
|
||||
position = (p.X(), p.Y(), p.Z())
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
"type": "vertex",
|
||||
"position": position,
|
||||
"normal": None,
|
||||
"x_dir": None,
|
||||
"vertex": vertex,
|
||||
"owner_obj_id": owner_obj_id,
|
||||
}]
|
||||
}
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return []
|
||||
|
||||
def probe_snap_candidates(
|
||||
self, x: int, y: int, radius: int = 30,
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
radius: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Probe a pixel grid around (x, y) and return visible snap candidates.
|
||||
|
||||
@@ -1534,13 +1644,28 @@ class OCCRenderer(Renderer):
|
||||
ring_offsets = [
|
||||
(0, 0),
|
||||
# Full radius ring (cardinal + diagonal)
|
||||
(-radius, 0), (radius, 0), (0, -radius), (0, radius),
|
||||
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
|
||||
(-radius, 0),
|
||||
(radius, 0),
|
||||
(0, -radius),
|
||||
(0, radius),
|
||||
(-radius, -radius),
|
||||
(radius, radius),
|
||||
(-radius, radius),
|
||||
(radius, -radius),
|
||||
# Half-radius ring
|
||||
(-h, 0), (h, 0), (0, -h), (0, h),
|
||||
(-h, -h), (h, h), (-h, h), (h, -h),
|
||||
(-h, 0),
|
||||
(h, 0),
|
||||
(0, -h),
|
||||
(0, h),
|
||||
(-h, -h),
|
||||
(h, h),
|
||||
(-h, h),
|
||||
(h, -h),
|
||||
# Quarter-radius ring for small features
|
||||
(-q, 0), (q, 0), (0, -q), (0, q),
|
||||
(-q, 0),
|
||||
(q, 0),
|
||||
(0, -q),
|
||||
(0, q),
|
||||
]
|
||||
|
||||
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
|
||||
@@ -1576,7 +1701,11 @@ class OCCRenderer(Renderer):
|
||||
|
||||
# Sort by screen-space distance to the cursor, nearest first.
|
||||
results = list(candidates.values())
|
||||
results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2)
|
||||
results.sort(
|
||||
key=lambda c: (
|
||||
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
|
||||
@@ -1592,6 +1721,7 @@ class OCCRenderer(Renderer):
|
||||
from OCP.gp import gp_Pnt
|
||||
from OCP.AIS import AIS_Shape
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
|
||||
try:
|
||||
scaled_size = size * self._get_gizmo_scale(position)
|
||||
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape()
|
||||
@@ -1742,7 +1872,9 @@ class OCCRenderer(Renderer):
|
||||
continue
|
||||
# Skip the primary itself — it gets its own bright marker.
|
||||
if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == (
|
||||
round(px, 1), round(py, 1), round(pz, 1)
|
||||
round(px, 1),
|
||||
round(py, 1),
|
||||
round(pz, 1),
|
||||
):
|
||||
continue
|
||||
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
|
||||
@@ -1772,9 +1904,7 @@ class OCCRenderer(Renderer):
|
||||
ey = origin[1] + uy * length
|
||||
ez = origin[2] + uz * length
|
||||
|
||||
edge = BRepBuilderAPI_MakeEdge(
|
||||
gp_Pnt(*origin), gp_Pnt(ex, ey, ez)
|
||||
).Edge()
|
||||
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
|
||||
ais = AIS_Shape(edge)
|
||||
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
|
||||
ais.SetDisplayMode(0) # wireframe
|
||||
@@ -1800,12 +1930,17 @@ class OCCRenderer(Renderer):
|
||||
# visual balance. This reads as 'bolt axis through hole'.
|
||||
_make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
|
||||
_make_axis_line(
|
||||
position, (-normal[0], -normal[1], -normal[2]),
|
||||
axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub",
|
||||
position,
|
||||
(-normal[0], -normal[1], -normal[2]),
|
||||
axis_length * 0.4,
|
||||
(0.6, 0.6, 0.6),
|
||||
"axis_stub",
|
||||
)
|
||||
# Radial reference (same colour as the marker).
|
||||
if x_dir is not None:
|
||||
_make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial")
|
||||
_make_axis_line(
|
||||
position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial"
|
||||
)
|
||||
|
||||
elif entity_type == "edge" and normal is not None:
|
||||
# Tangent direction at midpoint.
|
||||
@@ -1858,6 +1993,7 @@ class OCCRenderer(Renderer):
|
||||
return
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.AIS import AIS_Shape
|
||||
|
||||
for robj in self._objects.values():
|
||||
if robj.ais_shape is not None:
|
||||
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
|
||||
@@ -1878,6 +2014,7 @@ class OCCRenderer(Renderer):
|
||||
return
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.AIS import AIS_Shape
|
||||
|
||||
for robj in self._objects.values():
|
||||
if robj.ais_shape is not None:
|
||||
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
|
||||
@@ -1915,7 +2052,10 @@ class OCCRenderer(Renderer):
|
||||
return None
|
||||
|
||||
def probe_snap_candidates_geometric(
|
||||
self, x: int, y: int, radius: int = 30,
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
radius: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Probe snap candidates by iterating geometry directly (no selection system).
|
||||
|
||||
@@ -1937,7 +2077,6 @@ class OCCRenderer(Renderer):
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
import numpy as np
|
||||
|
||||
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
|
||||
# Expand the search radius for the bbox pre-filter so features near
|
||||
@@ -1965,10 +2104,14 @@ class OCCRenderer(Renderer):
|
||||
bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
|
||||
# Project the 8 AABB corners to screen.
|
||||
corners = [
|
||||
(bx0, by0, bz0), (bx1, by0, bz0),
|
||||
(bx0, by1, bz0), (bx1, by1, bz0),
|
||||
(bx0, by0, bz1), (bx1, by0, bz1),
|
||||
(bx0, by1, bz1), (bx1, by1, bz1),
|
||||
(bx0, by0, bz0),
|
||||
(bx1, by0, bz0),
|
||||
(bx0, by1, bz0),
|
||||
(bx1, by1, bz0),
|
||||
(bx0, by0, bz1),
|
||||
(bx1, by0, bz1),
|
||||
(bx0, by1, bz1),
|
||||
(bx1, by1, bz1),
|
||||
]
|
||||
sx_min, sy_min = 99999, 99999
|
||||
sx_max, sy_max = -99999, -99999
|
||||
@@ -1984,8 +2127,12 @@ class OCCRenderer(Renderer):
|
||||
if all_behind:
|
||||
continue
|
||||
# Check if cursor is within margin of the screen bbox.
|
||||
if (x < sx_min - margin or x > sx_max + margin or
|
||||
y < sy_min - margin or y > sy_max + margin):
|
||||
if (
|
||||
x < sx_min - margin
|
||||
or x > sx_max + margin
|
||||
or y < sy_min - margin
|
||||
or y > sy_max + margin
|
||||
):
|
||||
continue
|
||||
except Exception:
|
||||
pass # If bbox fails, fall through and try features.
|
||||
@@ -2060,8 +2207,9 @@ class OCCRenderer(Renderer):
|
||||
# Sort by screen-space distance to cursor, nearest first.
|
||||
results = list(candidates.values())
|
||||
results.sort(
|
||||
key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2
|
||||
+ (c.get("screen", (x, y))[1] - y) ** 2
|
||||
key=lambda c: (
|
||||
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -2081,7 +2229,6 @@ class OCCRenderer(Renderer):
|
||||
* ``suggestion`` — human-readable snap suggestion
|
||||
* ``feature_data`` — dict with feature-specific info (radius, axis, etc.)
|
||||
"""
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
|
||||
# Group candidates by owner_obj_id.
|
||||
@@ -2126,10 +2273,9 @@ class OCCRenderer(Renderer):
|
||||
if etype == "edge":
|
||||
# Look for other edges nearby that might form a loop.
|
||||
nearby_edges = [
|
||||
n for n in candidates
|
||||
if n.get("type") == "edge"
|
||||
and n.get("owner_obj_id") == owner
|
||||
and n is not c
|
||||
n
|
||||
for n in candidates
|
||||
if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n is not c
|
||||
]
|
||||
# For now, mark as edge — loop detection is complex.
|
||||
ec["feature_type"] = "edge"
|
||||
@@ -2145,9 +2291,9 @@ class OCCRenderer(Renderer):
|
||||
if etype == "vertex":
|
||||
# Look for edges that share this vertex (nearby edges).
|
||||
nearby_edges = [
|
||||
n for n in candidates
|
||||
if n.get("type") == "edge"
|
||||
and n.get("owner_obj_id") == owner
|
||||
n
|
||||
for n in candidates
|
||||
if n.get("type") == "edge" and n.get("owner_obj_id") == owner
|
||||
]
|
||||
if len(nearby_edges) >= 2:
|
||||
ec["feature_type"] = "meeting_edges"
|
||||
@@ -2180,6 +2326,7 @@ class OCCRenderer(Renderer):
|
||||
def _qt_buttons(self, event) -> Any:
|
||||
"""Return the PySide6 Qt enum module lazily."""
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
return Qt
|
||||
|
||||
def handle_mouse_press(self, event) -> None:
|
||||
|
||||
+230
-84
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Tuple
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
@@ -19,11 +20,20 @@ from PySide6.QtWidgets import (
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _vec3(value: Any) -> Tuple[float, float, float]:
|
||||
"""Coerce a 3-vector to a typed float triple (defensive fallback)."""
|
||||
try:
|
||||
return (float(value[0]), float(value[1]), float(value[2]))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
class ExtrudeDialog(QDialog):
|
||||
"""Dialog for extrude options.
|
||||
|
||||
@@ -33,7 +43,7 @@ class ExtrudeDialog(QDialog):
|
||||
*None*) to the callback tells the host to clear the preview.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Extrude Options")
|
||||
self.setMinimumWidth(320)
|
||||
@@ -82,8 +92,8 @@ class ExtrudeDialog(QDialog):
|
||||
layout.addWidget(self.rounded_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
@@ -95,9 +105,9 @@ class ExtrudeDialog(QDialog):
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview: recompute on every option change. Use a light-
|
||||
# weight guard so we don't emit before the host has wired up the
|
||||
# callback.
|
||||
# Live preview: recompute on every option change. Wire each widget
|
||||
# to its own signal by type — spinboxes emit ``valueChanged``,
|
||||
# checkboxes emit ``stateChanged``.
|
||||
for w in (
|
||||
self.length_input,
|
||||
self.symmetric_checkbox,
|
||||
@@ -108,27 +118,18 @@ class ExtrudeDialog(QDialog):
|
||||
self.cut_all_bodies_checkbox,
|
||||
self.rounded_checkbox,
|
||||
):
|
||||
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||
# Each must be wired in its own try/except so that a missing
|
||||
# signal on one widget type doesn't skip the OTHER signal's
|
||||
# connection (the prior single-try version accidentally
|
||||
# left checkboxes un-connected when valueChanged raised first).
|
||||
try:
|
||||
if isinstance(w, QDoubleSpinBox):
|
||||
w.valueChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
else:
|
||||
w.stateChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
# Emit once so the initial state shows a preview right away.
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
def _emit_preview(self, *args: Any) -> None:
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
@@ -136,7 +137,7 @@ class ExtrudeDialog(QDialog):
|
||||
except Exception as exc: # preview must never break the dialog
|
||||
logger.debug("extrude preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
def hideEvent(self, event: Any) -> None:
|
||||
# Tell the host to clear the preview when the dialog goes away
|
||||
# (accept, reject, or close). The host is responsible for the
|
||||
# actual viewer cleanup.
|
||||
@@ -163,7 +164,7 @@ class ExtrudeDialog(QDialog):
|
||||
class RevolveDialog(QDialog):
|
||||
"""Dialog for revolve options."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Revolve Options")
|
||||
self.setMinimumWidth(300)
|
||||
@@ -181,8 +182,8 @@ class RevolveDialog(QDialog):
|
||||
layout.addLayout(angle_layout)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
@@ -203,7 +204,7 @@ class OffsetDialog(QDialog):
|
||||
time. On accept the caller retrieves ``get_values()`` → distance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Offset Sketch")
|
||||
self.setMinimumWidth(300)
|
||||
@@ -227,8 +228,8 @@ class OffsetDialog(QDialog):
|
||||
layout.addWidget(self.inward_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
@@ -244,12 +245,12 @@ class OffsetDialog(QDialog):
|
||||
self.distance_input.valueChanged.connect(self._emit_preview)
|
||||
self.inward_checkbox.stateChanged.connect(self._emit_preview)
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
def _emit_preview(self, *args: Any) -> None:
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
@@ -257,7 +258,7 @@ class OffsetDialog(QDialog):
|
||||
except Exception as exc:
|
||||
logger.debug("offset preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
def hideEvent(self, event: Any) -> None:
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
@@ -277,7 +278,7 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
returns (normal, x_dir) pair (both as 3-tuples).
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("New Workplane Orientation")
|
||||
self.setMinimumWidth(320)
|
||||
@@ -297,6 +298,12 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._preset_group = QButtonGroup(self)
|
||||
# normal/x_dir per preset, keyed by the QButtonGroup id — QRadioButton
|
||||
# has no data slot of its own, so stash the vectors here instead of
|
||||
# duck-typing extra attributes onto the widget.
|
||||
self._preset_vectors: Dict[
|
||||
int, Tuple[Tuple[float, float, float], Tuple[float, float, float]]
|
||||
] = {}
|
||||
preset_layout = QGridLayout()
|
||||
presets = [
|
||||
("XY (Top)", (0, 0, 1), (1, 0, 0)),
|
||||
@@ -310,15 +317,14 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
btn = QRadioButton(label)
|
||||
btn.setChecked(idx == 0)
|
||||
self._preset_group.addButton(btn, idx)
|
||||
btn.normal = normal
|
||||
btn.x_dir = x_dir
|
||||
self._preset_vectors[idx] = (_vec3(normal), _vec3(x_dir))
|
||||
preset_layout.addWidget(btn, idx // 2, idx % 2)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# ── Custom angle (offset from XY) ──
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
self._custom_radio = QRadioButton("Custom (angle from XY):")
|
||||
@@ -351,8 +357,8 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
|
||||
# ── Buttons ──
|
||||
line2 = QFrame()
|
||||
line2.setFrameShape(QFrame.HLine)
|
||||
line2.setFrameShadow(QFrame.Sunken)
|
||||
line2.setFrameShape(QFrame.Shape.HLine)
|
||||
line2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
layout.addWidget(line2)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
@@ -371,7 +377,7 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
self._angle_x.valueChanged.connect(self._emit_preview)
|
||||
self._angle_y.valueChanged.connect(self._emit_preview)
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
|
||||
"""Install a callback for live 3D preview of the workplane orientation.
|
||||
|
||||
*callback* is called with ``(normal, x_dir)`` whenever the user
|
||||
@@ -381,7 +387,7 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
# Emit once so the initial state shows a preview right away.
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
def _emit_preview(self, *args: Any) -> None:
|
||||
"""Call the preview callback with the current orientation, if installed."""
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
@@ -391,7 +397,7 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
except Exception as exc:
|
||||
logger.debug("workplane preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
def hideEvent(self, event: Any) -> None:
|
||||
"""Clear the live preview when the dialog closes."""
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
@@ -400,17 +406,23 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def _on_preset_changed(self, btn):
|
||||
def _on_preset_changed(self, btn: Any) -> None:
|
||||
"""When a preset is selected, deselect the custom radio and emit preview."""
|
||||
self._custom_radio.setChecked(False)
|
||||
self._emit_preview()
|
||||
|
||||
def _on_ok(self):
|
||||
"""Compute the final orientation and accept."""
|
||||
def _compute_custom_orientation(
|
||||
self,
|
||||
) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
|
||||
"""Compute ``(normal, x_dir)`` from the custom angle spinboxes.
|
||||
|
||||
Starts from the +Z normal and rotates by the two angle values.
|
||||
Returns *None* if the math fails (defensive — the dialog then falls
|
||||
back to the default orientation instead of crashing).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
# Custom: start from XY normal and rotate by the two angles.
|
||||
try:
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
# Start from +Z normal, rotate around X then Y
|
||||
@@ -433,10 +445,13 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
]
|
||||
)
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
n_norm = np.linalg.norm(n)
|
||||
if n_norm < 1e-12:
|
||||
return None
|
||||
n = n / n_norm
|
||||
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
if abs(np.dot(n, world_y)) > 0.99:
|
||||
if abs(float(np.dot(n, world_y))) > 0.99:
|
||||
world_y = np.array([0.0, 0.0, 1.0])
|
||||
x = np.cross(world_y, n)
|
||||
x_norm = np.linalg.norm(x)
|
||||
@@ -444,14 +459,24 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
x = x / x_norm
|
||||
else:
|
||||
x = np.array([1.0, 0.0, 0.0])
|
||||
self._normal = tuple(float(v) for v in n)
|
||||
self._x_dir = tuple(float(v) for v in x)
|
||||
return (
|
||||
(float(n[0]), float(n[1]), float(n[2])),
|
||||
(float(x[0]), float(x[1]), float(x[2])),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("custom workplane orientation math failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _on_ok(self) -> None:
|
||||
"""Compute the final orientation and accept."""
|
||||
if self._custom_radio.isChecked():
|
||||
orientation = self._compute_custom_orientation()
|
||||
if orientation is not None:
|
||||
self._normal, self._x_dir = orientation
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
self._normal = btn.normal
|
||||
self._x_dir = btn.x_dir
|
||||
self._normal, self._x_dir = self._preset_vectors[self._preset_group.id(btn)]
|
||||
self.accept()
|
||||
|
||||
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
|
||||
@@ -460,50 +485,171 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
Computes the current selection from the UI state so it works
|
||||
whether called before or after ``_on_ok``.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
rx = np.array(
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
]
|
||||
)
|
||||
n = rx @ n
|
||||
ry = np.array(
|
||||
[
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
]
|
||||
)
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
if abs(np.dot(n, world_y)) > 0.99:
|
||||
world_y = np.array([0.0, 0.0, 1.0])
|
||||
x = np.cross(world_y, n)
|
||||
x_norm = np.linalg.norm(x)
|
||||
if x_norm > 1e-9:
|
||||
x = x / x_norm
|
||||
else:
|
||||
x = np.array([1.0, 0.0, 0.0])
|
||||
orientation = self._compute_custom_orientation()
|
||||
if orientation is None:
|
||||
orientation = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0))
|
||||
normal, x_dir = orientation
|
||||
return (
|
||||
tuple(float(v) for v in n),
|
||||
tuple(float(v) for v in x),
|
||||
normal,
|
||||
x_dir,
|
||||
self._name_input.text().strip() or "Workplane",
|
||||
)
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||
normal, x_dir = self._preset_vectors[self._preset_group.id(btn)]
|
||||
return (normal, x_dir, self._name_input.text().strip() or "Workplane")
|
||||
# Fallback: XY default.
|
||||
return (
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
self._name_input.text().strip() or "Workplane",
|
||||
)
|
||||
|
||||
|
||||
class FilletDialog(QDialog):
|
||||
"""Dialog for fillet options — the common settings from CAD fillet tools.
|
||||
|
||||
Shown AFTER the user has picked the two faces whose shared edges will
|
||||
be rounded. Offers:
|
||||
|
||||
- size, entered as **diameter** or **radius** (the user asked for a
|
||||
diameter box; the unit toggle covers the radius crowd),
|
||||
- **tangent propagation** (extend the round along tangent-connected
|
||||
edges, like FreeCAD/SolidWorks "tangent chain"),
|
||||
- edge **scope** (only the edges between the two picked faces vs
|
||||
every edge of the body),
|
||||
- a live 3D preview (``set_preview_callback``), so dragging the size
|
||||
spinner shows the fillet in real time before committing.
|
||||
|
||||
``get_values()`` returns ``(size, size_is_diameter, tangent_propagation,
|
||||
scope)`` where *scope* is ``"selected"`` or ``"all"``. The host
|
||||
converts *size* to a radius (``size / 2`` for diameter).
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Fillet Options")
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
self._preview_callback: Optional[Callable[[Any], None]] = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ── Size: value + Diameter/Radius unit ──
|
||||
size_row = QHBoxLayout()
|
||||
self.size_unit_combo = QComboBox()
|
||||
self.size_unit_combo.addItems(["Diameter", "Radius"])
|
||||
self.size_unit_combo.setToolTip(
|
||||
"Enter the fillet size as a diameter or a radius (radius = diameter / 2)."
|
||||
)
|
||||
size_row.addWidget(self.size_unit_combo)
|
||||
|
||||
self.size_value_label = QLabel("Diameter (mm):")
|
||||
size_row.addWidget(self.size_value_label)
|
||||
|
||||
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("Fillet size along the rounded edge.")
|
||||
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("Round only the edges shared by the two picked faces.")
|
||||
self.scope_all_radio = QRadioButton("All edges of body")
|
||||
self.scope_all_radio.setToolTip(
|
||||
"Round 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 fillet 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 Fillet")
|
||||
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_unit_combo.currentIndexChanged.connect(self._on_unit_changed)
|
||||
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 _on_unit_changed(self) -> None:
|
||||
"""Swap the size label between Diameter and Radius."""
|
||||
self.size_value_label.setText(
|
||||
"Radius (mm):" if self.size_unit_combo.currentText() == "Radius" else "Diameter (mm):"
|
||||
)
|
||||
self._emit_preview()
|
||||
|
||||
def set_edge_count(self, count: int) -> None:
|
||||
"""Show how many edges the current scope will round."""
|
||||
self.edge_label.setText(
|
||||
f"Fillets {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("fillet 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, bool, str]:
|
||||
"""Return ``(size, size_is_diameter, tangent_propagation, scope)``."""
|
||||
return (
|
||||
self.size_input.value(),
|
||||
self.size_unit_combo.currentText() == "Diameter",
|
||||
self.tangent_checkbox.isChecked(),
|
||||
"all" if self.scope_all_radio.isChecked() else "selected",
|
||||
)
|
||||
|
||||
@@ -19,15 +19,18 @@ from PySide6.QtGui import (
|
||||
MAX_RECENT_PROJECTS = 10
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
@@ -45,6 +48,7 @@ from fluency.models.data_model import Project, Component, Sketch, Body, Workplan
|
||||
|
||||
from fluency.ui.dialogs import (
|
||||
ExtrudeDialog,
|
||||
FilletDialog,
|
||||
OffsetDialog,
|
||||
RevolveDialog,
|
||||
WorkplaneOrientationDialog,
|
||||
@@ -641,6 +645,204 @@ def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) ->
|
||||
return face_geom
|
||||
|
||||
|
||||
# ── Fillet edge helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _edge_fingerprint(edge: Any) -> str:
|
||||
"""Stable textual fingerprint of an edge (sorted endpoint coordinates).
|
||||
|
||||
Used to persist fillet edge selections in the feature history so the
|
||||
replay can re-find the edges after the base geometry is rebuilt.
|
||||
"""
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_VERTEX
|
||||
|
||||
pts = []
|
||||
ex = TopExp_Explorer(edge, TopAbs_VERTEX)
|
||||
while ex.More():
|
||||
p = BRep_Tool.Pnt_s(TopoDS.Vertex_s(ex.Current()))
|
||||
pts.append((round(p.X(), 4), round(p.Y(), 4), round(p.Z(), 4)))
|
||||
ex.Next()
|
||||
return repr(sorted(pts))
|
||||
|
||||
|
||||
def _shared_edges_between_faces(shape: Any, face1: Any, face2: Any) -> List[Any]:
|
||||
"""Edges that belong to BOTH faces — the edges a fillet rounds.
|
||||
|
||||
*shape* is only used for context in the signature (the caller already
|
||||
resolved it from the body); the actual test is ``IsSame`` on the two
|
||||
faces' edge explorers.
|
||||
"""
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
|
||||
def _edges(face: Any) -> List[Any]:
|
||||
out = []
|
||||
ex = TopExp_Explorer(face, TopAbs_EDGE)
|
||||
while ex.More():
|
||||
out.append(TopoDS.Edge_s(ex.Current()))
|
||||
ex.Next()
|
||||
return out
|
||||
|
||||
e1 = _edges(face1)
|
||||
e2 = _edges(face2)
|
||||
return [a for a in e1 for b in e2 if a.IsSame(b)]
|
||||
|
||||
|
||||
def _edge_tangent_away(edge: Any, vertex: Any) -> Any:
|
||||
"""Unit tangent of *edge* at *vertex*, pointing AWAY from the vertex.
|
||||
|
||||
Two edges are tangent-connected at a shared vertex when these away-
|
||||
directions are (anti-)collinear. Falls back to ``(0,0,0)`` semantics
|
||||
(a zero vector) if the curve evaluation fails.
|
||||
"""
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_VERTEX
|
||||
from OCP.GeomLProp import GeomLProp_CLProps
|
||||
from OCP.gp import gp_Dir
|
||||
|
||||
edge = TopoDS.Edge_s(edge)
|
||||
vertex = TopoDS.Vertex_s(vertex)
|
||||
ad = BRepAdaptor_Curve(edge)
|
||||
first, last = ad.FirstParameter(), ad.LastParameter()
|
||||
curve = BRep_Tool.Curve_s(edge, edge.Location(), 0.0, 0.0)
|
||||
|
||||
# Which end is the vertex at?
|
||||
vex = TopExp_Explorer(edge, TopAbs_VERTEX)
|
||||
v1 = TopoDS.Vertex_s(vex.Current())
|
||||
vex.Next()
|
||||
v2 = TopoDS.Vertex_s(vex.Current()) if vex.More() else None
|
||||
if v2 is not None and v1.IsSame(vertex):
|
||||
use_first = True
|
||||
elif v2 is not None and v2.IsSame(vertex):
|
||||
use_first = False
|
||||
else:
|
||||
# Closed edge (single vertex) or ambiguous — use the start end.
|
||||
use_first = True
|
||||
|
||||
d = gp_Dir()
|
||||
try:
|
||||
props = GeomLProp_CLProps(curve, 2, 1e-6)
|
||||
if use_first:
|
||||
props.SetParameter(first)
|
||||
else:
|
||||
props.SetParameter(last)
|
||||
props.Tangent(d)
|
||||
if not use_first:
|
||||
d.Reverse()
|
||||
except Exception as exc:
|
||||
logger.debug("edge tangent eval failed: %s", exc)
|
||||
return d
|
||||
|
||||
|
||||
def _expand_tangent_chain(shape: Any, seeds: List[Any]) -> List[Any]:
|
||||
"""BFS-expand *seeds* to every edge smoothly connected to them.
|
||||
|
||||
An adjacent edge joins the chain when, at the shared vertex, its
|
||||
away-tangent is (anti-)collinear with the seed edge's away-tangent —
|
||||
i.e. the geometry continues without a corner. This is the "tangent
|
||||
propagation" behaviour of mainstream CAD fillet tools.
|
||||
"""
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX
|
||||
|
||||
all_edges = []
|
||||
ex = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while ex.More():
|
||||
all_edges.append(TopoDS.Edge_s(ex.Current()))
|
||||
ex.Next()
|
||||
|
||||
# vertex location -> incident edges
|
||||
adj: Dict[Tuple[float, float, float], List[Any]] = {}
|
||||
for e in all_edges:
|
||||
vex = TopExp_Explorer(e, TopAbs_VERTEX)
|
||||
while vex.More():
|
||||
p = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vex.Current()))
|
||||
key = (round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6))
|
||||
adj.setdefault(key, []).append(e)
|
||||
vex.Next()
|
||||
|
||||
def _key(edge: Any) -> Tuple[Any, ...]:
|
||||
pts = []
|
||||
vex = TopExp_Explorer(edge, TopAbs_VERTEX)
|
||||
while vex.More():
|
||||
p = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vex.Current()))
|
||||
pts.append((round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6)))
|
||||
vex.Next()
|
||||
return tuple(sorted(pts))
|
||||
|
||||
selected = list(seeds)
|
||||
frontier = list(seeds)
|
||||
seen = {_key(e) for e in seeds}
|
||||
while frontier:
|
||||
cur = frontier.pop()
|
||||
vex = TopExp_Explorer(cur, TopAbs_VERTEX)
|
||||
while vex.More():
|
||||
v = TopoDS.Vertex_s(vex.Current())
|
||||
p = BRep_Tool.Pnt_s(v)
|
||||
key = (round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6))
|
||||
t_cur = _edge_tangent_away(cur, v)
|
||||
for cand in adj.get(key, []):
|
||||
k = _key(cand)
|
||||
if k in seen:
|
||||
continue
|
||||
t_cand = _edge_tangent_away(cand, v)
|
||||
dot = abs(t_cur.X() * t_cand.X() + t_cur.Y() * t_cand.Y() + t_cur.Z() * t_cand.Z())
|
||||
if dot > 0.999:
|
||||
seen.add(k)
|
||||
selected.append(cand)
|
||||
frontier.append(cand)
|
||||
vex.Next()
|
||||
return selected
|
||||
|
||||
|
||||
def _resolve_fillet_edges(
|
||||
shape: Any, seed_edges: List[Any], tangent_propagation: bool, scope: str
|
||||
) -> Optional[List[Any]]:
|
||||
"""Compute the edge list to fillet for the given dialog settings.
|
||||
|
||||
Returns *None* for ``scope == "all"`` (the kernel then rounds every
|
||||
edge); otherwise the seed edges, optionally expanded along tangent
|
||||
chains.
|
||||
"""
|
||||
if scope == "all":
|
||||
return None
|
||||
if not tangent_propagation:
|
||||
return seed_edges
|
||||
return _expand_tangent_chain(shape, seed_edges)
|
||||
|
||||
|
||||
def _resolve_edges_by_fingerprint(shape: Any, refs: List[str]) -> List[Any]:
|
||||
"""Find the edges of *shape* matching the stored fingerprints.
|
||||
|
||||
Best-effort: edges whose endpoints moved or disappeared are simply
|
||||
skipped. Returns an empty list when nothing matches.
|
||||
"""
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
|
||||
target = set(refs)
|
||||
if not target:
|
||||
return []
|
||||
out = []
|
||||
ex = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while ex.More():
|
||||
e = TopoDS.Edge_s(ex.Current())
|
||||
if _edge_fingerprint(e) in target:
|
||||
out.append(e)
|
||||
ex.Next()
|
||||
return out
|
||||
|
||||
|
||||
def _replay_body_features(
|
||||
kernel: OCGeometryKernel,
|
||||
body: Body,
|
||||
@@ -659,6 +861,32 @@ def _replay_body_features(
|
||||
geom = feat.geometry
|
||||
continue
|
||||
|
||||
if feat.operation == "fillet":
|
||||
# Fillet needs no sketch — it rounds edges of the running solid.
|
||||
if geom is None:
|
||||
logger.warning(f"Body '{body.name}': fillet feature has no base, replay aborted")
|
||||
return None
|
||||
if feat.radius is None:
|
||||
logger.warning(f"Body '{body.name}': fillet feature has no radius, replay aborted")
|
||||
return None
|
||||
if feat.scope == "all" or not feat.edge_refs:
|
||||
edges: Optional[List[Any]] = None # round every edge
|
||||
else:
|
||||
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
|
||||
if not edges:
|
||||
# The referenced edges no longer exist after a topology
|
||||
# change — abort so the body keeps its previous state
|
||||
# (marked ⚠) instead of silently rounding nothing.
|
||||
logger.warning(
|
||||
f"Body '{body.name}': fillet edge refs unresolved after rebuild, "
|
||||
"replay aborted"
|
||||
)
|
||||
return None
|
||||
geom = kernel.fillet(geom, feat.radius, edges=edges)
|
||||
if geom is None:
|
||||
return None
|
||||
continue
|
||||
|
||||
sketch = feat.sketch
|
||||
if sketch is None or sketch.occ_sketch is None:
|
||||
logger.warning(
|
||||
@@ -741,6 +969,12 @@ class MainWindow(QMainWindow):
|
||||
self._current_sketch: Optional[Sketch] = None
|
||||
self._selected_body: Optional[Body] = None
|
||||
|
||||
# Fillet tool: two-face pick flow (face 1 → face 2 → options dialog).
|
||||
self._fillet_pick_active: bool = False
|
||||
self._fillet_face1: Optional[Any] = None
|
||||
self._fillet_face2: Optional[Any] = None
|
||||
self._fillet_body: Optional[Body] = None
|
||||
|
||||
self._component_buttons: List[QPushButton] = []
|
||||
self._component_group: Optional[QButtonGroup] = None
|
||||
|
||||
@@ -971,6 +1205,36 @@ class MainWindow(QMainWindow):
|
||||
# Panel-focus mode (equal | sketch | viewer).
|
||||
self._panel_focus: str = "equal"
|
||||
|
||||
# ── Body operations list (feature history of the selected body) ──
|
||||
# Shown in the "Bodys / Operations" group box, between the body
|
||||
# list and its Tools row. Each operation can be deleted to revert
|
||||
# the body to its state before that operation.
|
||||
self._operations_list = QListWidget()
|
||||
self._operations_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self._operations_list.setToolTip(
|
||||
"Operations of the selected body (top = newest).\n"
|
||||
"Select one and press 'Del Op' to revert the body to before it."
|
||||
)
|
||||
ops_layout = self._ui.groupBox_10.layout()
|
||||
if ops_layout is None:
|
||||
ops_layout = QVBoxLayout(self._ui.groupBox_10)
|
||||
# Index 1 places it between the body list and the Tools group box.
|
||||
ops_layout.insertWidget(1, self._operations_list)
|
||||
|
||||
self._btn_del_op = QPushButton("Del Op")
|
||||
self._btn_del_op.setEnabled(False)
|
||||
self._btn_del_op.setToolTip(
|
||||
"Delete the selected operation — the body reverts to its state "
|
||||
"before it (the base operation can't be deleted)."
|
||||
)
|
||||
ops_tools = self._ui.groupBox_8.layout()
|
||||
if ops_tools is None:
|
||||
ops_tools = QGridLayout(self._ui.groupBox_8)
|
||||
ops_tools.addWidget(self._btn_del_op, 1, 0, 1, 2)
|
||||
|
||||
self._operations_list.itemSelectionChanged.connect(self._on_operations_selection_changed)
|
||||
self._btn_del_op.clicked.connect(self._on_delete_operation)
|
||||
|
||||
def _setup_ui_aliases(self):
|
||||
"""Create _btn_* aliases pointing to the UI-loaded widgets.
|
||||
|
||||
@@ -1023,6 +1287,8 @@ class MainWindow(QMainWindow):
|
||||
self._btn_move = ui.pb_moveop
|
||||
self._btn_revolve = ui.pb_revop
|
||||
self._btn_array = ui.pb_arrayop
|
||||
self._btn_fillet = ui.pb_fillet_op
|
||||
self._btn_fillet.setCheckable(True)
|
||||
# ── Export ──
|
||||
self._btn_export_stl = ui.pushButton_2
|
||||
self._btn_export_step = ui.pb_export_step
|
||||
@@ -1138,6 +1404,9 @@ class MainWindow(QMainWindow):
|
||||
self._btn_cut.clicked.connect(self._boolean_cut)
|
||||
self._btn_combine.clicked.connect(self._boolean_union)
|
||||
self._btn_revolve.clicked.connect(self._revolve_sketch)
|
||||
self._btn_fillet.clicked.connect(self._on_fillet_button_clicked)
|
||||
self._viewer_3d.filletFacePicked.connect(self._on_fillet_face_picked)
|
||||
self._viewer_3d.filletPickCancelled.connect(self._cancel_fillet_pick)
|
||||
|
||||
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
|
||||
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
|
||||
@@ -1484,6 +1753,109 @@ class MainWindow(QMainWindow):
|
||||
item.setForeground(QColor("#f5a623")) # Orange warning
|
||||
self._body_list.addItem(item)
|
||||
|
||||
self._refresh_operations_list()
|
||||
|
||||
# ── Body operations list (per-body feature history) ───────────────────
|
||||
|
||||
def _describe_feature(self, feat: Feature, index: int) -> str:
|
||||
"""Human-readable one-liner for a feature-history entry."""
|
||||
op = feat.operation
|
||||
if op == "extrude":
|
||||
length = feat.length if feat.length is not None else 10.0
|
||||
return f"{index + 1}. Extrude {length:g} mm"
|
||||
if op == "cut":
|
||||
length = feat.length if feat.length is not None else 10.0
|
||||
return f"{index + 1}. Cut {length:g} mm"
|
||||
if op == "union":
|
||||
return f"{index + 1}. Union"
|
||||
if op == "revolve":
|
||||
angle = feat.angle if feat.angle is not None else 360.0
|
||||
return f"{index + 1}. Revolve {angle:g}°"
|
||||
if op == "fillet":
|
||||
radius = feat.radius if feat.radius is not None else 0.0
|
||||
scope = " (all edges)" if feat.scope == "all" else ""
|
||||
return f"{index + 1}. Fillet r={radius:g} mm{scope}"
|
||||
if op == "base":
|
||||
return f"{index + 1}. Base (baked geometry)"
|
||||
return f"{index + 1}. {op.title()}"
|
||||
|
||||
def _refresh_operations_list(self) -> None:
|
||||
"""Show the selected body's feature history in the ops list.
|
||||
|
||||
The first entry (the body's base) is shown greyed and cannot be
|
||||
selected, because deleting it would leave the body without an
|
||||
origin. Every other entry can be deleted to revert the body to
|
||||
its state before that operation.
|
||||
"""
|
||||
self._operations_list.clear()
|
||||
body = self._selected_body
|
||||
if body is None:
|
||||
self._btn_del_op.setEnabled(False)
|
||||
return
|
||||
features = _ensure_feature_history(body)
|
||||
for index, feat in enumerate(features):
|
||||
item = QListWidgetItem(self._describe_feature(feat, index))
|
||||
item.setData(Qt.ItemDataRole.UserRole, index)
|
||||
if index == 0:
|
||||
item.setForeground(QColor("#6c7086"))
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
|
||||
self._operations_list.addItem(item)
|
||||
self._operations_list.setCurrentRow(min(1, len(features) - 1))
|
||||
self._on_operations_selection_changed()
|
||||
|
||||
def _on_operations_selection_changed(self) -> None:
|
||||
"""Enable 'Del Op' only when a deletable (non-base) op is selected."""
|
||||
item = self._operations_list.currentItem()
|
||||
if item is None:
|
||||
self._btn_del_op.setEnabled(False)
|
||||
return
|
||||
index = item.data(Qt.ItemDataRole.UserRole)
|
||||
self._btn_del_op.setEnabled(index is not None and index > 0)
|
||||
|
||||
def _on_delete_operation(self) -> None:
|
||||
"""Delete the selected operation; the body reverts to before it.
|
||||
|
||||
The feature is removed from the body's history and the body is
|
||||
rebuilt from scratch by replaying the remaining features. If the
|
||||
remaining chain can't be replayed (e.g. a cut sketch depended on
|
||||
the deleted operation's geometry) the body keeps its current
|
||||
geometry and is marked ⚠ — Update retries the rebuild.
|
||||
"""
|
||||
body = self._selected_body
|
||||
if body is None:
|
||||
QMessageBox.information(self, "No Body", "Select a body first.")
|
||||
return
|
||||
item = self._operations_list.currentItem()
|
||||
if item is None:
|
||||
QMessageBox.information(self, "No Operation", "Select an operation in the list first.")
|
||||
return
|
||||
index = item.data(Qt.ItemDataRole.UserRole)
|
||||
features = _ensure_feature_history(body)
|
||||
if not isinstance(index, int) or not (0 <= index < len(features)):
|
||||
return
|
||||
if index == 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Cannot Delete Base",
|
||||
"The first operation is the body's base and can't be deleted.",
|
||||
)
|
||||
return
|
||||
feat = features[index]
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Delete Operation",
|
||||
f"Delete '{self._describe_feature(feat, index)}'?\n\n"
|
||||
"The body will revert to its state before this operation.",
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
del features[index]
|
||||
body.needs_update = True
|
||||
self._mark_dirty()
|
||||
logger.info(f"Body '{body.name}': deleted operation at index {index}")
|
||||
# Rebuild from the remaining features; refreshes lists + ops list.
|
||||
self._update_and_redraw()
|
||||
|
||||
def _update_and_redraw(self):
|
||||
"""Full pipeline: rebuild bodies, redraw, propagate to assembly.
|
||||
|
||||
@@ -3886,6 +4258,7 @@ class MainWindow(QMainWindow):
|
||||
for body_id, body in self._current_component.bodies.items():
|
||||
if body.name == name:
|
||||
self._selected_body = body
|
||||
self._refresh_operations_list()
|
||||
logger.info(f"Selected: {name}")
|
||||
break
|
||||
|
||||
@@ -4638,6 +5011,207 @@ class MainWindow(QMainWindow):
|
||||
logger.exception(f"Boolean union failed: {e}")
|
||||
QMessageBox.critical(self, "Error", f"Boolean union failed: {e}")
|
||||
|
||||
# ── Fillet (two-face pick → options dialog) ────────────────────────────
|
||||
|
||||
def _on_fillet_button_clicked(self) -> None:
|
||||
"""Toggle the fillet face-pick flow from the toolbar button."""
|
||||
if self._fillet_pick_active:
|
||||
self._cancel_fillet_pick()
|
||||
else:
|
||||
self._start_fillet_pick()
|
||||
|
||||
def _start_fillet_pick(self) -> None:
|
||||
"""Enter face-pick mode: the user picks face 1, then face 2."""
|
||||
if not self._current_component or not self._current_component.bodies:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No Body",
|
||||
"Create or import a body first, then pick two of its faces "
|
||||
"to fillet the edge between them.",
|
||||
)
|
||||
# Undo the checkable toggle: we never entered pick mode.
|
||||
self._btn_fillet.setChecked(False)
|
||||
self._fillet_pick_active = False
|
||||
return
|
||||
self._fillet_pick_active = True
|
||||
self._fillet_face1 = None
|
||||
self._fillet_face2 = None
|
||||
self._fillet_body = None
|
||||
self._btn_fillet.setChecked(True)
|
||||
self._viewer_3d.set_fillet_pick_mode(True)
|
||||
# Disarm the sketch-on-surface picker if it was active.
|
||||
self._btn_wp_face.setChecked(False)
|
||||
self._viewer_3d.set_pick_face_mode(False)
|
||||
self.statusBar().showMessage("Fillet: pick the FIRST face", 6000)
|
||||
|
||||
def _cancel_fillet_pick(self) -> None:
|
||||
"""Abort the fillet flow (button toggle, Esc, or after applying)."""
|
||||
self._fillet_pick_active = False
|
||||
self._fillet_face1 = None
|
||||
self._fillet_face2 = None
|
||||
self._fillet_body = None
|
||||
self._viewer_3d.set_fillet_pick_mode(False)
|
||||
self._btn_fillet.setChecked(False)
|
||||
self._viewer_3d.clear_faces_highlight()
|
||||
self._viewer_3d.clear_preview()
|
||||
|
||||
def _fillet_body_for_owner(self, owner_obj_id: Optional[str]) -> Optional[Body]:
|
||||
"""Resolve the body owning a picked face (by render object id).
|
||||
|
||||
Falls back to the component's only body when the owner can't be
|
||||
matched (e.g. an imported shape that wasn't registered).
|
||||
"""
|
||||
if not self._current_component:
|
||||
return None
|
||||
if owner_obj_id:
|
||||
for bid, body in self._current_component.bodies.items():
|
||||
if body.render_object == owner_obj_id:
|
||||
return body
|
||||
if len(self._current_component.bodies) == 1:
|
||||
return next(iter(self._current_component.bodies.values()))
|
||||
return None
|
||||
|
||||
def _on_fillet_face_picked(self, face: Any) -> None:
|
||||
"""Handle one fillet face pick: first face, then second → dialog."""
|
||||
if not self._fillet_pick_active:
|
||||
return
|
||||
owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
|
||||
body = self._fillet_body_for_owner(owner_obj_id)
|
||||
if body is None or body.geometry is None:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Pick a Body Face",
|
||||
"The picked face doesn't belong to a body in the current "
|
||||
"component. Pick a face on a body.",
|
||||
)
|
||||
return
|
||||
|
||||
if self._fillet_face1 is None:
|
||||
self._fillet_face1 = face
|
||||
self._fillet_body = body
|
||||
self._viewer_3d.highlight_faces([face])
|
||||
self.statusBar().showMessage("Fillet: pick the SECOND face", 6000)
|
||||
return
|
||||
|
||||
# Second face: same body, and it must share an edge with face 1.
|
||||
if body is not self._fillet_body:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Different Bodies",
|
||||
"Both faces must belong to the SAME body. Pick the second face again.",
|
||||
)
|
||||
return
|
||||
shape = self._kernel._get_shape(body.geometry)
|
||||
seed_edges = _shared_edges_between_faces(shape, self._fillet_face1, face)
|
||||
if not seed_edges:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No Shared Edge",
|
||||
"These faces don't share an edge. Pick the second face again.",
|
||||
)
|
||||
return
|
||||
|
||||
self._fillet_face2 = face
|
||||
self._viewer_3d.highlight_faces([self._fillet_face1, face])
|
||||
self._open_fillet_dialog(seed_edges)
|
||||
|
||||
def _open_fillet_dialog(self, seed_edges: List[Any]) -> None:
|
||||
"""Show the fillet options dialog with a live preview, then apply."""
|
||||
body = self._fillet_body
|
||||
if body is None or body.geometry is None:
|
||||
self._cancel_fillet_pick()
|
||||
return
|
||||
shape = self._kernel._get_shape(body.geometry)
|
||||
|
||||
dialog = FilletDialog(self)
|
||||
dialog.set_edge_count(len(seed_edges))
|
||||
|
||||
def _preview(values: Any) -> None:
|
||||
if values is None:
|
||||
self._viewer_3d.clear_preview()
|
||||
return
|
||||
size, is_diameter, tangent, scope = values
|
||||
radius = size / 2.0 if is_diameter else size
|
||||
try:
|
||||
edges = _resolve_fillet_edges(shape, seed_edges, tangent, scope)
|
||||
result = self._kernel.fillet(body.geometry, radius, edges=edges)
|
||||
self._viewer_3d.show_preview(self._kernel._get_shape(result))
|
||||
except Exception:
|
||||
self._viewer_3d.clear_preview()
|
||||
|
||||
dialog.set_preview_callback(_preview)
|
||||
|
||||
if dialog.exec():
|
||||
size, is_diameter, tangent, scope = dialog.get_values()
|
||||
radius = size / 2.0 if is_diameter else size
|
||||
self._apply_fillet(shape, seed_edges, radius, tangent, scope)
|
||||
|
||||
self._cancel_fillet_pick()
|
||||
|
||||
def _apply_fillet(
|
||||
self,
|
||||
shape: Any,
|
||||
seed_edges: List[Any],
|
||||
radius: float,
|
||||
tangent_propagation: bool,
|
||||
scope: str,
|
||||
) -> None:
|
||||
"""Fillet the body in place and record the operation as a feature.
|
||||
|
||||
The picked body's geometry is replaced by the filleted solid, its
|
||||
render object is refreshed, and a ``fillet`` feature is appended to
|
||||
its feature history so "Update Body" / save-load replay it.
|
||||
"""
|
||||
body = self._fillet_body
|
||||
if body is None or body.geometry is None:
|
||||
return
|
||||
try:
|
||||
edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope)
|
||||
result_geom = self._kernel.fillet(body.geometry, radius, edges=edges)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Fillet failed: {exc}")
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Fillet Failed",
|
||||
f"Could not fillet these edges: {exc}\n\n"
|
||||
"Try a smaller size or a different pair of faces.",
|
||||
)
|
||||
return
|
||||
|
||||
# Record the operation in the feature history so it replays on
|
||||
# Update Body and survives save/load.
|
||||
features = _ensure_feature_history(body)
|
||||
if not features and body.geometry is not None:
|
||||
# Imported / baked body: freeze current geometry as the base.
|
||||
features.append(Feature(operation="base", geometry=body.geometry))
|
||||
features.append(
|
||||
Feature(
|
||||
operation="fillet",
|
||||
radius=radius,
|
||||
tangent_propagation=tangent_propagation,
|
||||
scope=scope,
|
||||
edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
|
||||
)
|
||||
)
|
||||
|
||||
body.geometry = result_geom
|
||||
body.needs_update = False
|
||||
body.modified_at = datetime.now()
|
||||
self._mark_dirty()
|
||||
|
||||
# Re-render the body in place (replace its AIS object).
|
||||
if body.render_object is not None:
|
||||
self._viewer_3d.remove_mesh(body.render_object)
|
||||
new_shape = self._kernel._get_shape(body.geometry)
|
||||
body.render_object = self._viewer_3d.show_shape(new_shape, body.color, body.name)
|
||||
|
||||
self._refresh_lists()
|
||||
self._update_component_thumbnail(self._get_active_component_index())
|
||||
self.statusBar().showMessage(
|
||||
f"Fillet applied — radius {radius:g} mm on {len(edges or [])} edge(s)", 5000
|
||||
)
|
||||
logger.info(f"Fillet applied to '{body.name}' (radius {radius})")
|
||||
|
||||
def _delete_body(self):
|
||||
selected = self._body_list.currentItem()
|
||||
if not selected or not self._current_component:
|
||||
@@ -4654,6 +5228,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
if to_delete:
|
||||
del self._current_component.bodies[to_delete]
|
||||
if self._selected_body is not None and self._selected_body.name == name:
|
||||
self._selected_body = None
|
||||
self._mark_dirty()
|
||||
self._refresh_lists()
|
||||
logger.info(f"Deleted body: {name}")
|
||||
|
||||
+118
-23
@@ -20,6 +20,13 @@ class Viewer3DWidget(QWidget):
|
||||
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||
pickFaceCancelled = Signal()
|
||||
|
||||
# Emitted when the user picks a face for the fillet tool (ANY face,
|
||||
# planar or curved). Payload: the raw TopoDS_Face. The owning body is
|
||||
# read from ``_last_pick_owner_obj_id`` (same stash as facePicked).
|
||||
filletFacePicked = Signal(object)
|
||||
# Emitted when fillet pick mode is cancelled (Esc).
|
||||
filletPickCancelled = Signal()
|
||||
|
||||
# Emitted when the user picks an entity for a connector point (assembly).
|
||||
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
||||
@@ -44,11 +51,11 @@ class Viewer3DWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
||||
self.setAutoFillBackground(False)
|
||||
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||
# button held — required for the connector-pick hover gizmo (and any
|
||||
# status-bar hover feedback) to show under the cursor as the user
|
||||
@@ -64,6 +71,8 @@ class Viewer3DWidget(QWidget):
|
||||
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||
self._pick_face_mode: bool = False
|
||||
# When True, a left-click picks ANY face for the fillet tool.
|
||||
self._fillet_pick_mode: bool = False
|
||||
# When True, a left-click picks an entity for a connector point
|
||||
# (assembly component connection).
|
||||
self._connector_pick_mode: bool = False
|
||||
@@ -312,15 +321,19 @@ class Viewer3DWidget(QWidget):
|
||||
def mousePressEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||||
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||||
if self._pick_face_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._handle_face_pick(event)
|
||||
return
|
||||
# Fillet pick mode: a left-click selects any face (planar or curved).
|
||||
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._handle_fillet_face_pick(event)
|
||||
return
|
||||
# Connector pick mode: a left-click selects a face for a connection point.
|
||||
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
||||
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._handle_connector_pick(event)
|
||||
return
|
||||
# Assembly move mode: start dragging the clicked body.
|
||||
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
||||
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._handle_assembly_move_press(event)
|
||||
return
|
||||
self._renderer.handle_mouse_press(event)
|
||||
@@ -346,6 +359,12 @@ class Viewer3DWidget(QWidget):
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
# In fillet pick mode, keep dynamic highlighting too.
|
||||
if self._fillet_pick_mode:
|
||||
if hasattr(self._renderer, "handle_mouse_move"):
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
# Active drag in assembly move mode.
|
||||
if self._move_drag_active:
|
||||
self._handle_assembly_move_move(event)
|
||||
@@ -354,7 +373,7 @@ class Viewer3DWidget(QWidget):
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def paintEngine(self):
|
||||
def paintEngine(self) -> Any:
|
||||
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||||
return None
|
||||
|
||||
@@ -447,13 +466,75 @@ class Viewer3DWidget(QWidget):
|
||||
"""
|
||||
self._pick_face_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.CrossCursor)
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
else:
|
||||
self.unsetCursor()
|
||||
|
||||
def is_pick_face_mode(self) -> bool:
|
||||
return self._pick_face_mode
|
||||
|
||||
# ─── Fillet pick mode (any-face picking) ────────────────────────────────
|
||||
|
||||
def set_fillet_pick_mode(self, enabled: bool) -> None:
|
||||
"""Toggle fillet face-pick mode (any face — planar or curved).
|
||||
|
||||
The cursor selects faces for the fillet tool instead of orbiting the
|
||||
camera. Mutually exclusive with the other pick modes: entering this
|
||||
mode switches the others off.
|
||||
"""
|
||||
self._fillet_pick_mode = bool(enabled)
|
||||
if enabled:
|
||||
# Pick modes are mutually exclusive — entering fillet mode
|
||||
# disables sketch-on-surface / connector / assembly modes.
|
||||
self._pick_face_mode = False
|
||||
self._connector_pick_mode = False
|
||||
self._assembly_move_mode = False
|
||||
self._move_drag_active = False
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||
self.unsetCursor()
|
||||
|
||||
def is_fillet_pick_mode(self) -> bool:
|
||||
return self._fillet_pick_mode
|
||||
|
||||
def highlight_faces(self, faces: List[Any]) -> None:
|
||||
"""Tint all faces in *faces* so both fillet picks stay visible."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "highlight_faces", None)
|
||||
if fn is not None:
|
||||
fn(faces)
|
||||
self._renderer.render()
|
||||
return
|
||||
# Fallback: single-face highlight for the last picked face.
|
||||
if faces:
|
||||
self.highlight_face(faces[-1])
|
||||
|
||||
def clear_faces_highlight(self) -> None:
|
||||
"""Remove the multi-face fillet-pick overlays, if any."""
|
||||
if not self._initialized or self._renderer is None:
|
||||
return
|
||||
fn = getattr(self._renderer, "clear_faces_highlight", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
self._renderer.render()
|
||||
|
||||
def _handle_fillet_face_pick(self, event: Any) -> None:
|
||||
"""Detect any face under the click and emit filletFacePicked."""
|
||||
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("Fillet face pick: no face under cursor")
|
||||
return
|
||||
# Stash the owner so MainWindow can pair the face with its body
|
||||
# (same convention as sketch-on-face picking).
|
||||
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||||
self.filletFacePicked.emit(info["face"])
|
||||
|
||||
def highlight_face(self, face: Any) -> None:
|
||||
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||||
self._ensure_initialized()
|
||||
@@ -489,7 +570,7 @@ class Viewer3DWidget(QWidget):
|
||||
"""
|
||||
self._connector_pick_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.CrossCursor)
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
# Disable standard OCC selection so gizmo visuals are not
|
||||
# interfered with by dynamic face highlighting.
|
||||
fn = getattr(self._renderer, "deactivate_selection_modes", None)
|
||||
@@ -714,7 +795,7 @@ class Viewer3DWidget(QWidget):
|
||||
"""
|
||||
self._assembly_move_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.SizeAllCursor)
|
||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
||||
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||
self.unsetCursor()
|
||||
if not enabled:
|
||||
@@ -788,20 +869,29 @@ class Viewer3DWidget(QWidget):
|
||||
|
||||
# Compute world-space delta.
|
||||
modifiers = event.modifiers()
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
if modifiers & Qt.KeyboardModifier.ShiftModifier:
|
||||
# Shift+drag: move along camera direction (Z-depth).
|
||||
dz_world = dx * world_per_pixel
|
||||
dx_world = 0.0
|
||||
dy_world = 0.0
|
||||
else:
|
||||
# Normal drag: move in view plane.
|
||||
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
|
||||
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
|
||||
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
|
||||
try:
|
||||
dx_world = float(
|
||||
cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel
|
||||
)
|
||||
dy_world = float(
|
||||
cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel
|
||||
)
|
||||
dz_world = float(
|
||||
cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
dx_world = dy_world = dz_world = 0.0
|
||||
|
||||
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
||||
|
||||
def _handle_assembly_move_release(self, event) -> None:
|
||||
def _handle_assembly_move_release(self, event: Any) -> None:
|
||||
"""Finish the drag, emit final position."""
|
||||
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
||||
self._move_drag_active = False
|
||||
@@ -811,7 +901,7 @@ class Viewer3DWidget(QWidget):
|
||||
self._move_plane_normal = None
|
||||
self._move_initial_position = None
|
||||
|
||||
def _handle_face_pick(self, event) -> None:
|
||||
def _handle_face_pick(self, event: Any) -> None:
|
||||
"""Detect a planar face under the click and emit facePicked."""
|
||||
self._ensure_initialized()
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
@@ -834,7 +924,7 @@ class Viewer3DWidget(QWidget):
|
||||
info["face"],
|
||||
)
|
||||
|
||||
def set_view(self, view: str):
|
||||
def set_view(self, view: str) -> None:
|
||||
# Prefer the renderer's native orientation snap (preserves target,
|
||||
# refits the scene). Falls back to absolute eye positions for
|
||||
# renderers that don't implement set_view_orientation.
|
||||
@@ -856,26 +946,31 @@ class Viewer3DWidget(QWidget):
|
||||
pos, target = positions[view]
|
||||
self.set_camera_position(pos, target)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
def mouseDoubleClickEvent(self, event: Any) -> None:
|
||||
# Double-click → fit all (common CAD convention).
|
||||
self._ensure_initialized()
|
||||
if event.button() == Qt.LeftButton:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.fit_camera()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
def keyPressEvent(self, event: Any) -> None:
|
||||
# Esc cancels face-pick mode.
|
||||
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||||
if self._pick_face_mode and event.key() == Qt.Key.Key_Escape:
|
||||
self.set_pick_face_mode(False)
|
||||
self.pickFaceCancelled.emit()
|
||||
return
|
||||
# Esc cancels fillet pick mode.
|
||||
if self._fillet_pick_mode and event.key() == Qt.Key.Key_Escape:
|
||||
self.set_fillet_pick_mode(False)
|
||||
self.filletPickCancelled.emit()
|
||||
return
|
||||
# Esc cancels connector pick mode.
|
||||
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
||||
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
|
||||
self.set_connector_pick_mode(False)
|
||||
self.connectorPickCancelled.emit()
|
||||
return
|
||||
# Esc cancels assembly move mode.
|
||||
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
||||
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
|
||||
self.set_assembly_move_mode(False)
|
||||
return
|
||||
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||||
|
||||
Reference in New Issue
Block a user