Compare commits
2 Commits
0daa6152ee
...
48042659fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 48042659fc | |||
| baa2fd5d47 |
Generated
+62
-49
@@ -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 - improved line pickability.">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added "measurement lines"">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.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": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.base.executor": "Run",
|
"Python.base.executor": "Run",
|
||||||
"Python.data_model.executor": "Run",
|
"Python.data_model.executor": "Run",
|
||||||
"Python.debug_dragging.executor": "Run",
|
"Python.debug_dragging.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.gui_ui.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.main_window.executor": "Run",
|
"Python.main_window.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.occ_to_mesh.executor": "Run",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"Python.render_backend.executor": "Run",
|
"Python.render_backend.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.sketch.executor": "Run",
|
"Python.sketch.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
||||||
}
|
}
|
||||||
}]]></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 "measurement lines"">
|
||||||
|
<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 "measurement lines"">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785697123545</created>
|
||||||
|
<option name="number" value="00042" />
|
||||||
|
<option name="presentableId" value="LOCAL-00042" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785697123545</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="43" />
|
||||||
<servers />
|
<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 - Added undo" />
|
<MESSAGE value="- added renderer - 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 "measurement lines"" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="- added "measurement lines"" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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-
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
+1019
-149
File diff suppressed because it is too large
Load Diff
+291
-32
@@ -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 centre→start 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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user