2 Commits

Author SHA1 Message Date
bklronin 48042659fc - arc improvements, fillets, operations, bodys 2026-08-02 22:04:57 +02:00
bklronin baa2fd5d47 - added "measurement lines" 2026-08-02 20:58:43 +02:00
12 changed files with 3318 additions and 847 deletions
+62 -49
View File
@@ -4,18 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added contrain context menu&#10;- improved line pickability."> <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$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/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/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" />
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.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" />
@@ -52,47 +49,47 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Python.2dtest.executor": "Run", &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
"Python.3d_windows.executor": "Run", &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
"Python.Unnamed.executor": "Run", &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
"Python.base.executor": "Run", &quot;Python.base.executor&quot;: &quot;Run&quot;,
"Python.data_model.executor": "Run", &quot;Python.data_model.executor&quot;: &quot;Run&quot;,
"Python.debug_dragging.executor": "Run", &quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
"Python.draw_widget2d.executor": "Run", &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
"Python.draw_widget_solve.executor": "Run", &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
"Python.fluency.executor": "Run", &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
"Python.fluencyb.executor": "Run", &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
"Python.gl_widget.executor": "Run", &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
"Python.gui_ui.executor": "Run", &quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
"Python.kernel.executor": "Run", &quot;Python.kernel.executor&quot;: &quot;Run&quot;,
"Python.main.executor": "Run", &quot;Python.main.executor&quot;: &quot;Run&quot;,
"Python.main_window.executor": "Run", &quot;Python.main_window.executor&quot;: &quot;Run&quot;,
"Python.meshtest.executor": "Run", &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
"Python.occ_renderer.executor": "Run", &quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
"Python.occ_to_mesh.executor": "Run", &quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
"Python.render_backend.executor": "Run", &quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
"Python.side_fluency.executor": "Run", &quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
"Python.simple_mesh.executor": "Run", &quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
"Python.sketch.executor": "Run", &quot;Python.sketch.executor&quot;: &quot;Run&quot;,
"Python.vtk_widget.executor": "Run", &quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
"Python.vulkan.executor": "Run", &quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"git-widget-placeholder": "feature/occ-migration", &quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency", &quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" &quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
} }
}]]></component> }</component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -446,7 +443,23 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783976498520</updated> <updated>1783976498520</updated>
</task> </task>
<option name="localTasksCounter" value="41" /> <task id="LOCAL-00041" summary="- added &quot;measurement lines&quot;">
<option name="closed" value="true" />
<created>1785094789247</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1785094789248</updated>
</task>
<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">
@@ -467,7 +480,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" /> <MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" /> <MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" /> <MESSAGE value="- changing compos including sketches and bodies" />
@@ -492,6 +504,7 @@
<MESSAGE value="- added renderer" /> <MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" /> <MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" /> <MESSAGE value="- Render improvements, camera plane, update" />
<option name="LAST_COMMIT_MESSAGE" value="- Render improvements, camera plane, update" /> <MESSAGE value="- added &quot;measurement lines&quot;" />
<option name="LAST_COMMIT_MESSAGE" value="- added &quot;measurement lines&quot;" />
</component> </component>
</project> </project>
+137 -112
View File
@@ -7,7 +7,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>2551</width> <width>2551</width>
<height>1248</height> <height>1265</height>
</rect> </rect>
</property> </property>
<property name="sizePolicy"> <property name="sizePolicy">
@@ -218,7 +218,7 @@
</sizepolicy> </sizepolicy>
</property> </property>
<property name="currentIndex"> <property name="currentIndex">
<number>1</number> <number>0</number>
</property> </property>
<widget class="QWidget" name="sketch_tab"> <widget class="QWidget" name="sketch_tab">
<attribute name="title"> <attribute name="title">
@@ -372,6 +372,10 @@
<property name="text"> <property name="text">
<string>Pt_Pt</string> <string>Pt_Pt</string>
</property> </property>
<property name="icon">
<iconset>
<normaloff>icons/pt_pt.png</normaloff>icons/pt_pt.png</iconset>
</property>
<property name="checkable"> <property name="checkable">
<bool>true</bool> <bool>true</bool>
</property> </property>
@@ -782,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">
@@ -1260,17 +1170,10 @@
<string>Modify</string> <string>Modify</string>
</property> </property>
<layout class="QGridLayout" name="gridLayout_3"> <layout class="QGridLayout" name="gridLayout_3">
<item row="2" column="1"> <item row="1" column="0">
<widget class="QPushButton" name="pb_revop"> <widget class="QPushButton" name="pb_combop">
<property name="text"> <property name="text">
<string>Rev</string> <string>Comb</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
<string>Extrd</string>
</property> </property>
</widget> </widget>
</item> </item>
@@ -1281,6 +1184,34 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<property name="text">
<string>Mve</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pb_revop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="pb_fillet_op">
<property name="text">
<string>Fillet</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
<string>Extrd</string>
</property>
</widget>
</item>
<item row="0" column="1"> <item row="0" column="1">
<widget class="QPushButton" name="pb_cutop"> <widget class="QPushButton" name="pb_cutop">
<property name="text"> <property name="text">
@@ -1288,17 +1219,17 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="0"> <item row="3" column="1">
<widget class="QPushButton" name="pb_combop"> <widget class="QPushButton" name="pb_face_op">
<property name="text"> <property name="text">
<string>Comb</string> <string>Phase</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="1"> <item row="4" column="0">
<widget class="QPushButton" name="pb_moveop"> <widget class="QPushButton" name="pb_thread">
<property name="text"> <property name="text">
<string>Mve</string> <string>Thread</string>
</property> </property>
</widget> </widget>
</item> </item>
@@ -1312,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">
+88 -67
View File
@@ -26,7 +26,7 @@ class Ui_fluencyCAD(object):
def setupUi(self, fluencyCAD): def setupUi(self, fluencyCAD):
if not fluencyCAD.objectName(): if not fluencyCAD.objectName():
fluencyCAD.setObjectName(u"fluencyCAD") fluencyCAD.setObjectName(u"fluencyCAD")
fluencyCAD.resize(2551, 1248) fluencyCAD.resize(2551, 1265)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
@@ -249,6 +249,9 @@ class Ui_fluencyCAD(object):
self.gridLayout_4.setObjectName(u"gridLayout_4") self.gridLayout_4.setObjectName(u"gridLayout_4")
self.pb_con_ptpt = QPushButton(self.groupBox_3) self.pb_con_ptpt = QPushButton(self.groupBox_3)
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt") self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
icon = QIcon()
icon.addFile(u"icons/pt_pt.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.pb_con_ptpt.setIcon(icon)
self.pb_con_ptpt.setCheckable(True) self.pb_con_ptpt.setCheckable(True)
self.pb_con_ptpt.setAutoExclusive(False) self.pb_con_ptpt.setAutoExclusive(False)
@@ -435,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))
@@ -668,35 +628,50 @@ class Ui_fluencyCAD(object):
self.groupBox.setMaximumSize(QSize(200, 16777215)) self.groupBox.setMaximumSize(QSize(200, 16777215))
self.gridLayout_3 = QGridLayout(self.groupBox) self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3") self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_revop = QPushButton(self.groupBox) self.pb_combop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop") self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1) self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_arrayop = QPushButton(self.groupBox) self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop") self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1) self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.pb_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
self.pb_fillet_op = QPushButton(self.groupBox)
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
self.gridLayout_3.addWidget(self.pb_fillet_op, 3, 0, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_cutop = QPushButton(self.groupBox) self.pb_cutop = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop") self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1) self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_combop = QPushButton(self.groupBox) self.pb_face_op = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop") self.pb_face_op.setObjectName(u"pb_face_op")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1) self.gridLayout_3.addWidget(self.pb_face_op, 3, 1, 1, 1)
self.pb_moveop = QPushButton(self.groupBox) self.pb_thread = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop") self.pb_thread.setObjectName(u"pb_thread")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1) self.gridLayout_3.addWidget(self.pb_thread, 4, 0, 1, 1)
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1) self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
@@ -708,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")
@@ -875,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))
@@ -909,12 +922,20 @@ class Ui_fluencyCAD(object):
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None)) self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None)) self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None)) self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None)) self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None)) self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None))
self.pb_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()
+744 -37
View File
@@ -24,6 +24,13 @@ from fluency.geometry_occ.kernel import OCCGeometryObject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# World-unit tolerance used when matching a saved line/circle/arc position to
# an existing point entity during load. Old files can carry derived geometry
# that is stale relative to the point entities (saved after a drag that was
# never re-solved); the fallback must be generous enough to reach the real
# point while staying far below typical feature sizes.
_LOAD_POINT_TOL = 0.5
class OCCSketchEntity(SketchEntity): class OCCSketchEntity(SketchEntity):
"""Sketch entity for OpenCASCADE-based sketch with solver integration.""" """Sketch entity for OpenCASCADE-based sketch with solver integration."""
@@ -85,6 +92,16 @@ class OCCSketch(SketchInterface):
# Track first point as dragged/fixed for solver stability # Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None self._first_point_id: Optional[int] = None
# Cached 2D normal for the workplane. SolveSpace's add_arc() needs
# a normal_2d handle, and creating one per arc pollutes the solver
# with redundant entities. We create it lazily on first arc and
# reset it whenever the workplane or solver is reset.
self._wp_normal_handle: Optional[Any] = None
# Set of arc ids whose diameter is locked by a ``diameter``
# constraint — for those we MUST NOT overwrite the stored radius
# from the geometry, because the user explicitly fixed it.
self._arc_diameter_fixed: set = set()
# ── Workplane ─────────────────────────────────────────────────── # ── Workplane ───────────────────────────────────────────────────
# The sketch lives in a 2D UV frame on this plane. UV coordinates # The sketch lives in a 2D UV frame on this plane. UV coordinates
# map to world via: P = origin + u*x_dir + v*y_dir # map to world via: P = origin + u*x_dir + v*y_dir
@@ -262,6 +279,47 @@ class OCCSketch(SketchInterface):
return entity return entity
def _make_arc_normal_3d(self) -> Any:
"""Build a SolveSpace 3D normal (quaternion) that matches this sketch's workplane orientation.
SolveSpace's ``add_arc`` requires a 3D normal (quaternion) for the
arc plane, NOT a 2D one — passing a 2D normal raises
``TypeError: ... is not a 3d normal``. The 3D normal is a
unit quaternion that rotates the canonical Z-axis onto the
workplane's stored normal.
The default XY workplane (normal = +Z) maps to the identity
quaternion ``(1, 0, 0, 0)``. For arbitrary workplanes we derive
the shortest-arc quaternion that takes +Z onto the workplane
normal; this is the standard axis-angle → quaternion conversion
via the cross product as rotation axis.
"""
import math as _math
nx, ny, nz = self._wp_normal
# Identity rotation when the workplane normal is already +Z.
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz - 1.0) < 1e-12:
return self._solver.add_normal_3d(1.0, 0.0, 0.0, 0.0)
# Antiparallel case (workplane normal = -Z) — 180° about X axis.
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz + 1.0) < 1e-12:
return self._solver.add_normal_3d(0.0, 1.0, 0.0, 0.0)
# Axis = +Z × n = (-ny, nx, 0); angle = arccos(nz).
axis_len = _math.sqrt(nx * nx + ny * ny)
ax = -ny / axis_len
ay = nx / axis_len
az = 0.0
angle = _math.acos(max(-1.0, min(1.0, nz)))
half = angle * 0.5
s = _math.sin(half)
qw = _math.cos(half)
qx = ax * s
qy = ay * s
qz = az * s
return self._solver.add_normal_3d(qw, qx, qy, qz)
def add_arc( def add_arc(
self, self,
center: SketchEntity, center: SketchEntity,
@@ -270,10 +328,29 @@ class OCCSketch(SketchInterface):
end_point: SketchEntity, end_point: SketchEntity,
sweep: Optional[float] = None, sweep: Optional[float] = None,
) -> OCCSketchEntity: ) -> OCCSketchEntity:
"""Add an arc (tracked only). """Add an arc (added to solver + tracked).
*sweep* is the signed angular span in radians (positive = CCW, negative = CW). The arc is registered with SolveSpace so its three reference points
When *None* the rendering will infer the shortest path between start and end. are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
so the radius is **derived** from the current geometry rather than
stored as a fixed scalar.
Consequences:
* If the user constrains the start or end (e.g. coincident to a
rectangle corner) and then resizes the rectangle, the centre
slides on the perpendicular bisector of start↔end to keep the
arc consistent — the arc shape follows the rectangle, which is
the behaviour users expect from a fillet.
* If the centre is dragged instead, the radius adjusts so the
endpoints stay on the new circle.
* If the user wants the **diameter pinned** to a specific value
(e.g. a quarter-circle of exactly 10 mm), they can call
:meth:`constrain_arc_diameter` afterwards.
*sweep* is the signed angular span in radians (positive = CCW,
negative = CW). When *None* the rendering will infer the shortest
path between start and end.
""" """
import math import math
@@ -285,6 +362,8 @@ class OCCSketch(SketchInterface):
if center_entity is None or start_entity is None or end_entity is None: if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch") raise ValueError("Arc points not found in sketch")
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry cx, cy = center_entity.geometry
sx, sy = start_entity.geometry sx, sy = start_entity.geometry
@@ -300,6 +379,25 @@ class OCCSketch(SketchInterface):
while sweep < -math.pi: while sweep < -math.pi:
sweep += 2 * math.pi sweep += 2 * math.pi
# ── Add the arc to the SolveSpace solver ───────────────────────
# We need a 3D normal (quaternion) for the work plane. Cache one
# per (sketch, workplane) so we don't accumulate unused normals
# across many arc creations, and so the cache is invalidated
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
entity = OCCSketchEntity( entity = OCCSketchEntity(
entity_id=entity_id, entity_id=entity_id,
entity_type="arc", entity_type="arc",
@@ -310,6 +408,7 @@ class OCCSketch(SketchInterface):
"end": (ex, ey), "end": (ex, ey),
"sweep": sweep, "sweep": sweep,
}, },
handle=arc_handle,
) )
self._entities[entity_id] = entity self._entities[entity_id] = entity
@@ -319,6 +418,15 @@ class OCCSketch(SketchInterface):
"end": end_point.id, "end": end_point.id,
"radius": radius, "radius": radius,
"sweep": sweep, "sweep": sweep,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
# uses this to re-derive the centre position so the arc
# scales with the rectangle while keeping the same shape.
# The user can override it later with
# :meth:`constrain_arc_diameter` if they want a fixed-size
# arc regardless of the host geometry.
"original_sweep": sweep,
} }
return entity return entity
@@ -881,16 +989,59 @@ class OCCSketch(SketchInterface):
# tracked only (no solver entity) # tracked only (no solver entity)
pass pass
elif ctype == "diameter": elif ctype == "diameter":
# Update circle radius in sketch data # Update circle radius in sketch data. Legacy files (pre-ghost-
# circle-fix) sometimes recorded the diameter against the CENTER
# point id instead of the circle entity id; resolve such entries
# to the real circle via the _circles center mapping. Never
# touch the geometry of a non-circle entity — doing so used to
# turn a point into circle-shaped geometry and crashed the UI's
# ``round()`` calls.
circle_id = ids[0] circle_id = ids[0]
if circle_id in self._circles:
center_id, _ = self._circles[circle_id]
radius = params[0] / 2.0 radius = params[0] / 2.0
self._circles[circle_id] = (center_id, radius) resolved: Optional[int] = circle_id
ent = self._entities.get(circle_id) if resolved not in self._circles:
if ent is not None and ent.geometry is not None: for cid, (center_id, _r) in self._circles.items():
cx, cy = ent.geometry[0] if isinstance(ent.geometry[0], tuple) else ent.geometry if center_id == circle_id:
resolved = cid
break
else:
resolved = None
if resolved is not None:
if resolved in self._circles:
center_id, _ = self._circles[resolved]
self._circles[resolved] = (center_id, radius)
ent = self._entities.get(resolved)
if ent is not None and ent.entity_type == "circle" and ent.geometry is not None:
cx, cy = (
ent.geometry[0]
if isinstance(ent.geometry[0], (tuple, list))
else ent.geometry
)
ent.geometry = ((cx, cy), radius) ent.geometry = ((cx, cy), radius)
elif ctype == "arc_diameter":
# Re-apply the solver-side diameter constraint and refresh the
# stored radius so the renderer matches. Marks the arc as
# diameter-pinned so subsequent solves don't overwrite the
# radius from the implicit geometry.
arc_id = ids[0]
ent = self._entities.get(arc_id)
if ent is None or ent.handle is None or arc_id not in self._arcs:
return False
try:
diameter_value = float(params[0])
except (TypeError, ValueError) as e:
logger.debug("arc_diameter log had non-numeric param: %s", e)
return False
try:
self._solver.diameter(ent.handle, diameter_value)
except Exception as e:
logger.debug("Re-applying arc_diameter failed: %s", e)
return False
radius = diameter_value / 2.0
self._arcs[arc_id]["radius"] = radius
if isinstance(ent.geometry, dict):
ent.geometry["radius"] = radius
self._arc_diameter_fixed.add(arc_id)
else: else:
return False return False
return True return True
@@ -901,8 +1052,9 @@ class OCCSketch(SketchInterface):
python_solvespace cannot remove individual entities/constraints, so python_solvespace cannot remove individual entities/constraints, so
after deleting an entity we rebuild the whole system: re-add every after deleting an entity we rebuild the whole system: re-add every
surviving point at its current position (first point re-fixed for surviving point at its current position (first point re-fixed for
stability), re-add every surviving line, then re-apply the pruned stability), re-add every surviving line, re-add every surviving
constraint log. Entity ids are preserved; only solver handles change. arc, then re-apply the pruned constraint log. Entity ids are
preserved; only solver handles change.
""" """
# Snapshot current point positions before resetting the solver. # Snapshot current point positions before resetting the solver.
saved_pos: Dict[int, Tuple[float, float]] = {} saved_pos: Dict[int, Tuple[float, float]] = {}
@@ -913,6 +1065,9 @@ class OCCSketch(SketchInterface):
self._solver = SolverSystem() self._solver = SolverSystem()
self._wp = self._solver.create_2d_base() self._wp = self._solver.create_2d_base()
self._first_point_id = None self._first_point_id = None
# New solver = new work plane = new normal entity. Drop the cache
# so ``add_arc`` recreates it on demand.
self._wp_normal_handle = None
# Re-add point entities in id order (preserves first-point-fixed). # Re-add point entities in id order (preserves first-point-fixed).
for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"): for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"):
@@ -945,6 +1100,43 @@ class OCCSketch(SketchInterface):
if line_ent is not None: if line_ent is not None:
line_ent.handle = new_handle line_ent.handle = new_handle
# Re-add arc entities in id order. Without this, every arc loses
# its solver-side constraint that ties start/end/centre together,
# and the renderer would happily draw the old radius over the new
# geometry — the exact "arc doesn't follow the rectangle" bug
# that motivated the add-arc-to-solver change.
if self._arcs:
if self._wp_normal_handle is None:
# Use the 3D-normal helper, not add_normal_2d — the latter
# produces a 2D entity that add_arc rejects with
# ``TypeError: ... is not a 3d normal``.
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
c_ent = self._entities.get(c_id) if c_id is not None else None
s_ent = self._entities.get(s_id) if s_id is not None else None
e_ent = self._entities.get(e_id) if e_id is not None else None
if (
c_ent is None
or s_ent is None
or e_ent is None
or c_ent.handle is None
or s_ent.handle is None
or e_ent.handle is None
):
continue
new_handle = self._solver.add_arc(
nm, c_ent.handle, s_ent.handle, e_ent.handle, self._wp
)
arc_ent = self._entities.get(aid)
if arc_ent is not None:
arc_ent.handle = new_handle
# Re-apply every surviving logged constraint. # Re-apply every surviving logged constraint.
for entry in self._constraint_log: for entry in self._constraint_log:
self._apply_constraint_log(entry) self._apply_constraint_log(entry)
@@ -1069,14 +1261,55 @@ class OCCSketch(SketchInterface):
if circle.id in self._circles: if circle.id in self._circles:
center_id, _ = self._circles[circle.id] center_id, _ = self._circles[circle.id]
self._circles[circle.id] = (center_id, radius) self._circles[circle.id] = (center_id, radius)
# Update the entity geometry # Update the entity geometry. Circle geometry is
# ``((cx, cy), old_radius)`` — keep the center, swap the radius.
ent = self._entities.get(circle.id) ent = self._entities.get(circle.id)
if ent is not None: if ent is not None and ent.geometry is not None:
if isinstance(ent.geometry[0], (tuple, list)):
(cx, cy), _old_radius = ent.geometry
else:
cx, cy = ent.geometry cx, cy = ent.geometry
ent.geometry = ((cx, cy), radius) ent.geometry = ((cx, cy), radius)
self._record_constraint("diameter", (circle.id,), (diameter,)) self._record_constraint("diameter", (circle.id,), (diameter,))
return True return True
def constrain_arc_diameter(self, arc: SketchEntity, diameter: float) -> bool:
"""Pin the diameter of an arc to a specific value.
Without this constraint an arc is implicit-radius: its diameter
is whatever the geometry needs it to be to keep
``distance(start, centre) = distance(end, centre)`` — perfect for
fillets that grow with the rectangle they're attached to. When
the user wants a quarter-circle of *exactly* N mm they pin it
with this method; afterwards the solver enforces the diameter
and our ``_arc_diameter_fixed`` set tells ``_sync_solved_positions``
to stop overwriting the stored radius.
"""
ent = self._entities.get(arc.id)
if ent is None or ent.handle is None or arc.id not in self._arcs:
return False
try:
d = float(diameter)
except (TypeError, ValueError) as e:
logger.error("Arc diameter must be numeric: %s", e)
return False
try:
self._solver.diameter(ent.handle, d)
except Exception as e:
logger.error("Arc diameter constraint failed: %s", e)
return False
radius = d / 2.0
self._arcs[arc.id]["radius"] = radius
if isinstance(ent.geometry, dict):
ent.geometry["radius"] = radius
self._arc_diameter_fixed.add(arc.id)
try:
self._record_constraint("arc_diameter", (arc.id,), (d,))
except Exception as e:
logger.error("Recording arc diameter constraint failed: %s", e)
return False
return True
def constrain_fixed(self, entity: SketchEntity) -> bool: def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place via dragged constraint.""" """Fix an entity in place via dragged constraint."""
ent = self._entities.get(entity.id) ent = self._entities.get(entity.id)
@@ -1086,6 +1319,22 @@ class OCCSketch(SketchInterface):
self._record_constraint("fixed", (entity.id,)) self._record_constraint("fixed", (entity.id,))
return True return True
def is_entity_dragged(self, entity_id: int) -> bool:
"""True if the entity already has a ``dragged`` (fixed) constraint.
Used by the UI to avoid stacking duplicate ``dragged`` constraints
on the same point every time the user moves it — SolveSpace can
take several dragged constraints on the same point, but each one
bloats the constraint log without changing the locked position.
The user can still move the point later: a fresh
``set_entity_position`` updates the params and the existing
``dragged`` keeps the point at the new location on the next solve.
"""
for entry in self._constraint_log:
if entry["type"] == "fixed" and entity_id in entry["ids"]:
return True
return False
def constrain_symmetric( def constrain_symmetric(
self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity
) -> bool: ) -> bool:
@@ -1144,22 +1393,89 @@ class OCCSketch(SketchInterface):
# ─── Solving ─────────────────────────────────────────────────────────── # ─── Solving ───────────────────────────────────────────────────────────
def solve(self) -> bool: def solve(self) -> bool:
"""Solve all constraints via SolveSpace solver.""" """Solve all constraints via SolveSpace solver.
Returns True on success, False if the solver returns a non-OKAY
result (INCONSISTENT, DIDNT_CONVERGE, TOO_MANY_UNKNOWNS). When
False, :attr:`last_solve_status` is set to a human-readable string
describing the failure so the UI can surface it to the user.
Callers that need to know *which* failure happened should use
:meth:`last_solve_result` (returns the raw :class:`ResultFlag`).
"""
try: try:
result = self._solver.solve() result = self._solver.solve()
self._last_solve_result = int(result)
if result == ResultFlag.OKAY: if result == ResultFlag.OKAY:
# Sync solved positions back to entity geometries # Sync solved positions back to entity geometries
self._sync_solved_positions() self._sync_solved_positions()
self._last_solve_status = "ok"
return True return True
else: # Map SolveSpace's result enum to a one-line user-facing
logger.warning(f"Solver returned: {result}") # hint. INCONSISTENT is the most common and the most useful
# to call out: a new constraint conflicts with existing
# ones, so the geometry can't satisfy all of them.
status_map = {
int(ResultFlag.INCONSISTENT): (
"inconsistent: the new constraint conflicts with existing constraints"
),
int(ResultFlag.DIDNT_CONVERGE): (
"didn't converge: try simplifying the constraints or removing one"
),
int(ResultFlag.TOO_MANY_UNKNOWNS): (
"too many unknowns: the sketch is under-constrained"
),
}
self._last_solve_status = status_map.get(
int(result), f"failed (result code {int(result)})"
)
logger.warning(f"Solver returned: {result}{self._last_solve_status}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Solver error: {e}") logger.error(f"Solver error: {e}")
self._last_solve_status = f"error: {e}"
self._last_solve_result = -1
return False return False
def last_solve_result(self) -> int:
"""Raw SolveSpace result code from the most recent :meth:`solve` call.
``0`` = OKAY, ``1`` = INCONSISTENT, ``2`` = DIDNT_CONVERGE,
``3`` = TOO_MANY_UNKNOWNS, ``-1`` if solve raised an exception.
Use :attr:`last_solve_status` for a human-readable version.
"""
return getattr(self, "_last_solve_result", 0)
@property
def last_solve_status(self) -> str:
"""One-line human-readable description of the most recent solve outcome.
``"ok"`` on success, or a short explanation of the failure
(e.g. ``"inconsistent: the new constraint conflicts with existing
constraints"``). Useful for status-bar messages when the solver
can't satisfy the current set of constraints.
"""
return getattr(self, "_last_solve_status", "ok")
def _sync_solved_positions(self) -> None: def _sync_solved_positions(self) -> None:
"""Read solved point positions from solver and update entity geometries.""" """Read solved point positions from solver and update entity geometries.
After syncing points and lines, also refreshes every arc's stored
radius from the current centre→start distance. This is what
makes a coincident-constrained arc follow the rectangle it's
attached to: as the start/end points move with the rectangle's
corners, the solver shifts the centre onto the perpendicular
bisector and the radius becomes the new centre-to-endpoint
distance. Without this refresh the renderer would still draw
the arc with the original radius and the visual would desync
from the constraints.
Arcs whose diameter has been explicitly pinned via
:meth:`constrain_arc_diameter` are skipped — the user wants
the diameter fixed and we must not overwrite it.
"""
import math as _math
for entity_id, entity in list(self._entities.items()): for entity_id, entity in list(self._entities.items()):
if entity.entity_type == "point" and entity.handle is not None: if entity.entity_type == "point" and entity.handle is not None:
try: try:
@@ -1177,6 +1493,207 @@ class OCCSketch(SketchInterface):
if start_entity and end_entity and start_entity.geometry and end_entity.geometry: if start_entity and end_entity and start_entity.geometry and end_entity.geometry:
entity.geometry = (start_entity.geometry, end_entity.geometry) entity.geometry = (start_entity.geometry, end_entity.geometry)
elif entity.entity_type == "arc" and entity_id in self._arcs:
if entity_id in self._arc_diameter_fixed:
# User has pinned the diameter; don't touch it.
continue
arc_data = self._arcs[entity_id]
center_id = arc_data.get("center")
start_id = arc_data.get("start")
end_id = arc_data.get("end")
center_ent = self._entities.get(center_id) if center_id is not None else None
start_ent = self._entities.get(start_id) if start_id is not None else None
end_ent = self._entities.get(end_id) if end_id is not None else None
if (
center_ent is not None
and start_ent is not None
and end_ent is not None
and center_ent.geometry is not None
and start_ent.geometry is not None
and end_ent.geometry is not None
):
cx, cy = center_ent.geometry
sx, sy = start_ent.geometry
ex, ey = end_ent.geometry
# ── Side lock: keep the arc on the side the user drew it on.
# The L2-norm minimisation SolveSpace uses to pick
# the new centre position can land on the *opposite*
# side of the chord from where the user originally
# drew the arc — for example, when a corner is
# dragged upward past the original centre, the chord
# ends up above the centre and the arc now bulges
# INTO the rectangle. The sign of the stored
# ``sweep`` encodes which side the centre is on
# (positive = CCW from start→end, negative = CW),
# so we use that to detect a side flip and mirror
# the centre across the chord midpoint to put it
# back on the correct side.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
prev_sweep = arc_data.get("sweep")
if (
prev_sweep is not None
and prev_sweep != 0.0
and new_sweep != 0.0
and (prev_sweep * new_sweep) < 0.0
):
# Sign flipped — mirror the centre across the
# chord so the arc stays on the original side.
mid_x = (sx + ex) * 0.5
mid_y = (sy + ey) * 0.5
cx = 2.0 * mid_x - cx
cy = 2.0 * mid_y - cy
center_ent.geometry = (cx, cy)
# Recompute sweep with the mirrored centre.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
new_radius = _math.dist((cx, cy), (sx, sy))
arc_data["radius"] = new_radius
arc_data["sweep"] = new_sweep
if isinstance(entity.geometry, dict):
entity.geometry["radius"] = new_radius
entity.geometry["center"] = (cx, cy)
entity.geometry["start"] = (sx, sy)
entity.geometry["end"] = (ex, ey)
entity.geometry["sweep"] = new_sweep
# ── Auto-scale: preserve the original sweep ──
# The user-drawn sweep (captured in
# ``original_sweep`` at add_arc time) describes the
# arc's *shape* — its angular span and which side
# of the chord the centre is on. When the host
# geometry (e.g. a rectangle the arc is attached
# to) resizes, the L2-minimising solver leaves the
# centre close to its previous position, which
# gives the *wrong* shape (the sweep drifts). If
# the centre is free, we override it with the
# position that exactly preserves the original
# sweep on the new chord — geometrically, this is
# the only well-defined choice for an arc whose
# start and end are constrained but whose radius
# should scale with the host.
original_sweep = arc_data.get("original_sweep")
if (
original_sweep is not None
and abs(original_sweep) > 1e-9
and not self._is_centre_constrained(center_id)
):
new_cx, new_cy = self._centre_for_sweep((sx, sy), (ex, ey), original_sweep)
if new_cx is not None:
# Push the new centre into the solver
# AND the entity geometry. The solver
# accepts set_params even after solve()
# because the centre is a free point and
# the arc constraint
# (|s-c| = |e-c|) is satisfied
# automatically when the centre sits on
# the perpendicular bisector.
assert new_cy is not None # both-or-neither from _centre_for_sweep
try:
self._solver.set_params(
center_ent.handle.params,
(new_cx, new_cy),
)
except Exception as e:
logger.debug("set_params for arc centre failed: %s", e)
center_ent.geometry = (new_cx, new_cy)
cx, cy = new_cx, new_cy
new_radius = _math.dist((cx, cy), (sx, sy))
arc_data["radius"] = new_radius
# Recompute the sweep from the new
# geometry — should equal original_sweep
# up to floating point.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
arc_data["sweep"] = new_sweep
if isinstance(entity.geometry, dict):
entity.geometry["radius"] = new_radius
entity.geometry["center"] = (cx, cy)
entity.geometry["sweep"] = new_sweep
def _is_centre_constrained(self, centre_id: Optional[int]) -> bool:
"""True if *centre_id* is referenced by any constraint in the log.
Used by the arc auto-scale path in :meth:`_sync_solved_positions`
to avoid moving a centre that's locked by a coincident, fixed,
distance, or symmetric constraint — in those cases the user
has expressed an intent about where the centre should be, and
overriding it would silently break the constraint.
"""
if centre_id is None:
return True # Conservative: don't move a centre we can't identify.
for entry in self._constraint_log:
if centre_id in entry.get("ids", ()):
return True
return False
def _centre_for_sweep(
self,
start: Tuple[float, float],
end: Tuple[float, float],
sweep: float,
) -> Tuple[Optional[float], Optional[float]]:
"""Return the centre position that gives an arc a specific sweep on a chord.
Given two endpoints *start* and *end* and a signed sweep
*sweep* (positive = CCW from start→end, negative = CW), the
unique centre on the perpendicular bisector at the right
distance is::
d = |start end| / (2 * tan(|sweep| / 2))
C = midpoint ± d * normal
where *normal* is the unit vector 90° CCW from the chord
direction and the sign of *sweep* picks which side the centre
is on. Returns ``(None, None)`` for degenerate inputs (zero
chord, sweep ≥ π so d ≤ 0).
"""
import math as _math
sx, sy = start
ex, ey = end
dx = ex - sx
dy = ey - sy
chord_len = _math.hypot(dx, dy)
if chord_len < 1e-12:
return None, None
half_sweep = abs(sweep) * 0.5
# tan(π/2) is infinite — the arc is a semicircle and the centre
# sits on the chord. Skip rather than divide by zero.
if half_sweep >= _math.pi * 0.5 - 1e-9:
return None, None
d = chord_len / (2.0 * _math.tan(half_sweep))
# Left normal: rotate the chord direction 90° CCW. For a chord
# direction (dx, dy) the CCW perpendicular is (-dy, dx). With
# this convention, a POSITIVE sweep (CCW from start→end) places
# the centre on the +n side — i.e. the bulge is on the "left"
# of the chord, matching what the user sees when they draw the
# arc. Get the sign wrong and the centre lands on the wrong side
# and the stored ``original_sweep`` flips sign on the next solve.
nx = -dy / chord_len
ny = dx / chord_len
side = 1.0 if sweep > 0 else -1.0
mid_x = (sx + ex) * 0.5
mid_y = (sy + ey) * 0.5
return mid_x + d * side * nx, mid_y + d * side * ny
def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]: def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]:
"""Get the solved position of a point entity.""" """Get the solved position of a point entity."""
entity = self._entities.get(entity_id) entity = self._entities.get(entity_id)
@@ -1789,6 +2306,9 @@ class OCCSketch(SketchInterface):
self._external_entity_ids.clear() self._external_entity_ids.clear()
self._centerline_ids.clear() self._centerline_ids.clear()
self._first_point_id = None self._first_point_id = None
# New solver = new work plane; cached normal_2d is now stale.
self._wp_normal_handle = None
self._arc_diameter_fixed.clear()
def _prune_log_for(self, removed_ids: set) -> None: def _prune_log_for(self, removed_ids: set) -> None:
"""Drop constraint-log entries that reference any id in ``removed_ids``.""" """Drop constraint-log entries that reference any id in ``removed_ids``."""
@@ -1902,6 +2422,9 @@ class OCCSketch(SketchInterface):
del self._arcs[aid] del self._arcs[aid]
if aid in self._entities: if aid in self._entities:
del self._entities[aid] del self._entities[aid]
# If a diameter-pinned arc is being torn down, clear its flag
# so the set doesn't grow stale.
self._arc_diameter_fixed.discard(aid)
self._prune_log_for(removed_ids) self._prune_log_for(removed_ids)
self._rebuild_solver() self._rebuild_solver()
@@ -2005,13 +2528,71 @@ class OCCSketch(SketchInterface):
entities_payload: List[Dict[str, Any]] = [] entities_payload: List[Dict[str, Any]] = []
for eid in sorted(self._entities.keys()): for eid in sorted(self._entities.keys()):
ent = self._entities[eid] ent = self._entities[eid]
# Serialize line / circle / arc geometry DERIVED from the point
# entities they reference, not from ``ent.geometry``. A drag
# (set_entity_position) updates the point but not the line/circle
# stored geometry, so the raw attribute can be stale — writing it
# produces files whose lines/circles no longer match their
# endpoints, and the next load drops those entities.
geometry_payload = ent.geometry
if ent.entity_type == "line":
line_ref = self._lines.get(eid)
if line_ref is not None:
s_ent = self._entities.get(line_ref[0])
e_ent = self._entities.get(line_ref[1])
if (
s_ent is not None
and e_ent is not None
and s_ent.geometry is not None
and e_ent.geometry is not None
):
geometry_payload = (tuple(s_ent.geometry), tuple(e_ent.geometry))
elif ent.entity_type == "circle":
circle_ref = self._circles.get(eid)
if circle_ref is not None:
c_ent = self._entities.get(circle_ref[0])
if c_ent is not None and c_ent.geometry is not None:
try:
geometry_payload = (tuple(c_ent.geometry), float(circle_ref[1]))
except (TypeError, ValueError):
# Corrupt in-memory radius must not abort the save.
geometry_payload = ent.geometry
elif ent.entity_type == "arc" and isinstance(ent.geometry, dict):
arc_data = self._arcs.get(eid)
if arc_data is not None:
c_ent = self._entities.get(arc_data.get("center"))
s_ent = self._entities.get(arc_data.get("start"))
e_ent = self._entities.get(arc_data.get("end"))
if (
c_ent is not None
and s_ent is not None
and e_ent is not None
and c_ent.geometry is not None
and s_ent.geometry is not None
and e_ent.geometry is not None
):
try:
radius_val = float(
arc_data.get("radius", ent.geometry.get("radius", 0.0))
)
sweep_val = float(arc_data.get("sweep", ent.geometry.get("sweep", 0.0)))
except (TypeError, ValueError):
radius_val = ent.geometry.get("radius", 0.0)
sweep_val = ent.geometry.get("sweep", 0.0)
geometry_payload = {
"center": tuple(c_ent.geometry),
"start": tuple(s_ent.geometry),
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
}
entities_payload.append( entities_payload.append(
{ {
"id": eid, "id": eid,
"type": ent.entity_type, "type": ent.entity_type,
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)), # geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
# circle→((cx,cy),r), arc→dict. All JSON-friendly. # circle→((cx,cy),r), arc→dict. All JSON-friendly.
"geometry": ent.geometry, "geometry": geometry_payload,
"is_construction": bool(ent.is_construction), "is_construction": bool(ent.is_construction),
"is_external": bool(ent.is_external), "is_external": bool(ent.is_external),
"constraints": list(ent.constraints), "constraints": list(ent.constraints),
@@ -2065,6 +2646,12 @@ class OCCSketch(SketchInterface):
order so :attr:`_first_point_id` is anchored correctly. Existing order so :attr:`_first_point_id` is anchored correctly. Existing
callers (notably the solver-rebuild path on entity delete) don't use callers (notably the solver-rebuild path on entity delete) don't use
this; only the project load path does. this; only the project load path does.
Numeric coercions (``int``/``float``) and entity-replay calls are
wrapped in try/except: a single corrupt entry in a project file
(hand-edited, partially-written, from a different app version) must
not abort the whole load. The bad entry is logged and skipped so
the surviving geometry can still be loaded and used.
""" """
# Wipe solver + trackers (don't lose the workplane yet — we set it # Wipe solver + trackers (don't lose the workplane yet — we set it
# explicitly below). # explicitly below).
@@ -2081,59 +2668,139 @@ class OCCSketch(SketchInterface):
# 2. Force the entity counter so the replay assigns the same ids as # 2. Force the entity counter so the replay assigns the same ids as
# the saved sketch — the constraint log references those ids. # the saved sketch — the constraint log references those ids.
try:
self._entity_counter = int(data.get("entity_counter", 0)) self._entity_counter = int(data.get("entity_counter", 0))
except (TypeError, ValueError) as e:
logger.warning("entity_counter invalid (%s); starting at 0", e)
self._entity_counter = 0
# 3. Replay entities in id order. We need the OCCSketchEntity # 3. Replay entities. Points are loaded in a first pass (in file
# objects back (for arc center/start/end lookups), so we # order) and lines / circles / arcs in a second pass, so an
# reconstruct by id and let ``_next_id`` advance the counter. # endpoint reference can resolve by position even when the
# referenced point has a HIGHER id than the line (re-saved
# recovery points, hand-edited files). We need the
# OCCSketchEntity objects back (for arc center/start/end
# lookups), so we reconstruct by id and let ``_next_id`` advance
# the counter.
entities_by_id: Dict[int, OCCSketchEntity] = {} entities_by_id: Dict[int, OCCSketchEntity] = {}
for entry in data.get("entities", []): # Pre-compute the centerline id set (step 5 below restores it after
# the loops) and the highest saved id. A legacy file can save a
# centerline whose axis point has stale coordinates, leaving the
# line's other endpoint unmatched; in that case we reconstruct the
# missing point with an id ABOVE every saved id so it can't collide
# with the entities still to load.
try:
centerline_ids_set = {int(x) for x in data.get("centerline_ids", [])}
except (TypeError, ValueError):
centerline_ids_set = set()
max_saved_id = 0
try:
max_saved_id = max(int(e["id"]) for e in data.get("entities", []))
except (TypeError, ValueError):
pass
def _replay_entry(entry: Dict[str, Any]) -> None:
"""Recreate one saved entity, preserving its id and flags."""
nonlocal entities_by_id
try:
eid = int(entry["id"]) eid = int(entry["id"])
except (TypeError, ValueError) as e:
logger.warning("Skipping entity with invalid id (%s): %r", e, entry)
return
# Ensure the next _next_id() call returns eid. # Ensure the next _next_id() call returns eid.
self._entity_counter = eid - 1 self._entity_counter = eid - 1
etype = entry["type"] etype = entry["type"]
geom = entry.get("geometry") geom = entry.get("geometry")
is_external = bool(entry.get("is_external", False)) is_external = bool(entry.get("is_external", False))
if geom is None:
logger.warning("Skipping entity %s during load: missing geometry", eid)
return
try:
if etype == "point": if etype == "point":
try:
x, y = float(geom[0]), float(geom[1]) x, y = float(geom[0]), float(geom[1])
except (TypeError, ValueError, IndexError) as e:
logger.warning("Skipping point %s during load: bad geometry (%s)", eid, e)
return
if is_external: if is_external:
ent = self.add_external_point(x, y) ent = self.add_external_point(x, y)
else: else:
ent = self.add_point(x, y) ent = self.add_point(x, y)
elif etype == "line": elif etype == "line":
# line geometry is ((x1,y1),(x2,y2)); we already know the # line geometry is ((x1,y1),(x2,y2)); the endpoints are
# endpoints exist as point entities. Look them up by saved # point entities already loaded in pass 1. Look them up
# position via _points (which was just populated above). # by saved position via _points. Older files can carry
# line geometry that is stale relative to the endpoint
# points (a drag without a subsequent solve), so fall
# back to the nearest point within a small world
# tolerance before giving up.
try:
(x1, y1), (x2, y2) = geom (x1, y1), (x2, y2) = geom
s_id = self._find_point_at(x1, y1) except (TypeError, ValueError) as e:
e_id = self._find_point_at(x2, y2) logger.warning("Skipping line %s during load: bad geometry (%s)", eid, e)
return
s_id = self._find_point_at(x1, y1) or self._find_point_near(x1, y1)
e_id = self._find_point_at(x2, y2) or self._find_point_near(x2, y2)
if (s_id is None or e_id is None) and eid in centerline_ids_set:
# Centerline endpoint missing — older files saved the
# axis point with stale coordinates. Reconstruct it
# from the line's own geometry so the reference axis
# survives; allocate above every saved id to avoid
# colliding with entities that load afterwards.
self._entity_counter = max(max_saved_id, self._entity_counter)
if s_id is None:
s_ent = self.add_point(x1, y1)
s_ent.is_construction = True
s_id = s_ent.id
entities_by_id[s_id] = s_ent
self._centerline_ids.add(s_id)
if e_id is None:
e_ent = self.add_point(x2, y2)
e_ent.is_construction = True
e_id = e_ent.id
entities_by_id[e_id] = e_ent
self._centerline_ids.add(e_id)
# Recovery points were allocated above every saved
# id; re-point the counter at the line's own id so
# the line below keeps its saved id (and the next
# file entity re-points the counter anyway).
self._entity_counter = eid - 1
if s_id is None or e_id is None: if s_id is None or e_id is None:
logger.warning("Skipping line %s during load: endpoints not found", eid) logger.warning("Skipping line %s during load: endpoints not found", eid)
continue return
if is_external: if is_external:
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id]) ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
else: else:
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id]) ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
elif etype == "circle": elif etype == "circle":
try:
(cx, cy), radius = geom (cx, cy), radius = geom
c_id = self._find_point_at(cx, cy) radius = float(radius)
except (TypeError, ValueError) as e:
logger.warning("Skipping circle %s during load: bad geometry (%s)", eid, e)
return
c_id = self._find_point_at(cx, cy) or self._find_point_near(cx, cy)
if c_id is None: if c_id is None:
logger.warning("Skipping circle %s during load: center not found", eid) logger.warning("Skipping circle %s during load: center not found", eid)
continue return
ent = self.add_circle(entities_by_id[c_id], float(radius)) ent = self.add_circle(entities_by_id[c_id], radius)
elif etype == "arc": elif etype == "arc":
try:
center_pos = tuple(geom["center"]) center_pos = tuple(geom["center"])
start_pos = tuple(geom["start"]) start_pos = tuple(geom["start"])
end_pos = tuple(geom["end"]) end_pos = tuple(geom["end"])
radius = float(geom["radius"]) radius = float(geom["radius"])
sweep = float(geom.get("sweep", 0.0)) sweep = float(geom.get("sweep", 0.0))
c_id = self._find_point_at(*center_pos) except (TypeError, ValueError, KeyError) as e:
s_id = self._find_point_at(*start_pos) logger.warning("Skipping arc %s during load: bad geometry (%s)", eid, e)
e_id = self._find_point_at(*end_pos) return
c_id = self._find_point_at(*center_pos) or self._find_point_near(*center_pos)
s_id = self._find_point_at(*start_pos) or self._find_point_near(*start_pos)
e_id = self._find_point_at(*end_pos) or self._find_point_near(*end_pos)
if c_id is None or s_id is None or e_id is None: if c_id is None or s_id is None or e_id is None:
logger.warning("Skipping arc %s during load: endpoints not found", eid) logger.warning("Skipping arc %s during load: endpoints not found", eid)
continue return
ent = self.add_arc( ent = self.add_arc(
entities_by_id[c_id], entities_by_id[c_id],
radius, radius,
@@ -2143,7 +2810,13 @@ class OCCSketch(SketchInterface):
) )
else: else:
logger.warning("Unknown sketch entity type %r; skipping", etype) logger.warning("Unknown sketch entity type %r; skipping", etype)
continue return
except Exception as e:
# Last-ditch guard: a single bad entity must not abort the
# whole load. Log and move on so the rest of the sketch
# can still be reconstructed.
logger.warning("Skipping entity %s during load: %s", eid, e)
return
# Restore the per-entity UI flags / labels that aren't carried # Restore the per-entity UI flags / labels that aren't carried
# by the add_* methods themselves. # by the add_* methods themselves.
@@ -2151,6 +2824,17 @@ class OCCSketch(SketchInterface):
ent.constraints = list(entry.get("constraints", [])) ent.constraints = list(entry.get("constraints", []))
entities_by_id[eid] = ent entities_by_id[eid] = ent
all_entries = data.get("entities", [])
# Pass 1: every point, so pass 2 can resolve endpoint references by
# position regardless of the id order in the file.
for entry in all_entries:
if entry.get("type") == "point":
_replay_entry(entry)
# Pass 2: lines, circles, arcs (id order preserved per entry).
for entry in all_entries:
if entry.get("type") != "point":
_replay_entry(entry)
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the # 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
# solver call and pushes back into the entity tracker via # solver call and pushes back into the entity tracker via
# ``entity.constraints``. We don't double-record into the log # ``entity.constraints``. We don't double-record into the log
@@ -2174,9 +2858,15 @@ class OCCSketch(SketchInterface):
# for some reason (legacy / hand-edited file), fold it in too so # for some reason (legacy / hand-edited file), fold it in too so
# the saved flag is authoritative. # the saved flag is authoritative.
for eid in data.get("external_entity_ids", []): for eid in data.get("external_entity_ids", []):
try:
self._external_entity_ids.add(int(eid)) self._external_entity_ids.add(int(eid))
except (TypeError, ValueError) as exc:
logger.warning("Skipping invalid external_entity_ids entry: %s", exc)
for eid in data.get("centerline_ids", []): for eid in data.get("centerline_ids", []):
try:
self._centerline_ids.add(int(eid)) self._centerline_ids.add(int(eid))
except (TypeError, ValueError) as exc:
logger.warning("Skipping invalid centerline_ids entry: %s", exc)
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]: def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol).""" """Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
@@ -2184,3 +2874,20 @@ class OCCSketch(SketchInterface):
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol: if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
return pid return pid
return None return None
def _find_point_near(self, x: float, y: float, tol: float = _LOAD_POINT_TOL) -> Optional[int]:
"""Return the id of the point *closest* to ``(x, y)`` within ``tol``.
Fallback for ``_find_point_at`` when loading older project files
whose line/circle/arc geometry is stale relative to the point
entities (saved after a drag that was never re-solved). Points are
authoritative — the derived geometry just has to reach them.
"""
best_id: Optional[int] = None
best_d = tol
for pid, pos in self._points.items():
d = math.hypot(pos[0] - x, pos[1] - y)
if d < best_d:
best_d = d
best_id = pid
return best_id
+151 -23
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
@@ -42,6 +43,7 @@ from fluency.models.data_model import (
Body, Body,
Component, Component,
Connector, Connector,
Feature,
Project, Project,
Sketch, Sketch,
Workplane, Workplane,
@@ -65,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")
@@ -85,10 +87,37 @@ 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)
try:
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
seq = value.tolist() seq = value.tolist()
else: else:
@@ -96,10 +125,13 @@ def _to_3tuple(value: Any) -> Tuple[float, float, float]:
if len(seq) < 3: if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq)) seq = list(seq) + [0.0] * (3 - len(seq))
return (float(seq[0]), float(seq[1]), float(seq[2])) 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."""
try:
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
return value.astype(float).reshape(3) return value.astype(float).reshape(3)
if value is None: if value is None:
@@ -108,10 +140,13 @@ def _to_3vec(value: Any) -> np.ndarray:
if len(seq) < 3: if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq)) seq = list(seq) + [0.0] * (3 - len(seq))
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float) return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
except (TypeError, ValueError, IndexError):
return np.zeros(3, dtype=float)
def _to_mat3(value: Any) -> np.ndarray: 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."""
try:
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
arr = value.astype(float) arr = value.astype(float)
return arr.reshape(3, 3) return arr.reshape(3, 3)
@@ -121,6 +156,8 @@ def _to_mat3(value: Any) -> np.ndarray:
if len(flat) < 9: if len(flat) < 9:
flat = flat + [0.0] * (9 - len(flat)) flat = flat + [0.0] * (9 - len(flat))
return np.array(flat[:9], dtype=float).reshape(3, 3) return np.array(flat[:9], dtype=float).reshape(3, 3)
except (TypeError, ValueError, IndexError):
return np.eye(3, dtype=float)
def _parse_iso(value: Optional[str]) -> datetime: def _parse_iso(value: Optional[str]) -> datetime:
@@ -151,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"))
@@ -163,6 +200,56 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
return wp return wp
def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"""Serialize one parametric feature (sketch id + params).
"base" snapshot features are NOT serialized here — their frozen
geometry is written as a separate STEP member (``base_geometry_ref``)
and the ``features_base_snapshot`` flag on the body marks that the
list starts with one.
"""
return {
"id": feat.id,
"operation": feat.operation,
"sketch_id": feat.sketch.id if feat.sketch is not None else None,
"length": feat.length,
"symmetric": bool(feat.symmetric),
"invert": bool(feat.invert),
"through_all": bool(feat.through_all),
"cut_all_bodies": bool(feat.cut_all_bodies),
"face_index": feat.face_index,
"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),
}
def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Feature:
"""Deserialize a feature, resolving its sketch reference against the
component's already-loaded sketches."""
feat = Feature(
id=_saved_id(data),
operation=data.get("operation", "extrude"),
length=data.get("length"),
symmetric=bool(data.get("symmetric", False)),
invert=bool(data.get("invert", False)),
through_all=bool(data.get("through_all", False)),
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
face_index=data.get("face_index"),
angle=_to_float(data.get("angle"), 360.0),
radius=data.get("radius"),
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
)
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
return feat
def _body_to_dict(body: Body) -> Dict[str, Any]: def _body_to_dict(body: Body) -> Dict[str, Any]:
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer """Body serialization. ``geometry_ref`` is set later by the ZIP writer
once the STEP file is written.""" once the STEP file is written."""
@@ -180,10 +267,13 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"extrude_cut_all_bodies": body.extrude_cut_all_bodies, "extrude_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index, "extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id, "extrude_target_body_id": body.extrude_target_body_id,
"features": [_feature_to_dict(f) for f in body.features if f.operation != "base"],
"features_base_snapshot": bool(body.features and body.features[0].operation == "base"),
"base_geometry_ref": None, # filled in by save_project
"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
@@ -202,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,
@@ -219,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"))
@@ -269,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
@@ -279,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,
@@ -316,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"),
@@ -337,7 +427,22 @@ def _component_from_dict(
src_id = body_data.get("source_sketch_id") src_id = body_data.get("source_sketch_id")
if src_id and src_id in comp.sketches: if src_id and src_id in comp.sketches:
src_sketch = comp.sketches[src_id] src_sketch = comp.sketches[src_id]
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch) body = _body_from_dict(body_data, body_geometry_loader, src_sketch)
# Parametric feature history (new files). Old files carry no
# "features" key — the body keeps an empty list and is migrated
# lazily at update time (see ``_ensure_feature_history``).
for f_data in body_data.get("features") or []:
body.features.append(_feature_from_dict(f_data, comp.sketches))
if body_data.get("features_base_snapshot") and body.features:
# The list was saved WITHOUT its leading "base" snapshot;
# restore it from the dedicated STEP member.
base_geom: Optional[OCCGeometryObject] = None
base_ref = body_data.get("base_geometry_ref")
if base_ref and body_geometry_loader is not None:
base_geom = body_geometry_loader(base_ref)
if base_geom is not None:
body.features.insert(0, Feature(operation="base", geometry=base_geom))
comp.bodies[bid] = body
return comp return comp
@@ -349,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,
@@ -364,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", ""),
) )
@@ -398,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")),
@@ -424,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"),
@@ -448,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"),
) )
@@ -519,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
@@ -582,6 +689,20 @@ def save_project(
arcname = f"bodies/{body_id}.step" arcname = f"bodies/{body_id}.step"
body_files.append((arcname, step_bytes)) body_files.append((arcname, step_bytes))
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
# Base-snapshot STEP for migrated legacy bodies whose feature
# list starts with a frozen "base" geometry snapshot.
if (
body.features
and body.features[0].operation == "base"
and body.features[0].geometry is not None
):
base_bytes = _write_step_for_body(kernel, body.features[0].geometry)
if base_bytes is not None:
base_arcname = f"bodies/{body_id}_base.step"
body_files.append((base_arcname, base_bytes))
manifest["components"][comp_id]["bodies"][body_id]["base_geometry_ref"] = (
base_arcname
)
# Per-sketch STEP files (solved face geometry). # Per-sketch STEP files (solved face geometry).
sketch_files: List[Tuple[str, bytes]] = [] sketch_files: List[Tuple[str, bytes]] = []
@@ -674,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")
try:
manifest = json.loads(manifest_raw.decode("utf-8")) 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
@@ -689,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
try:
meta = json.loads(meta_bytes.decode("utf-8")) 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).
+62 -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]:
@@ -206,6 +206,59 @@ class Sketch:
self.modified_at = datetime.now() self.modified_at = datetime.now()
@dataclass
class Feature:
"""
One operation in a body's parametric feature history.
Bodies rebuild their geometry by replaying their ordered feature
list from scratch (see ``Body.features``). This is what makes
sketch edits propagate: a moved circle re-cuts at the new position
on a freshly rebuilt base instead of adding to the previous result.
``operation`` is one of:
- "extrude": base solid, ``kernel.extrude`` of the sketch profile
- "revolve": base solid, ``kernel.revolve`` of the sketch profile
- "cut": boolean difference of the running geometry with the
extruded sketch profile
- "union": boolean union of the running geometry with the
extruded sketch profile
- "fillet": round a set of edges of the running geometry
(``radius``, ``tangent_propagation``, ``scope``,
``edge_refs`` see below)
- "base": frozen geometry snapshot (``geometry`` field) used
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
operation: str = "extrude"
sketch: Optional[Sketch] = None # runtime ref; serialized as sketch_id
length: Optional[float] = None
symmetric: bool = False
invert: bool = False
through_all: bool = False
cut_all_bodies: bool = False
face_index: Optional[int] = None # which sketch face was selected
angle: float = 360.0 # revolve only (degrees)
# "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None
# "fillet" features only: radius (mm) of the round, whether the fillet
# should extend along edges tangent to the picked ones, the edge scope
# ("selected" = edges between the two picked faces, "all" = every edge
# of the body), and stable fingerprints of the selected edges so the
# replay can re-find them after the base geometry is rebuilt.
radius: Optional[float] = None
tangent_propagation: bool = False
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
@dataclass @dataclass
class Body: class Body:
""" """
@@ -222,6 +275,12 @@ class Body:
source_sketch: Optional[Sketch] = None source_sketch: Optional[Sketch] = None
source_operation: str = "extrude" source_operation: str = "extrude"
# Parametric feature history. When non-empty, the body is rebuilt
# from scratch by replaying these features in order; the flat
# extrude_* / source_* fields below then only mirror the LAST
# feature for backward compatibility (old files, old code paths).
features: List[Feature] = field(default_factory=list)
# Re-extrusion parameters — stored so the body can be rebuilt from # Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body # its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto- # was not created by an extrude-type operation and cannot be auto-
+241 -94
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)
@@ -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", "type": "planar_face",
"position": origin, "position": origin,
"normal": (nx, ny, nz), "normal": (nx, ny, nz),
"x_dir": x_dir, "x_dir": x_dir,
"face": face, "face": face,
"owner_obj_id": owner_obj_id, "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.X() + ax_dir.X() * vmin,
ax_pos.Y() + ax_dir.Y() * vmin, ax_pos.Y() + ax_dir.Y() * vmin,
ax_pos.Z() + ax_dir.Z() * vmin, ax_pos.Z() + ax_dir.Z() * vmin,
], dtype=float) ],
c1 = np.array([ dtype=float,
)
c1 = np.array(
[
ax_pos.X() + ax_dir.X() * vmax, ax_pos.X() + ax_dir.X() * vmax,
ax_pos.Y() + ax_dir.Y() * vmax, ax_pos.Y() + ax_dir.Y() * vmax,
ax_pos.Z() + ax_dir.Z() * vmax, ax_pos.Z() + ax_dir.Z() * vmax,
], dtype=float) ],
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,7 +1524,8 @@ 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", "type": "cylindrical_face",
"position": origin, "position": origin,
"normal": normal, "normal": normal,
@@ -1431,7 +1533,8 @@ class OCCRenderer(Renderer):
"face": face, "face": face,
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
"radius": radius, "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", "type": "edge",
"position": position, "position": position,
"normal": tangent, "normal": tangent,
"x_dir": x_dir, "x_dir": x_dir,
"edge": edge, "edge": edge,
"owner_obj_id": owner_obj_id, "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", "type": "vertex",
"position": position, "position": position,
"normal": None, "normal": None,
"x_dir": None, "x_dir": None,
"vertex": vertex, "vertex": vertex,
"owner_obj_id": owner_obj_id, "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()
@@ -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",
)
File diff suppressed because it is too large Load Diff
+291 -32
View File
@@ -97,6 +97,12 @@ class Sketch2DWidget(QWidget):
constrain_done = Signal() constrain_done = Signal()
sketch_updated = Signal() sketch_updated = Signal()
# Emitted when the SolveSpace solver returns a non-OKAY result
# (INCONSISTENT, DIDNT_CONVERGE, or an exception). The payload is
# a short human-readable explanation; the main window hooks this up
# to the status bar so the user sees the failure instead of the
# geometry silently staying put.
solver_warning = Signal(str)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -524,6 +530,24 @@ class Sketch2DWidget(QWidget):
return False return False
return entity.id in self._sketch._centerline_ids return entity.id in self._sketch._centerline_ids
@staticmethod
def _flat_xy(geometry: Any) -> Optional[Tuple[float, float]]:
"""Return ``(x, y)`` when *geometry* is a flat 2-tuple of numbers (a point).
Guards the point-iteration loops against malformed point geometry
(e.g. circle-shaped ``((cx, cy), r)`` left behind by a legacy
load or constraint replay) so a bad entity degrades to "not
snappable / not drawn" instead of crashing ``round()``.
"""
if (
isinstance(geometry, (tuple, list))
and len(geometry) == 2
and isinstance(geometry[0], (int, float))
and isinstance(geometry[1], (int, float))
):
return (float(geometry[0]), float(geometry[1]))
return None
def get_sketch(self) -> Optional[OCCSketch]: def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch return self._sketch
@@ -624,6 +648,45 @@ class Sketch2DWidget(QWidget):
self.setCursor(Qt.ArrowCursor) self.setCursor(Qt.ArrowCursor)
self.update() self.update()
def _is_drawing_tool_active(self) -> bool:
"""Return True while a drawing or constraint tool is in progress.
Used to decide whether to render the constraint badges / dimension
lines in ``paintEvent``. They are visually loud and frequently
overlap the geometry the user is currently trying to place, so we
suppress them while an operation is mid-flight and bring them back
as soon as the operation is finished, cancelled, or never started
(``mode is None`` or ``mode == "select"``).
"""
if self._mode is None or self._mode == "select":
return False
return True
def _cancel_active_tool(self) -> None:
"""Abort the currently active drawing / constraint tool.
Single source of truth for "deselect the current tool" called
from the Escape key handler, the right-click cancel branch, and
anywhere else that needs to drop the user back to a neutral state.
Resets the draw buffer, arc-sweep tracker, and any in-progress
constraint multi-select, then emits ``constrain_done`` so the
toolbar buttons uncheck themselves.
"""
if not self._is_drawing_tool_active():
return
self._mode = None
self._draw_buffer = []
self._dynamic_line_end = None
self._selected_entities = []
self._hovered_constraint_idx = -1
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
# Invalidate any pending dimension-input prompt so the value
# dialog (if one is open elsewhere) doesn't latch onto stale state.
self._pending_distance_val = None
self.constrain_done.emit()
self.update()
def set_construct_mode(self, enabled: bool): def set_construct_mode(self, enabled: bool):
self._is_construct = enabled self._is_construct = enabled
@@ -683,9 +746,12 @@ class Sketch2DWidget(QWidget):
# cursor position while the entity lookup returned None and no # cursor position while the entity lookup returned None and no
# coincident constraint was created (silent disconnect). # coincident constraint was created (silent disconnect).
entity = self._find_nearest_point_entity(pos, max_distance) entity = self._find_nearest_point_entity(pos, max_distance)
if entity is None or entity.geometry is None: if entity is None:
return None return None
x, y = entity.geometry xy = self._flat_xy(entity.geometry)
if xy is None:
return None
x, y = xy
return QPoint(int(round(x)), int(round(y))) return QPoint(int(round(x)), int(round(y)))
def _find_nearest_point_entity( def _find_nearest_point_entity(
@@ -705,13 +771,13 @@ class Sketch2DWidget(QWidget):
for entity in self._points: for entity in self._points:
if self._is_external(entity) and not self._underlay_visible: if self._is_external(entity) and not self._underlay_visible:
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
continue
x, y = xy
point = QPoint(int(round(x)), int(round(y))) point = QPoint(int(round(x)), int(round(y)))
screen_point = self._world_to_screen(point) screen_point = self._world_to_screen(point)
dist = math.sqrt( dist = math.sqrt((pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2)
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
)
if dist < min_dist: if dist < min_dist:
min_dist = dist min_dist = dist
nearest = entity nearest = entity
@@ -890,9 +956,12 @@ class Sketch2DWidget(QWidget):
nearest_entity = None nearest_entity = None
min_dist = self._snap_distance min_dist = self._snap_distance
for entity in self._points: for entity in self._points:
if entity.id in exclude_ids or not entity.geometry: if entity.id in exclude_ids:
continue continue
x, y = entity.geometry xy = self._flat_xy(entity.geometry)
if xy is None:
continue
x, y = xy
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2) d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
if d < min_dist: if d < min_dist:
@@ -990,8 +1059,10 @@ class Sketch2DWidget(QWidget):
for entity in self._points: for entity in self._points:
if self._is_external(entity) and not self._underlay_visible: if self._is_external(entity) and not self._underlay_visible:
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
continue
x, y = xy
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2) dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
if dist < self._pick_tolerance_world(10): if dist < self._pick_tolerance_world(10):
return entity return entity
@@ -1440,7 +1511,28 @@ class Sketch2DWidget(QWidget):
return None return None
def _sync_solved_positions(self): def _sync_solved_positions(self):
"""Sync solver positions back to UI points and lines.""" """Sync solver positions back to UI points, lines, AND arcs.
The UI keeps its own ``self._arcs`` list (a snapshot of
``(centre, radius, start, end, sweep)`` tuples built in
:meth:`_rebuild_from_sketch`) for fast access in
:meth:`paintEvent`. The radius in that list was a frozen copy
of the value at creation time before the sketch solver was
wired up to the arc entity, it never changed after a solve, so
arcs visually stayed the same size even when the user resized
the rectangle they were attached to.
Now that the sketch solver owns the arc (and refreshes the
radius from the current centrestart distance after every
solve in :meth:`OCCSketch._sync_solved_positions`), we mirror
that refresh here: read each arc's current radius / sweep
straight from the authoritative ``self._sketch._arcs`` dict and
update the corresponding tuple in ``self._arcs`` in place.
Tuples are immutable so we replace each entry with a freshly-
built tuple rather than mutating an item the ``paintEvent``
loop keeps working without re-running ``_rebuild_from_sketch``.
"""
if not self._sketch: if not self._sketch:
return return
for entity in self._points: for entity in self._points:
@@ -1454,12 +1546,66 @@ class Sketch2DWidget(QWidget):
for p1_ent, p2_ent in self._lines: for p1_ent, p2_ent in self._lines:
if p1_ent.geometry and p2_ent.geometry: if p1_ent.geometry and p2_ent.geometry:
pass # geometry already updated via point sync pass # geometry already updated via point sync
# Refresh arc radii / sweeps from the sketch so a coincident-
# constrained arc (e.g. attached to a rectangle's corner) resizes
# with the rectangle. See OCCSketch._sync_solved_positions.
if self._arcs:
import math as _m
new_arcs = []
for centre_ent, _radius, start_ent, end_ent, _sweep in self._arcs:
arc_id = None
for aid, adata in self._sketch._arcs.items():
if adata.get("center") == centre_ent.id:
arc_id = aid
break
if arc_id is None:
# The sketch no longer has this arc (deleted by
# ``delete_point`` / project load). Drop it from
# the UI list so paintEvent doesn't draw a ghost.
continue
adata = self._sketch._arcs[arc_id]
new_radius = float(adata.get("radius", _radius))
new_sweep = adata.get("sweep", _sweep)
if (
new_sweep is None
and start_ent.geometry
and end_ent.geometry
and centre_ent.geometry
):
# Legacy arc without a stored sweep — fall back to the
# same shortest-path inference that paintEvent uses.
sx, sy = start_ent.geometry
ex, ey = end_ent.geometry
cx, cy = centre_ent.geometry
sa = _m.atan2(sy - cy, sx - cx)
ea = _m.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _m.pi:
new_sweep -= 2 * _m.pi
while new_sweep < -_m.pi:
new_sweep += 2 * _m.pi
new_arcs.append((centre_ent, new_radius, start_ent, end_ent, new_sweep))
self._arcs = new_arcs
def _solve_and_sync(self) -> bool: def _solve_and_sync(self) -> bool:
"""Solve constraints, sync positions, update UI. Returns True if solved OK.""" """Solve constraints, sync positions, update UI. Returns True if solved OK.
On a non-OKAY solve result (INCONSISTENT, DIDNT_CONVERGE, ...)
the geometry is left untouched and :attr:`solver_warning` is
emitted with a one-line human-readable explanation sourced from
:attr:`OCCSketch.last_solve_status`. The main window hooks this
to the status bar so the user sees the failure instead of the
geometry silently staying put.
"""
if not self._sketch: if not self._sketch:
return True return True
ok = self._sketch.solve() ok = self._sketch.solve()
if not ok:
# Surface the failure to the user. Status text comes from
# OCCSketch.last_solve_status (e.g. "inconsistent: the new
# constraint conflicts with existing constraints").
self.solver_warning.emit(f"Solver failed — {self._sketch.last_solve_status}")
self._sync_solved_positions() self._sync_solved_positions()
self.update() self.update()
return ok return ok
@@ -1635,16 +1781,13 @@ class Sketch2DWidget(QWidget):
return return
if event.button() == Qt.RightButton: if event.button() == Qt.RightButton:
# Only clear drawing-state, preserve any point selection for the # Right-click cancels the active drawing/constraint tool (same
# context menu (contextMenuEvent is invoked by Qt after this). # behaviour as pressing Escape). Routed through the shared
if self._mode in ("line", "rectangle", "circle", "arc", "slot"): # helper so right-click, Escape, and any future cancel path
self._mode = None # all perform the exact same teardown. We only suppress the
self._draw_buffer = [] # tool itself — the context menu (if any) is still delivered
self._dynamic_line_end = None # by Qt afterwards, so we do NOT return here.
self._arc_accum_sweep = 0.0 self._cancel_active_tool()
self._arc_prev_angle = None
self.constrain_done.emit()
# Do NOT return — let Qt deliver the contextMenuEvent.
return return
if event.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
@@ -1957,6 +2100,32 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync() self._solve_and_sync()
self._snap_point_target = None self._snap_point_target = None
else: else:
# Lock the moved anchor in its new location with a
# ``dragged`` constraint. ``set_positions`` alone only
# seeds the solver's initial state; with the many DOFs
# a free sketch typically has, the solver reverts the
# position to minimise the L2 parameter change. Adding
# ``dragged`` makes the move a hard constraint so the
# position sticks — and lets the arc constraint (now
# live in the solver, see OCCSketch._sync_solved_positions)
# propagate the change into a new centre/radius.
#
# Why only the anchor? ``_collect_connected_points``
# already moves every connected point by the same delta
# in mouseMoveEvent; locking all of them would freeze
# the entire connected component (e.g. a 4-corner
# rectangle becomes a rigid body and individual corners
# can no longer be dragged to resize it). Locking just
# the anchor lets the other points adjust under the
# line / arc / coincident constraints, which is what
# the user expects from dragging a single corner.
if self._move_anchor is not None and self._move_anchor.geometry is not None:
ax, ay = self._move_anchor.geometry
if not self._sketch.is_entity_dragged(self._move_anchor.id):
self._sketch.constrain_fixed(self._move_anchor)
# constrain_fixed reads the current params via
# the dragged() call, so re-sync to be safe.
self._solve_and_sync()
# Auto-constrain: snap → coincident / point-on-line # Auto-constrain: snap → coincident / point-on-line
target = None target = None
if self._snap_point_target is not None: if self._snap_point_target is not None:
@@ -2054,6 +2223,17 @@ class Sketch2DWidget(QWidget):
event.accept() event.accept()
return return
# Escape cancels the active drawing/constraint tool. This is
# the user-facing counterpart to right-click: drop the in-progress
# operation (without losing the entities already placed) and
# uncheck the toolbar button so the constraint badges / dimension
# overlay reappears.
if event.key() == Qt.Key_Escape and not event.modifiers():
if self._is_drawing_tool_active():
self._cancel_active_tool()
event.accept()
return
super().keyPressEvent(event) super().keyPressEvent(event)
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
@@ -2247,6 +2427,54 @@ class Sketch2DWidget(QWidget):
self.sketch_updated.emit() self.sketch_updated.emit()
self.update() self.update()
def convert_hovered_line_to_construction(self) -> bool:
"""Convert the hovered line to a construction line.
Public counterpart of ``_toggle_hovered_line_construction`` that
always sets the line to construction (not toggle). Wired to the
toolbar "Cstrct" button so that selecting an existing line and
pressing the button promotes it to a construction line the
same behaviour the C key provides via toggle, but with
button-as-action semantics.
Only the line entity's ``is_construction`` flag is changed; its
endpoint points are left alone so that other lines sharing the
same endpoint are unaffected. External (underlay) lines and
centerlines are rejected (matching the toggle behaviour).
Returns ``True`` if a line was converted, ``False`` otherwise
(no sketch, no hover, external/centerline, or already
construction). Callers use the return value to decide whether
the button should be left in the "on" state.
"""
line_ent = self._hovered_line_entity
if line_ent is None or self._sketch is None:
return False
# External (underlay) lines are reference geometry from the source
# face — they can't be promoted individually.
if getattr(line_ent, "is_external", False):
logger.debug("Refusing to convert external (underlay) line to construction")
return False
# Centerlines are permanent reference axes — refuse promotion.
if self._is_centerline(line_ent):
logger.debug("Refusing to convert centerline to construction")
return False
# Already construction — nothing to do, but still report success
# so the caller keeps the button in a consistent "on" state.
if getattr(line_ent, "is_construction", False):
return True
# Save state before converting
if self._undo_manager:
self._undo_manager.save_state()
line_ent.is_construction = True
logger.info(f"Converted line {line_ent.id} to construction via toolbar")
self._hovered_line = None
self._hovered_line_entity = None
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
return True
# ─── Drawing handlers ───────────────────────────────────────────────── # ─── Drawing handlers ─────────────────────────────────────────────────
def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None: def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None:
@@ -2987,15 +3215,34 @@ class Sketch2DWidget(QWidget):
if self._undo_manager: if self._undo_manager:
self._undo_manager.save_state() self._undo_manager.save_state()
new_radius = diameter / 2.0 new_radius = diameter / 2.0
# Update the circle's radius in the sketch # Update the circle's radius in the sketch. Note that
# ``self._circles`` holds (center_point_entity, radius)
# — ``c_ent`` is the CENTER point, not the circle. The
# sketch's ``_circles`` dict is keyed by circle id with
# values (center_id, radius), so we must find the circle
# entity whose center is the clicked point and route
# through ``constrain_diameter``. The old code wrote a
# bogus entry keyed by the center point id and left the
# real circle entry untouched with the OLD radius — that
# ghost circle kept forming a face and made hole cuts
# use the stale diameter.
circle_found = False
for cid, (center_id, _old_r) in self._sketch._circles.items():
if center_id == c_ent.id:
circle_ent = self._sketch._entities.get(cid)
if circle_ent is not None:
self._sketch.constrain_diameter(circle_ent, diameter)
circle_found = True
break
if not circle_found:
# Defensive fallback: keep the direct update rather
# than silently dropping the user's input.
self._sketch._circles[c_ent.id] = (c_ent.id, new_radius) self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
# Update the local cache # Update the local cache
for i, (ent, rad) in enumerate(self._circles): for i, (ent, rad) in enumerate(self._circles):
if ent.id == c_ent.id: if ent.id == c_ent.id:
self._circles[i] = (ent, new_radius) self._circles[i] = (ent, new_radius)
break break
# Record constraint for undo/redo
self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
self._solve_and_sync() self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm") logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = [] self._selected_entities = []
@@ -3285,8 +3532,9 @@ class Sketch2DWidget(QWidget):
# at the endpoints far out of view. # at the endpoints far out of view.
if self._is_centerline(entity): if self._is_centerline(entity):
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is not None:
x, y = xy
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
if entity.is_construction: if entity.is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1)) painter.setPen(QPen(QColor("#6c7086"), 1))
@@ -3316,8 +3564,10 @@ class Sketch2DWidget(QWidget):
for entity in self._points: for entity in self._points:
if not self._is_external(entity): if not self._is_external(entity):
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
continue
x, y = xy
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
painter.setPen(QPen(QColor("#fab387"), 1)) painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387"))) painter.setBrush(QBrush(QColor("#fab387")))
@@ -3352,7 +3602,13 @@ class Sketch2DWidget(QWidget):
# ── Constraint tags (log-driven; drawn upright in screen space) ── # ── Constraint tags (log-driven; drawn upright in screen space) ──
# Tags are recomputed here so paint stays in sync with the latest solve. # Tags are recomputed here so paint stays in sync with the latest solve.
# While a drawing or constraint tool is active the badges are hidden
# because their pills/extension lines routinely cover the very
# geometry the user is trying to place — they reappear as soon as
# the operation finishes, Escape is pressed, or the tool is
# otherwise deselected (see ``_is_drawing_tool_active``).
self._constraint_tags = self._compute_constraint_tags() self._constraint_tags = self._compute_constraint_tags()
if not self._is_drawing_tool_active():
tag_font = QFont("Monospace", 9) tag_font = QFont("Monospace", 9)
painter.setFont(tag_font) painter.setFont(tag_font)
for tag in self._constraint_tags: for tag in self._constraint_tags:
@@ -3366,8 +3622,11 @@ class Sketch2DWidget(QWidget):
painter.drawText(rect, Qt.AlignCenter, tag["label"]) painter.drawText(rect, Qt.AlignCenter, tag["label"])
# ── Technical dimension lines for distance constraints ── # ── Technical dimension lines for distance constraints ──
# Draw proper measurement lines (extension lines + dimension line # Draw proper measurement lines (extension lines + dimension
# with arrowheads + centred text) for every distance constraint. # line with arrowheads + centred text) for every distance
# constraint. Gated together with the badges above so the
# whole constraint overlay hides as one unit while a tool is
# active.
for tag in self._constraint_tags: for tag in self._constraint_tags:
if "p1_world" in tag and "p2_world" in tag: if "p1_world" in tag and "p2_world" in tag:
try: try:
+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,