- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-02 22:04:57 +02:00
parent baa2fd5d47
commit 48042659fc
10 changed files with 1481 additions and 442 deletions
+13 -5
View File
@@ -6,13 +6,13 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added &quot;measurement lines&quot;"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added &quot;measurement lines&quot;">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" 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/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.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/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/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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -451,7 +451,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1785094789248</updated> <updated>1785094789248</updated>
</task> </task>
<option name="localTasksCounter" value="42" /> <task id="LOCAL-00042" summary="- added &quot;measurement lines&quot;">
<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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
+94 -94
View File
@@ -786,100 +786,6 @@
</layout> </layout>
</widget> </widget>
</item> </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"> <item row="7" column="3" rowspan="2">
<widget class="QGroupBox" name="joint_tools"> <widget class="QGroupBox" name="joint_tools">
<property name="minimumSize"> <property name="minimumSize">
@@ -1337,6 +1243,100 @@
</property> </property>
</widget> </widget>
</item> </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> </layout>
</widget> </widget>
<widget class="QMenuBar" name="menubar"> <widget class="QMenuBar" name="menubar">
+48 -48
View File
@@ -438,49 +438,6 @@ class Ui_fluencyCAD(object):
self.gridLayout.addWidget(self.groupBox_12, 4, 3, 1, 1) 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 = QGroupBox(self.centralwidget)
self.joint_tools.setObjectName(u"joint_tools") self.joint_tools.setObjectName(u"joint_tools")
self.joint_tools.setMinimumSize(QSize(0, 50)) 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.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) fluencyCAD.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(fluencyCAD) self.menubar = QMenuBar(fluencyCAD)
self.menubar.setObjectName(u"menubar") 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_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", 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.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.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None)) self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
self.pb_remove_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_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None)) self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None))
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None)) self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
self.groupBox_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.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None)) self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi # retranslateUi
+2 -1
View File
@@ -455,10 +455,11 @@ class OCGeometryKernel(GeometryKernel):
else: else:
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE) explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More(): while explorer.More():
fillet.Add(radius, explorer.Current()) fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
explorer.Next() explorer.Next()
fillet.Build() fillet.Build()
+102 -49
View File
@@ -28,6 +28,7 @@ import logging
import os import os
import shutil import shutil
import tempfile import tempfile
import uuid
import zipfile import zipfile
from dataclasses import asdict, is_dataclass from dataclasses import asdict, is_dataclass
from datetime import datetime from datetime import datetime
@@ -66,7 +67,7 @@ def _json_default(obj: Any) -> Any:
return sorted(obj) return sorted(obj)
if isinstance(obj, tuple): if isinstance(obj, tuple):
return list(obj) return list(obj)
if is_dataclass(obj): if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj) return asdict(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
@@ -86,42 +87,77 @@ def _coerce_listlike(value: Any) -> List[Any]:
return list(value) 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]: def _to_3tuple(value: Any) -> Tuple[float, float, float]:
"""Coerce a saved 3-vector to a tuple of floats (for OCC).""" """Coerce a saved 3-vector to a tuple of floats (for OCC)."""
if value is None: if value is None:
return (0.0, 0.0, 0.0) return (0.0, 0.0, 0.0)
if isinstance(value, np.ndarray): try:
seq = value.tolist() if isinstance(value, np.ndarray):
else: seq = value.tolist()
seq = list(value) else:
if len(seq) < 3: seq = list(value)
seq = list(seq) + [0.0] * (3 - len(seq)) if len(seq) < 3:
return (float(seq[0]), float(seq[1]), float(seq[2])) 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: def _to_3vec(value: Any) -> np.ndarray:
"""Coerce a saved 3-vector to a 3-element numpy array.""" """Coerce a saved 3-vector to a 3-element numpy array."""
if isinstance(value, np.ndarray): try:
return value.astype(float).reshape(3) if isinstance(value, np.ndarray):
if value is None: return value.astype(float).reshape(3)
if value is None:
return np.zeros(3, dtype=float)
seq = list(value)
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) return np.zeros(3, dtype=float)
seq = list(value)
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)
def _to_mat3(value: Any) -> np.ndarray: def _to_mat3(value: Any) -> np.ndarray:
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray.""" """Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
if isinstance(value, np.ndarray): try:
arr = value.astype(float) if isinstance(value, np.ndarray):
return arr.reshape(3, 3) arr = value.astype(float)
if value is None: return arr.reshape(3, 3)
if value is None:
return np.eye(3, dtype=float)
flat = list(np.asarray(value, dtype=float).flatten())
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) return np.eye(3, dtype=float)
flat = list(np.asarray(value, dtype=float).flatten())
if len(flat) < 9:
flat = flat + [0.0] * (9 - len(flat))
return np.array(flat[:9], dtype=float).reshape(3, 3)
def _parse_iso(value: Optional[str]) -> datetime: 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: def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
wp = Workplane( wp = Workplane(
id=data.get("id") or None, # Workplane generates uuid if None id=_saved_id(data),
name=data.get("name", "Untitled Workplane"), name=data.get("name", "Untitled Workplane"),
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))), origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))),
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))), normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))),
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))), x_dir=_to_3tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
visible=bool(data.get("visible", True)), visible=bool(data.get("visible", True)),
) )
wp.created_at = _parse_iso(data.get("created_at")) 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), "through_all": bool(feat.through_all),
"cut_all_bodies": bool(feat.cut_all_bodies), "cut_all_bodies": bool(feat.cut_all_bodies),
"face_index": feat.face_index, "face_index": feat.face_index,
"angle": 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 """Deserialize a feature, resolving its sketch reference against the
component's already-loaded sketches.""" component's already-loaded sketches."""
feat = Feature( feat = Feature(
id=data.get("id") or None, id=_saved_id(data),
operation=data.get("operation", "extrude"), operation=data.get("operation", "extrude"),
length=data.get("length"), length=data.get("length"),
symmetric=bool(data.get("symmetric", False)), 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)), through_all=bool(data.get("through_all", False)),
cut_all_bodies=bool(data.get("cut_all_bodies", False)), cut_all_bodies=bool(data.get("cut_all_bodies", False)),
face_index=data.get("face_index"), face_index=data.get("face_index"),
angle=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") sid = data.get("sketch_id")
if sid and sid in sketches: if sid and sid in sketches:
@@ -229,7 +273,7 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"position": _coerce_listlike(body.position), "position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation), "rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8], "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), "visible": bool(body.visible),
"has_geometry": body.geometry is not None, "has_geometry": body.geometry is not None,
"geometry_ref": None, # filled in by save_project "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 geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
body = Body( body = Body(
id=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Body"), name=data.get("name", "Untitled Body"),
geometry=geometry, geometry=geometry,
source_sketch=source_sketch, source_sketch=source_sketch,
@@ -265,7 +309,7 @@ def _body_from_dict(
position=_to_3vec(data.get("position")), position=_to_3vec(data.get("position")),
rotation=_to_mat3(data.get("rotation")), rotation=_to_mat3(data.get("rotation")),
color=tuple(data.get("color", [0.2, 0.4, 0.8])), 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)), visible=bool(data.get("visible", True)),
) )
body.created_at = _parse_iso(data.get("created_at")) 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 # Re-apply the workplane (from_dict already does this internally, but be
# defensive in case the saved dict didn't carry the workplane fields). # defensive in case the saved dict didn't carry the workplane fields).
occ_sketch.set_workplane( occ_sketch.set_workplane(
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))), _to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))), _to_3tuple(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_x_dir", (1.0, 0.0, 0.0))),
) )
geometry: Optional[OCCGeometryObject] = None 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 geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
sk = Sketch( sk = Sketch(
id=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Sketch"), name=data.get("name", "Untitled Sketch"),
occ_sketch=occ_sketch, occ_sketch=occ_sketch,
geometry=geometry, geometry=geometry,
@@ -362,7 +406,7 @@ def _component_from_dict(
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None, sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Component: ) -> Component:
comp = Component( comp = Component(
id=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Component"), name=data.get("name", "Untitled Component"),
description=data.get("description", ""), description=data.get("description", ""),
active_sketch=data.get("active_sketch"), active_sketch=data.get("active_sketch"),
@@ -410,8 +454,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"position": list(conn.position), "position": list(conn.position),
"normal": list(conn.normal), "normal": list(conn.normal),
"x_dir": list(conn.x_dir), "x_dir": list(conn.x_dir),
"axis_rotation": float(conn.axis_rotation), "axis_rotation": _to_float(conn.axis_rotation, 0.0),
"offset": float(conn.offset), "offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id, "assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id, "source_obj_id": conn.source_obj_id,
"partner_ac_id": conn.partner_ac_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: def _connector_from_dict(data: Dict[str, Any]) -> Connector:
conn = Connector( conn = Connector(
id=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Connector"), name=data.get("name", "Untitled Connector"),
position=_to_3tuple(data.get("position")), position=_to_3tuple(data.get("position")),
normal=_to_3tuple(data.get("normal")), normal=_to_3tuple(data.get("normal")),
x_dir=_to_3tuple(data.get("x_dir")), x_dir=_to_3tuple(data.get("x_dir")),
axis_rotation=float(data.get("axis_rotation", 0.0)), axis_rotation=_to_float(data.get("axis_rotation"), 0.0),
offset=float(data.get("offset", 0.0)), offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""), assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_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: def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac = AssemblyComponent( ac = AssemblyComponent(
id=data.get("id") or None, id=_saved_id(data),
component_id=data.get("component_id", ""), component_id=data.get("component_id", ""),
name=data.get("name", "Untitled Instance"), name=data.get("name", "Untitled Instance"),
position=_to_3vec(data.get("position")), 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: def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
conn = AssemblyConnection( conn = AssemblyConnection(
id=data.get("id") or None, id=_saved_id(data),
first_ac_id=data.get("first_ac_id", ""), first_ac_id=data.get("first_ac_id", ""),
second_ac_id=data.get("second_ac_id", ""), second_ac_id=data.get("second_ac_id", ""),
first_connector_id=data.get("first_connector_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: def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm = Assembly( asm = Assembly(
id=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Assembly"), name=data.get("name", "Untitled Assembly"),
active_assembly_component=data.get("active_assembly_component"), active_assembly_component=data.get("active_assembly_component"),
) )
@@ -580,7 +624,9 @@ def _read_step_bytes(
with open(tmp_path, "wb") as f: with open(tmp_path, "wb") as f:
f.write(data) f.write(data)
geom = kernel.import_step(tmp_path) geom = kernel.import_step(tmp_path)
return geom from typing import cast
return cast(OCCGeometryObject, geom)
except Exception as exc: except Exception as exc:
logger.warning("Failed to read STEP: %s", exc) logger.warning("Failed to read STEP: %s", exc)
return None return None
@@ -749,7 +795,10 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
with zipfile.ZipFile(filepath, "r") as zipf: with zipfile.ZipFile(filepath, "r") as zipf:
manifest_raw = zipf.read("project.json") manifest_raw = zipf.read("project.json")
manifest = json.loads(manifest_raw.decode("utf-8")) 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 {} view_state: Dict[str, Any] = manifest.get("view_state") or {}
# If a sketch's occ_sketch is referenced as a separate file, read # 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: except KeyError:
logger.warning("Sketch meta missing in archive: %s", ref) logger.warning("Sketch meta missing in archive: %s", ref)
continue continue
meta = json.loads(meta_bytes.decode("utf-8")) 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") sk_data["occ_sketch"] = meta.get("occ_sketch")
# Workplane fields on the sketch-level file override the # Workplane fields on the sketch-level file override the
# embedded ones (source of truth lives in the sidecar). # embedded ones (source of truth lives in the sidecar).
+16 -3
View File
@@ -60,9 +60,9 @@ class Workplane:
x = x / x_norm x = x / x_norm
y = np.cross(n, x) y = np.cross(n, x)
y = y / np.linalg.norm(y) y = y / np.linalg.norm(y)
self.normal = tuple(float(v) for v in n) self.normal = (float(n[0]), float(n[1]), float(n[2]))
self.x_dir = tuple(float(v) for v in x) self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
self._y_dir = tuple(float(v) for v in y) self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
@property @property
def y_dir(self) -> Tuple[float, float, float]: def y_dir(self) -> Tuple[float, float, float]:
@@ -223,6 +223,9 @@ class Feature:
extruded sketch profile extruded sketch profile
- "union": boolean union of the running geometry with the - "union": boolean union of the running geometry with the
extruded sketch profile 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 - "base": frozen geometry snapshot (``geometry`` field) — used
to migrate legacy bodies whose original base feature to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation. is unknown. Never the result of a user operation.
@@ -243,6 +246,16 @@ class Feature:
# "base" features only: frozen pre-feature geometry snapshot. # "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None geometry: Optional[OCCGeometryObject] = None
# "fillet" features only: radius (mm) of the round, whether the fillet
# 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) created_at: datetime = field(default_factory=datetime.now)
+282 -135
View File
@@ -126,6 +126,8 @@ class OCCRenderer(Renderer):
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
# Persistent light-blue transparent overlay marking the selected face. # Persistent light-blue transparent overlay marking the selected face.
self._highlight_ais: Any = None 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. # Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None self._preview_ais: Any = None
# Smart entity picker gizmo objects (snap markers, axis lines, rings). # Smart entity picker gizmo objects (snap markers, axis lines, rings).
@@ -137,6 +139,7 @@ class OCCRenderer(Renderer):
self._parent_widget = parent_widget self._parent_widget = parent_widget
import os as _os import os as _os
if _os.environ.get("QT_QPA_PLATFORM") == "offscreen": if _os.environ.get("QT_QPA_PLATFORM") == "offscreen":
logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)") logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)")
return False return False
@@ -159,16 +162,14 @@ class OCCRenderer(Renderer):
) )
from OCP.AIS import AIS_InteractiveContext from OCP.AIS import AIS_InteractiveContext
from OCP.Graphic3d import ( from OCP.Graphic3d import (
Graphic3d_Camera,
Graphic3d_TypeOfShadingModel, Graphic3d_TypeOfShadingModel,
Graphic3d_MaterialAspect,
Graphic3d_NameOfMaterial,
) )
from OCP.Quantity import ( from OCP.Quantity import (
Quantity_Color, Quantity_Color,
Quantity_TOC_RGB, Quantity_TOC_RGB,
Quantity_NameOfColor, Quantity_NameOfColor,
) )
logger.info("OCCRenderer imports complete") logger.info("OCCRenderer imports complete")
hwnd = int(parent_widget.winId()) hwnd = int(parent_widget.winId())
@@ -203,9 +204,7 @@ class OCCRenderer(Renderer):
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB), Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
True, True,
) )
ambient = V3d_AmbientLight( ambient = V3d_AmbientLight(Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB))
Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)
)
for light in (key, fill, rim, ambient): for light in (key, fill, rim, ambient):
viewer.SetLightOn(light) viewer.SetLightOn(light)
@@ -249,6 +248,7 @@ class OCCRenderer(Renderer):
# pick preview matches the persistent selection overlay below. # pick preview matches the persistent selection overlay below.
try: try:
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
# Modify the existing dynamic-highlight drawer in place (per # Modify the existing dynamic-highlight drawer in place (per
# OCC docs this is safer than building a fresh Prs3d_Drawer). # OCC docs this is safer than building a fresh Prs3d_Drawer).
hd = context.HighlightStyle() hd = context.HighlightStyle()
@@ -305,7 +305,6 @@ class OCCRenderer(Renderer):
""" """
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB 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]}" 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). # explicit pick methods (pick_entity / pick_planar_face).
try: try:
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo) mode = AIS_Shape.SelectionMode_s(topo)
self._context.Activate(ais, mode) self._context.Activate(ais, mode)
@@ -377,9 +377,7 @@ class OCCRenderer(Renderer):
""" """
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
mat = Graphic3d_MaterialAspect( mat = Graphic3d_MaterialAspect(Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC)
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
)
return mat return mat
# ─── Legacy mesh / wireframe (kept for backward compat) ──────────── # ─── Legacy mesh / wireframe (kept for backward compat) ────────────
@@ -582,10 +580,12 @@ class OCCRenderer(Renderer):
self._context.RemoveAll(True) self._context.RemoveAll(True)
except Exception: except Exception:
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
lst = AIS_ListOfInteractive() lst = AIS_ListOfInteractive()
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst) self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
for ais in lst: for ais in lst:
self._context.Remove(ais, True) self._context.Remove(ais, True)
self._faces_highlight_ais = []
self._objects.clear() self._objects.clear()
def update_mesh( def update_mesh(
@@ -702,10 +702,9 @@ class OCCRenderer(Renderer):
# Check projection type. # Check projection type.
from OCP.Graphic3d import Graphic3d_Camera from OCP.Graphic3d import Graphic3d_Camera
proj_type = cam.ProjectionType() proj_type = cam.ProjectionType()
is_orthographic = ( is_orthographic = proj_type == Graphic3d_Camera.Projection_Orthographic
proj_type == Graphic3d_Camera.Projection_Orthographic
)
if not is_orthographic: if not is_orthographic:
# Perspective mode: use the actual eye position directly. # Perspective mode: use the actual eye position directly.
@@ -726,6 +725,7 @@ class OCCRenderer(Renderer):
# Compute scene bounding box diagonal from displayed objects. # Compute scene bounding box diagonal from displayed objects.
from OCP.Bnd import Bnd_Box from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box() bbox = Bnd_Box()
try: try:
for robj in self._objects.values(): for robj in self._objects.values():
@@ -737,13 +737,7 @@ class OCCRenderer(Renderer):
pass pass
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
diag = float( diag = float(np.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2))
np.sqrt(
(xmax - xmin) ** 2
+ (ymax - ymin) ** 2
+ (zmax - zmin) ** 2
)
)
# Fallback: if bbox is empty (no objects or all shapes failed), # Fallback: if bbox is empty (no objects or all shapes failed),
# use the eye-to-at distance as a reasonable estimate. # 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 # Base distance: how far the camera must be for the bbox diagonal
# to fill the frame at the given vertical FOV. # to fill the frame at the given vertical FOV.
import math 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. # Scale factor maps inversely: larger scale (zoomed in) → closer camera.
# Dividing by view_scale ensures that when the user zooms in (scale increases) # Dividing by view_scale ensures that when the user zooms in (scale increases)
@@ -802,13 +795,13 @@ class OCCRenderer(Renderer):
from OCP.V3d import V3d_TypeOfOrientation from OCP.V3d import V3d_TypeOfOrientation
mapping = { mapping = {
"front": V3d_TypeOfOrientation.V3d_Yneg, "front": V3d_TypeOfOrientation.V3d_Yneg,
"back": V3d_TypeOfOrientation.V3d_Ypos, "back": V3d_TypeOfOrientation.V3d_Ypos,
"top": V3d_TypeOfOrientation.V3d_Zpos, "top": V3d_TypeOfOrientation.V3d_Zpos,
"bottom": V3d_TypeOfOrientation.V3d_Zneg, "bottom": V3d_TypeOfOrientation.V3d_Zneg,
"left": V3d_TypeOfOrientation.V3d_Xneg, "left": V3d_TypeOfOrientation.V3d_Xneg,
"right": V3d_TypeOfOrientation.V3d_Xpos, "right": V3d_TypeOfOrientation.V3d_Xpos,
"iso": V3d_TypeOfOrientation.V3d_XposYnegZpos, "iso": V3d_TypeOfOrientation.V3d_XposYnegZpos,
} }
orient = mapping.get(orientation.lower()) orient = mapping.get(orientation.lower())
if orient is None: if orient is None:
@@ -886,9 +879,7 @@ class OCCRenderer(Renderer):
def on_pick(self, callback: Any) -> None: def on_pick(self, callback: Any) -> None:
pass pass
def project_to_screen( def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[float, float]:
self, point: Tuple[float, float, float]
) -> Tuple[float, float]:
return (0.0, 0.0) return (0.0, 0.0)
def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None: 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: if self._view is None:
return return
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
qcol = Quantity_Color(*color, Quantity_TOC_RGB) qcol = Quantity_Color(*color, Quantity_TOC_RGB)
self._view.SetBackgroundColor(qcol) self._view.SetBackgroundColor(qcol)
@@ -931,7 +923,6 @@ class OCCRenderer(Renderer):
from OCP.gp import gp_Pln from OCP.gp import gp_Pln
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB 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]}" 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. # Use gp_Pnt for the corners to make a bounded face.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = BRepBuilderAPI_MakePolygon() mp = BRepBuilderAPI_MakePolygon()
for c in corners_3d: for c in corners_3d:
mp.Add(gp_Pnt(*c)) mp.Add(gp_Pnt(*c))
@@ -1039,9 +1031,7 @@ class OCCRenderer(Renderer):
def take_screenshot(self) -> bytes: def take_screenshot(self) -> bytes:
return b"" return b""
def unproject_from_screen( def unproject_from_screen(self, x: float, y: float) -> Tuple[float, float, float]:
self, x: float, y: float
) -> Tuple[float, float, float]:
return (0.0, 0.0, 0.0) return (0.0, 0.0, 0.0)
# ─── Face picking (for sketch-on-surface) ──────────────────────────── # ─── Face picking (for sketch-on-surface) ────────────────────────────
@@ -1061,12 +1051,8 @@ class OCCRenderer(Renderer):
from OCP.BRepAdaptor import BRepAdaptor_Surface from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane from OCP.GeomAbs import GeomAbs_Plane
from OCP.TopoDS import TopoDS_Face, TopoDS from OCP.TopoDS import TopoDS
from OCP.TopExp import TopExp_Explorer from OCP.gp import gp_Pln
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
# Detect what's under the cursor. # Detect what's under the cursor.
self._context.MoveTo(x, y, self._view, True) 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 # default (non-inverted) extrude would punch back into the body
# instead of building outward on top of it. # instead of building outward on top of it.
from OCP.TopAbs import TopAbs_REVERSED from OCP.TopAbs import TopAbs_REVERSED
n = pln.Axis().Direction() n = pln.Axis().Direction()
if face.Orientation() == TopAbs_REVERSED: if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed() n = n.Reversed()
@@ -1115,6 +1102,7 @@ class OCCRenderer(Renderer):
# plane, so the UV frame is centred on the face (nicer for sketching). # plane, so the UV frame is centred on the face (nicer for sketching).
from OCP.Bnd import Bnd_Box from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box() bbox = Bnd_Box()
BRepBndLib.Add_s(face, bbox) BRepBndLib.Add_s(face, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
@@ -1157,6 +1145,56 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id, "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: def highlight_face(self, face: Any) -> None:
"""Overlay a persistent, mostly-transparent light-blue tint on *face*. """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) logger.debug("clear_face_highlight remove failed", exc_info=True)
self._highlight_ais = None 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) ─────────── # ─── General entity picking (for assembly connectors / snaps) ───────────
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]: def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
@@ -1246,12 +1336,13 @@ class OCCRenderer(Renderer):
except Exception: except Exception:
pass pass
if eye is not None: if eye is not None:
results.sort(key=lambda c: float(np.linalg.norm( results.sort(key=lambda c: float(np.linalg.norm(np.array(c["position"]) - eye)))
np.array(c["position"]) - eye)))
return results[0] return results[0]
def _classify_detected_shape( 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]]: ) -> List[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into snap-candidate dicts. """Classify a detected OCC sub-shape into snap-candidate dicts.
@@ -1267,17 +1358,14 @@ class OCCRenderer(Renderer):
if shape is None: if shape is None:
return [] return []
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS from OCP.TopoDS import TopoDS
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle
from OCP.BRep import BRep_Tool from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE 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.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
from OCP.TopExp import TopExp
import numpy as np import numpy as np
# Helper: find owner object id if not supplied. # Helper: find owner object id if not supplied.
@@ -1309,6 +1397,7 @@ class OCCRenderer(Renderer):
pln = adaptor.Plane() pln = adaptor.Plane()
n = pln.Axis().Direction() n = pln.Axis().Direction()
from OCP.TopAbs import TopAbs_REVERSED from OCP.TopAbs import TopAbs_REVERSED
if face.Orientation() == TopAbs_REVERSED: if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed() n = n.Reversed()
nx, ny, nz = n.X(), n.Y(), n.Z() nx, ny, nz = n.X(), n.Y(), n.Z()
@@ -1319,20 +1408,26 @@ class OCCRenderer(Renderer):
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0 cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
pln_origin = pln.Location() 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) origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir: viewport-aligned so connector gizmo matches screen. # x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view) x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [{ return [
"type": "planar_face", {
"position": origin, "type": "planar_face",
"normal": (nx, ny, nz), "position": origin,
"x_dir": x_dir, "normal": (nx, ny, nz),
"face": face, "x_dir": x_dir,
"owner_obj_id": owner_obj_id, "face": face,
}] "owner_obj_id": owner_obj_id,
}
]
elif stype == GeomAbs_Cylinder: elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder() cyl = adaptor.Cylinder()
@@ -1356,9 +1451,11 @@ class OCCRenderer(Renderer):
if curve_adaptor.GetType() == GeomAbs_Circle: if curve_adaptor.GetType() == GeomAbs_Circle:
circ = curve_adaptor.Circle() circ = curve_adaptor.Circle()
center_pnt = circ.Location() center_pnt = circ.Location()
circle_centers.append(np.array([ circle_centers.append(
center_pnt.X(), center_pnt.Y(), center_pnt.Z() np.array(
], dtype=float)) [center_pnt.X(), center_pnt.Y(), center_pnt.Z()], dtype=float
)
)
except Exception: except Exception:
pass pass
edge_explorer.Next() edge_explorer.Next()
@@ -1368,9 +1465,7 @@ class OCCRenderer(Renderer):
if len(circle_centers) >= 2: if len(circle_centers) >= 2:
# Project each center onto the axis direction to get a # Project each center onto the axis direction to get a
# scalar "height" value. Cluster into two groups. # scalar "height" value. Cluster into two groups.
ax_dir_np = np.array([ ax_dir_np = np.array([ax_dir.X(), ax_dir.Y(), ax_dir.Z()], dtype=float)
ax_dir.X(), ax_dir.Y(), ax_dir.Z()
], dtype=float)
heights = [np.dot(c, ax_dir_np) for c in circle_centers] heights = [np.dot(c, ax_dir_np) for c in circle_centers]
# Sort by height (scalar) and split roughly in half. # Sort by height (scalar) and split roughly in half.
indexed = list(enumerate(heights)) indexed = list(enumerate(heights))
@@ -1391,16 +1486,22 @@ class OCCRenderer(Renderer):
# No circular edges found — fall back to parameter-based. # No circular edges found — fall back to parameter-based.
vmin = adaptor.FirstVParameter() vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter() 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.X() + ax_dir.X() * vmin,
ax_pos.Z() + ax_dir.Z() * vmin, ax_pos.Y() + ax_dir.Y() * vmin,
], dtype=float) ax_pos.Z() + ax_dir.Z() * vmin,
c1 = np.array([ ],
ax_pos.X() + ax_dir.X() * vmax, dtype=float,
ax_pos.Y() + ax_dir.Y() * vmax, )
ax_pos.Z() + ax_dir.Z() * vmax, c1 = np.array(
], dtype=float) [
ax_pos.X() + ax_dir.X() * vmax,
ax_pos.Y() + ax_dir.Y() * vmax,
ax_pos.Z() + ax_dir.Z() * vmax,
],
dtype=float,
)
# Normal = the cylinder axis direction. This is the "bolt # Normal = the cylinder axis direction. This is the "bolt
# axis": the direction a bolt would travel INTO the hole. # axis": the direction a bolt would travel INTO the hole.
@@ -1423,15 +1524,17 @@ class OCCRenderer(Renderer):
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for end_center in [c0, c1]: for end_center in [c0, c1]:
origin = (float(end_center[0]), float(end_center[1]), float(end_center[2])) origin = (float(end_center[0]), float(end_center[1]), float(end_center[2]))
results.append({ results.append(
"type": "cylindrical_face", {
"position": origin, "type": "cylindrical_face",
"normal": normal, "position": origin,
"x_dir": x_dir, "normal": normal,
"face": face, "x_dir": x_dir,
"owner_obj_id": owner_obj_id, "face": face,
"radius": radius, "owner_obj_id": owner_obj_id,
}) "radius": radius,
}
)
return results return results
# Try edge. # Try edge.
@@ -1475,14 +1578,16 @@ class OCCRenderer(Renderer):
x = x / xlen x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2])) x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [{ return [
"type": "edge", {
"position": position, "type": "edge",
"normal": tangent, "position": position,
"x_dir": x_dir, "normal": tangent,
"edge": edge, "x_dir": x_dir,
"owner_obj_id": owner_obj_id, "edge": edge,
}] "owner_obj_id": owner_obj_id,
}
]
# Try vertex. # Try vertex.
vertex = None vertex = None
@@ -1490,21 +1595,26 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape) vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex) p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z()) position = (p.X(), p.Y(), p.Z())
return [{ return [
"type": "vertex", {
"position": position, "type": "vertex",
"normal": None, "position": position,
"x_dir": None, "normal": None,
"vertex": vertex, "x_dir": None,
"owner_obj_id": owner_obj_id, "vertex": vertex,
}] "owner_obj_id": owner_obj_id,
}
]
except Exception: except Exception:
pass pass
return [] return []
def probe_snap_candidates( def probe_snap_candidates(
self, x: int, y: int, radius: int = 30, self,
x: int,
y: int,
radius: int = 30,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Probe a pixel grid around (x, y) and return visible snap candidates. """Probe a pixel grid around (x, y) and return visible snap candidates.
@@ -1534,13 +1644,28 @@ class OCCRenderer(Renderer):
ring_offsets = [ ring_offsets = [
(0, 0), (0, 0),
# Full radius ring (cardinal + diagonal) # Full radius ring (cardinal + diagonal)
(-radius, 0), (radius, 0), (0, -radius), (0, radius), (-radius, 0),
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius), (radius, 0),
(0, -radius),
(0, radius),
(-radius, -radius),
(radius, radius),
(-radius, radius),
(radius, -radius),
# Half-radius ring # Half-radius ring
(-h, 0), (h, 0), (0, -h), (0, h), (-h, 0),
(-h, -h), (h, h), (-h, h), (h, -h), (h, 0),
(0, -h),
(0, h),
(-h, -h),
(h, h),
(-h, h),
(h, -h),
# Quarter-radius ring for small features # 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]] = {} 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. # Sort by screen-space distance to the cursor, nearest first.
results = list(candidates.values()) 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 return results
def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]: 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.gp import gp_Pnt
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
try: try:
scaled_size = size * self._get_gizmo_scale(position) scaled_size = size * self._get_gizmo_scale(position)
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape() sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape()
@@ -1709,10 +1839,10 @@ class OCCRenderer(Renderer):
# Per-entity-type colours (shared by the dim candidate markers). # Per-entity-type colours (shared by the dim candidate markers).
default_colors = { default_colors = {
"planar_face": (0.0, 0.8, 1.0), # cyan "planar_face": (0.0, 0.8, 1.0), # cyan
"cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis) "cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis)
"edge": (0.2, 1.0, 0.4), # green "edge": (0.2, 1.0, 0.4), # green
"vertex": (1.0, 1.0, 0.0), # yellow "vertex": (1.0, 1.0, 0.0), # yellow
} }
gizmo_color = color or default_colors.get(entity_type, (1.0, 0.6, 0.0)) gizmo_color = color or default_colors.get(entity_type, (1.0, 0.6, 0.0))
@@ -1742,7 +1872,9 @@ class OCCRenderer(Renderer):
continue continue
# Skip the primary itself — it gets its own bright marker. # Skip the primary itself — it gets its own bright marker.
if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == ( 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 continue
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7)) 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 ey = origin[1] + uy * length
ez = origin[2] + uz * length ez = origin[2] + uz * length
edge = BRepBuilderAPI_MakeEdge( edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
gp_Pnt(*origin), gp_Pnt(ex, ey, ez)
).Edge()
ais = AIS_Shape(edge) ais = AIS_Shape(edge)
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB)) ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
ais.SetDisplayMode(0) # wireframe ais.SetDisplayMode(0) # wireframe
@@ -1800,12 +1930,17 @@ class OCCRenderer(Renderer):
# visual balance. This reads as 'bolt axis through hole'. # 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, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
_make_axis_line( _make_axis_line(
position, (-normal[0], -normal[1], -normal[2]), position,
axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub", (-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). # Radial reference (same colour as the marker).
if x_dir is not None: 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: elif entity_type == "edge" and normal is not None:
# Tangent direction at midpoint. # Tangent direction at midpoint.
@@ -1858,6 +1993,7 @@ class OCCRenderer(Renderer):
return return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
for robj in self._objects.values(): for robj in self._objects.values():
if robj.ais_shape is not None: if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
@@ -1878,6 +2014,7 @@ class OCCRenderer(Renderer):
return return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
for robj in self._objects.values(): for robj in self._objects.values():
if robj.ais_shape is not None: if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
@@ -1915,7 +2052,10 @@ class OCCRenderer(Renderer):
return None return None
def probe_snap_candidates_geometric( 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]]: ) -> List[Dict[str, Any]]:
"""Probe snap candidates by iterating geometry directly (no selection system). """Probe snap candidates by iterating geometry directly (no selection system).
@@ -1937,7 +2077,6 @@ class OCCRenderer(Renderer):
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS
from OCP.Bnd import Bnd_Box from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
import numpy as np
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
# Expand the search radius for the bbox pre-filter so features near # 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() bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
# Project the 8 AABB corners to screen. # Project the 8 AABB corners to screen.
corners = [ corners = [
(bx0, by0, bz0), (bx1, by0, bz0), (bx0, by0, bz0),
(bx0, by1, bz0), (bx1, by1, bz0), (bx1, by0, bz0),
(bx0, by0, bz1), (bx1, by0, bz1), (bx0, by1, bz0),
(bx0, by1, bz1), (bx1, by1, bz1), (bx1, by1, bz0),
(bx0, by0, bz1),
(bx1, by0, bz1),
(bx0, by1, bz1),
(bx1, by1, bz1),
] ]
sx_min, sy_min = 99999, 99999 sx_min, sy_min = 99999, 99999
sx_max, sy_max = -99999, -99999 sx_max, sy_max = -99999, -99999
@@ -1984,8 +2127,12 @@ class OCCRenderer(Renderer):
if all_behind: if all_behind:
continue continue
# Check if cursor is within margin of the screen bbox. # Check if cursor is within margin of the screen bbox.
if (x < sx_min - margin or x > sx_max + margin or if (
y < sy_min - margin or y > sy_max + margin): x < sx_min - margin
or x > sx_max + margin
or y < sy_min - margin
or y > sy_max + margin
):
continue continue
except Exception: except Exception:
pass # If bbox fails, fall through and try features. 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. # Sort by screen-space distance to cursor, nearest first.
results = list(candidates.values()) results = list(candidates.values())
results.sort( results.sort(
key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 key=lambda c: (
+ (c.get("screen", (x, y))[1] - y) ** 2 (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
) )
return results return results
@@ -2081,7 +2229,6 @@ class OCCRenderer(Renderer):
* ``suggestion`` — human-readable snap suggestion * ``suggestion`` — human-readable snap suggestion
* ``feature_data`` — dict with feature-specific info (radius, axis, etc.) * ``feature_data`` — dict with feature-specific info (radius, axis, etc.)
""" """
import numpy as np
from collections import defaultdict from collections import defaultdict
# Group candidates by owner_obj_id. # Group candidates by owner_obj_id.
@@ -2126,10 +2273,9 @@ class OCCRenderer(Renderer):
if etype == "edge": if etype == "edge":
# Look for other edges nearby that might form a loop. # Look for other edges nearby that might form a loop.
nearby_edges = [ nearby_edges = [
n for n in candidates n
if n.get("type") == "edge" for n in candidates
and n.get("owner_obj_id") == owner if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n is not c
and n is not c
] ]
# For now, mark as edge — loop detection is complex. # For now, mark as edge — loop detection is complex.
ec["feature_type"] = "edge" ec["feature_type"] = "edge"
@@ -2145,9 +2291,9 @@ class OCCRenderer(Renderer):
if etype == "vertex": if etype == "vertex":
# Look for edges that share this vertex (nearby edges). # Look for edges that share this vertex (nearby edges).
nearby_edges = [ nearby_edges = [
n for n in candidates n
if n.get("type") == "edge" for n in candidates
and n.get("owner_obj_id") == owner if n.get("type") == "edge" and n.get("owner_obj_id") == owner
] ]
if len(nearby_edges) >= 2: if len(nearby_edges) >= 2:
ec["feature_type"] = "meeting_edges" ec["feature_type"] = "meeting_edges"
@@ -2180,6 +2326,7 @@ class OCCRenderer(Renderer):
def _qt_buttons(self, event) -> Any: def _qt_buttons(self, event) -> Any:
"""Return the PySide6 Qt enum module lazily.""" """Return the PySide6 Qt enum module lazily."""
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
return Qt return Qt
def handle_mouse_press(self, event) -> None: def handle_mouse_press(self, event) -> None:
+230 -84
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import logging import logging
import math import math
from typing import Tuple from typing import Any, Callable, Dict, Optional, Tuple
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QComboBox,
QDialog, QDialog,
QDoubleSpinBox, QDoubleSpinBox,
QFrame, QFrame,
@@ -19,11 +20,20 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QRadioButton, QRadioButton,
QVBoxLayout, QVBoxLayout,
QWidget,
) )
logger = logging.getLogger(__name__) 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): class ExtrudeDialog(QDialog):
"""Dialog for extrude options. """Dialog for extrude options.
@@ -33,7 +43,7 @@ class ExtrudeDialog(QDialog):
*None*) to the callback tells the host to clear the preview. *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) super().__init__(parent)
self.setWindowTitle("Extrude Options") self.setWindowTitle("Extrude Options")
self.setMinimumWidth(320) self.setMinimumWidth(320)
@@ -82,8 +92,8 @@ class ExtrudeDialog(QDialog):
layout.addWidget(self.rounded_checkbox) layout.addWidget(self.rounded_checkbox)
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.HLine) line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line) layout.addWidget(line)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -95,9 +105,9 @@ class ExtrudeDialog(QDialog):
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
# Live preview: recompute on every option change. Use a light- # Live preview: recompute on every option change. Wire each widget
# weight guard so we don't emit before the host has wired up the # to its own signal by type — spinboxes emit ``valueChanged``,
# callback. # checkboxes emit ``stateChanged``.
for w in ( for w in (
self.length_input, self.length_input,
self.symmetric_checkbox, self.symmetric_checkbox,
@@ -108,27 +118,18 @@ class ExtrudeDialog(QDialog):
self.cut_all_bodies_checkbox, self.cut_all_bodies_checkbox,
self.rounded_checkbox, self.rounded_checkbox,
): ):
# The spinbox has valueChanged; the checkboxes have stateChanged. if isinstance(w, QDoubleSpinBox):
# 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:
w.valueChanged.connect(self._emit_preview) w.valueChanged.connect(self._emit_preview)
except AttributeError: else:
pass
try:
w.stateChanged.connect(self._emit_preview) 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).""" """Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback self._preview_callback = callback
# Emit once so the initial state shows a preview right away. # Emit once so the initial state shows a preview right away.
self._emit_preview() self._emit_preview()
def _emit_preview(self, *args) -> None: def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None: if self._preview_callback is None:
return return
try: try:
@@ -136,7 +137,7 @@ class ExtrudeDialog(QDialog):
except Exception as exc: # preview must never break the dialog except Exception as exc: # preview must never break the dialog
logger.debug("extrude preview callback raised: %s", exc) 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 # Tell the host to clear the preview when the dialog goes away
# (accept, reject, or close). The host is responsible for the # (accept, reject, or close). The host is responsible for the
# actual viewer cleanup. # actual viewer cleanup.
@@ -163,7 +164,7 @@ class ExtrudeDialog(QDialog):
class RevolveDialog(QDialog): class RevolveDialog(QDialog):
"""Dialog for revolve options.""" """Dialog for revolve options."""
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Revolve Options") self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300) self.setMinimumWidth(300)
@@ -181,8 +182,8 @@ class RevolveDialog(QDialog):
layout.addLayout(angle_layout) layout.addLayout(angle_layout)
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.HLine) line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line) layout.addWidget(line)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -203,7 +204,7 @@ class OffsetDialog(QDialog):
time. On accept the caller retrieves ``get_values()`` distance. time. On accept the caller retrieves ``get_values()`` distance.
""" """
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Offset Sketch") self.setWindowTitle("Offset Sketch")
self.setMinimumWidth(300) self.setMinimumWidth(300)
@@ -227,8 +228,8 @@ class OffsetDialog(QDialog):
layout.addWidget(self.inward_checkbox) layout.addWidget(self.inward_checkbox)
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.HLine) line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line) layout.addWidget(line)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -244,12 +245,12 @@ class OffsetDialog(QDialog):
self.distance_input.valueChanged.connect(self._emit_preview) self.distance_input.valueChanged.connect(self._emit_preview)
self.inward_checkbox.stateChanged.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).""" """Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback self._preview_callback = callback
self._emit_preview() self._emit_preview()
def _emit_preview(self, *args) -> None: def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None: if self._preview_callback is None:
return return
try: try:
@@ -257,7 +258,7 @@ class OffsetDialog(QDialog):
except Exception as exc: except Exception as exc:
logger.debug("offset preview callback raised: %s", 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: if self._preview_callback is not None:
try: try:
self._preview_callback(None) self._preview_callback(None)
@@ -277,7 +278,7 @@ class WorkplaneOrientationDialog(QDialog):
returns (normal, x_dir) pair (both as 3-tuples). returns (normal, x_dir) pair (both as 3-tuples).
""" """
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("New Workplane Orientation") self.setWindowTitle("New Workplane Orientation")
self.setMinimumWidth(320) self.setMinimumWidth(320)
@@ -297,6 +298,12 @@ class WorkplaneOrientationDialog(QDialog):
layout.addWidget(lbl) layout.addWidget(lbl)
self._preset_group = QButtonGroup(self) 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() preset_layout = QGridLayout()
presets = [ presets = [
("XY (Top)", (0, 0, 1), (1, 0, 0)), ("XY (Top)", (0, 0, 1), (1, 0, 0)),
@@ -310,15 +317,14 @@ class WorkplaneOrientationDialog(QDialog):
btn = QRadioButton(label) btn = QRadioButton(label)
btn.setChecked(idx == 0) btn.setChecked(idx == 0)
self._preset_group.addButton(btn, idx) self._preset_group.addButton(btn, idx)
btn.normal = normal self._preset_vectors[idx] = (_vec3(normal), _vec3(x_dir))
btn.x_dir = x_dir
preset_layout.addWidget(btn, idx // 2, idx % 2) preset_layout.addWidget(btn, idx // 2, idx % 2)
layout.addLayout(preset_layout) layout.addLayout(preset_layout)
# ── Custom angle (offset from XY) ── # ── Custom angle (offset from XY) ──
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.HLine) line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line) layout.addWidget(line)
self._custom_radio = QRadioButton("Custom (angle from XY):") self._custom_radio = QRadioButton("Custom (angle from XY):")
@@ -351,8 +357,8 @@ class WorkplaneOrientationDialog(QDialog):
# ── Buttons ── # ── Buttons ──
line2 = QFrame() line2 = QFrame()
line2.setFrameShape(QFrame.HLine) line2.setFrameShape(QFrame.Shape.HLine)
line2.setFrameShadow(QFrame.Sunken) line2.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line2) layout.addWidget(line2)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -371,7 +377,7 @@ class WorkplaneOrientationDialog(QDialog):
self._angle_x.valueChanged.connect(self._emit_preview) self._angle_x.valueChanged.connect(self._emit_preview)
self._angle_y.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. """Install a callback for live 3D preview of the workplane orientation.
*callback* is called with ``(normal, x_dir)`` whenever the user *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. # Emit once so the initial state shows a preview right away.
self._emit_preview() 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.""" """Call the preview callback with the current orientation, if installed."""
if self._preview_callback is None: if self._preview_callback is None:
return return
@@ -391,7 +397,7 @@ class WorkplaneOrientationDialog(QDialog):
except Exception as exc: except Exception as exc:
logger.debug("workplane preview callback raised: %s", 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.""" """Clear the live preview when the dialog closes."""
if self._preview_callback is not None: if self._preview_callback is not None:
try: try:
@@ -400,17 +406,23 @@ class WorkplaneOrientationDialog(QDialog):
pass pass
super().hideEvent(event) 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.""" """When a preset is selected, deselect the custom radio and emit preview."""
self._custom_radio.setChecked(False) self._custom_radio.setChecked(False)
self._emit_preview() self._emit_preview()
def _on_ok(self): def _compute_custom_orientation(
"""Compute the final orientation and accept.""" 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 import numpy as np
if self._custom_radio.isChecked(): try:
# Custom: start from XY normal and rotate by the two angles.
ax = math.radians(self._angle_x.value()) ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value()) ay = math.radians(self._angle_y.value())
# Start from +Z normal, rotate around X then Y # Start from +Z normal, rotate around X then Y
@@ -433,10 +445,13 @@ class WorkplaneOrientationDialog(QDialog):
] ]
) )
n = ry @ n 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 # 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]) 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]) world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n) x = np.cross(world_y, n)
x_norm = np.linalg.norm(x) x_norm = np.linalg.norm(x)
@@ -444,14 +459,24 @@ class WorkplaneOrientationDialog(QDialog):
x = x / x_norm x = x / x_norm
else: else:
x = np.array([1.0, 0.0, 0.0]) x = np.array([1.0, 0.0, 0.0])
self._normal = tuple(float(v) for v in n) return (
self._x_dir = tuple(float(v) for v in x) (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: else:
btn = self._preset_group.checkedButton() btn = self._preset_group.checkedButton()
if btn is not None: if btn is not None:
self._normal = btn.normal self._normal, self._x_dir = self._preset_vectors[self._preset_group.id(btn)]
self._x_dir = btn.x_dir
self.accept() self.accept()
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]: 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 Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``. whether called before or after ``_on_ok``.
""" """
import numpy as np
if self._custom_radio.isChecked(): if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value()) orientation = self._compute_custom_orientation()
ay = math.radians(self._angle_y.value()) if orientation is None:
n = np.array([0.0, 0.0, 1.0]) orientation = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0))
rx = np.array( normal, x_dir = orientation
[
[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])
return ( return (
tuple(float(v) for v in n), normal,
tuple(float(v) for v in x), x_dir,
self._name_input.text().strip() or "Workplane", self._name_input.text().strip() or "Workplane",
) )
else: else:
btn = self._preset_group.checkedButton() btn = self._preset_group.checkedButton()
if btn is not None: 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. # Fallback: XY default.
return ( return (
(0.0, 0.0, 1.0), (0.0, 0.0, 1.0),
(1.0, 0.0, 0.0), (1.0, 0.0, 0.0),
self._name_input.text().strip() or "Workplane", 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",
)
+576
View File
@@ -19,15 +19,18 @@ from PySide6.QtGui import (
MAX_RECENT_PROJECTS = 10 MAX_RECENT_PROJECTS = 10
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QDialog, QDialog,
QDoubleSpinBox, QDoubleSpinBox,
QFileDialog, QFileDialog,
QFrame, QFrame,
QGridLayout,
QHBoxLayout, QHBoxLayout,
QInputDialog, QInputDialog,
QLabel, QLabel,
QListWidget,
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QMenu, QMenu,
@@ -45,6 +48,7 @@ from fluency.models.data_model import Project, Component, Sketch, Body, Workplan
from fluency.ui.dialogs import ( from fluency.ui.dialogs import (
ExtrudeDialog, ExtrudeDialog,
FilletDialog,
OffsetDialog, OffsetDialog,
RevolveDialog, RevolveDialog,
WorkplaneOrientationDialog, WorkplaneOrientationDialog,
@@ -641,6 +645,204 @@ def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) ->
return face_geom 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( def _replay_body_features(
kernel: OCGeometryKernel, kernel: OCGeometryKernel,
body: Body, body: Body,
@@ -659,6 +861,32 @@ def _replay_body_features(
geom = feat.geometry geom = feat.geometry
continue 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 sketch = feat.sketch
if sketch is None or sketch.occ_sketch is None: if sketch is None or sketch.occ_sketch is None:
logger.warning( logger.warning(
@@ -741,6 +969,12 @@ class MainWindow(QMainWindow):
self._current_sketch: Optional[Sketch] = None self._current_sketch: Optional[Sketch] = None
self._selected_body: Optional[Body] = 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_buttons: List[QPushButton] = []
self._component_group: Optional[QButtonGroup] = None self._component_group: Optional[QButtonGroup] = None
@@ -971,6 +1205,36 @@ class MainWindow(QMainWindow):
# Panel-focus mode (equal | sketch | viewer). # Panel-focus mode (equal | sketch | viewer).
self._panel_focus: str = "equal" 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): def _setup_ui_aliases(self):
"""Create _btn_* aliases pointing to the UI-loaded widgets. """Create _btn_* aliases pointing to the UI-loaded widgets.
@@ -1023,6 +1287,8 @@ class MainWindow(QMainWindow):
self._btn_move = ui.pb_moveop self._btn_move = ui.pb_moveop
self._btn_revolve = ui.pb_revop self._btn_revolve = ui.pb_revop
self._btn_array = ui.pb_arrayop self._btn_array = ui.pb_arrayop
self._btn_fillet = ui.pb_fillet_op
self._btn_fillet.setCheckable(True)
# ── Export ── # ── Export ──
self._btn_export_stl = ui.pushButton_2 self._btn_export_stl = ui.pushButton_2
self._btn_export_step = ui.pb_export_step 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_cut.clicked.connect(self._boolean_cut)
self._btn_combine.clicked.connect(self._boolean_union) self._btn_combine.clicked.connect(self._boolean_union)
self._btn_revolve.clicked.connect(self._revolve_sketch) 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_add_sketch.clicked.connect(self._add_sketch_to_component)
self._btn_edit_sketch.clicked.connect(self._edit_sketch) self._btn_edit_sketch.clicked.connect(self._edit_sketch)
@@ -1484,6 +1753,109 @@ class MainWindow(QMainWindow):
item.setForeground(QColor("#f5a623")) # Orange warning item.setForeground(QColor("#f5a623")) # Orange warning
self._body_list.addItem(item) 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): def _update_and_redraw(self):
"""Full pipeline: rebuild bodies, redraw, propagate to assembly. """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(): for body_id, body in self._current_component.bodies.items():
if body.name == name: if body.name == name:
self._selected_body = body self._selected_body = body
self._refresh_operations_list()
logger.info(f"Selected: {name}") logger.info(f"Selected: {name}")
break break
@@ -4638,6 +5011,207 @@ class MainWindow(QMainWindow):
logger.exception(f"Boolean union failed: {e}") logger.exception(f"Boolean union failed: {e}")
QMessageBox.critical(self, "Error", 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): def _delete_body(self):
selected = self._body_list.currentItem() selected = self._body_list.currentItem()
if not selected or not self._current_component: if not selected or not self._current_component:
@@ -4654,6 +5228,8 @@ class MainWindow(QMainWindow):
if to_delete: if to_delete:
del self._current_component.bodies[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._mark_dirty()
self._refresh_lists() self._refresh_lists()
logger.info(f"Deleted body: {name}") logger.info(f"Deleted body: {name}")
+118 -23
View File
@@ -20,6 +20,13 @@ class Viewer3DWidget(QWidget):
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck. # Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal() 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). # Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id). # Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str) connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
@@ -44,11 +51,11 @@ class Viewer3DWidget(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it. # For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WA_PaintOnScreen) self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen)
self.setAttribute(Qt.WA_OpaquePaintEvent) self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
self.setAutoFillBackground(False) self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work. # 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 # Enable mouse tracking so ``mouseMoveEvent`` fires even without a
# button held — required for the connector-pick hover gizmo (and any # button held — required for the connector-pick hover gizmo (and any
# status-bar hover feedback) to show under the cursor as the user # 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) # When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode(). # instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False 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 # When True, a left-click picks an entity for a connector point
# (assembly component connection). # (assembly component connection).
self._connector_pick_mode: bool = False self._connector_pick_mode: bool = False
@@ -312,15 +321,19 @@ class Viewer3DWidget(QWidget):
def mousePressEvent(self, event): def mousePressEvent(self, event):
self._ensure_initialized() self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on. # 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) self._handle_face_pick(event)
return 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. # 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) self._handle_connector_pick(event)
return return
# Assembly move mode: start dragging the clicked body. # 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) self._handle_assembly_move_press(event)
return return
self._renderer.handle_mouse_press(event) self._renderer.handle_mouse_press(event)
@@ -346,6 +359,12 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event) self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
return 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. # Active drag in assembly move mode.
if self._move_drag_active: if self._move_drag_active:
self._handle_assembly_move_move(event) self._handle_assembly_move_move(event)
@@ -354,7 +373,7 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event) self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(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 to prevent Qt from painting over OCC's direct OpenGL."""
return None return None
@@ -447,13 +466,75 @@ class Viewer3DWidget(QWidget):
""" """
self._pick_face_mode = bool(enabled) self._pick_face_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CrossCursor) self.setCursor(Qt.CursorShape.CrossCursor)
else: else:
self.unsetCursor() self.unsetCursor()
def is_pick_face_mode(self) -> bool: def is_pick_face_mode(self) -> bool:
return self._pick_face_mode 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: def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer.""" """Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized() self._ensure_initialized()
@@ -489,7 +570,7 @@ class Viewer3DWidget(QWidget):
""" """
self._connector_pick_mode = bool(enabled) self._connector_pick_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CrossCursor) self.setCursor(Qt.CursorShape.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not # Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting. # interfered with by dynamic face highlighting.
fn = getattr(self._renderer, "deactivate_selection_modes", None) fn = getattr(self._renderer, "deactivate_selection_modes", None)
@@ -714,7 +795,7 @@ class Viewer3DWidget(QWidget):
""" """
self._assembly_move_mode = bool(enabled) self._assembly_move_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.SizeAllCursor) self.setCursor(Qt.CursorShape.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode: elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor() self.unsetCursor()
if not enabled: if not enabled:
@@ -788,20 +869,29 @@ class Viewer3DWidget(QWidget):
# Compute world-space delta. # Compute world-space delta.
modifiers = event.modifiers() modifiers = event.modifiers()
if modifiers & Qt.ShiftModifier: if modifiers & Qt.KeyboardModifier.ShiftModifier:
# Shift+drag: move along camera direction (Z-depth). # Shift+drag: move along camera direction (Z-depth).
dz_world = dx * world_per_pixel dz_world = dx * world_per_pixel
dx_world = 0.0 dx_world = 0.0
dy_world = 0.0 dy_world = 0.0
else: else:
# Normal drag: move in view plane. # Normal drag: move in view plane.
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel) try:
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel) dx_world = float(
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel) 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) 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.""" """Finish the drag, emit final position."""
self.assemblyMoveFinished.emit(self._move_owner_obj_id) self.assemblyMoveFinished.emit(self._move_owner_obj_id)
self._move_drag_active = False self._move_drag_active = False
@@ -811,7 +901,7 @@ class Viewer3DWidget(QWidget):
self._move_plane_normal = None self._move_plane_normal = None
self._move_initial_position = 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.""" """Detect a planar face under the click and emit facePicked."""
self._ensure_initialized() self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None) picker = getattr(self._renderer, "pick_planar_face", None)
@@ -834,7 +924,7 @@ class Viewer3DWidget(QWidget):
info["face"], info["face"],
) )
def set_view(self, view: str): def set_view(self, view: str) -> None:
# Prefer the renderer's native orientation snap (preserves target, # Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for # refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation. # renderers that don't implement set_view_orientation.
@@ -856,26 +946,31 @@ class Viewer3DWidget(QWidget):
pos, target = positions[view] pos, target = positions[view]
self.set_camera_position(pos, target) self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event): def mouseDoubleClickEvent(self, event: Any) -> None:
# Double-click → fit all (common CAD convention). # Double-click → fit all (common CAD convention).
self._ensure_initialized() self._ensure_initialized()
if event.button() == Qt.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self.fit_camera() self.fit_camera()
super().mouseDoubleClickEvent(event) super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event): def keyPressEvent(self, event: Any) -> None:
# Esc cancels face-pick mode. # 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.set_pick_face_mode(False)
self.pickFaceCancelled.emit() self.pickFaceCancelled.emit()
return 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. # 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.set_connector_pick_mode(False)
self.connectorPickCancelled.emit() self.connectorPickCancelled.emit()
return return
# Esc cancels assembly move mode. # 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) self.set_assembly_move_mode(False)
return return
# Navigation shortcuts (lowercase = view presets, F = fit, # Navigation shortcuts (lowercase = view presets, F = fit,