- added "measurement lines"
This commit is contained in:
Generated
+52
-47
@@ -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" 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$/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/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.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/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/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_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,15 @@
|
|||||||
<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>
|
||||||
|
<option name="localTasksCounter" value="42" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -467,7 +472,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 +496,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>
|
||||||
@@ -1260,17 +1264,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 +1278,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 +1313,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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -668,35 +671,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)
|
||||||
@@ -909,12 +927,15 @@ 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.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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@ from fluency.models.data_model import (
|
|||||||
Body,
|
Body,
|
||||||
Component,
|
Component,
|
||||||
Connector,
|
Connector,
|
||||||
|
Feature,
|
||||||
Project,
|
Project,
|
||||||
Sketch,
|
Sketch,
|
||||||
Workplane,
|
Workplane,
|
||||||
@@ -163,6 +164,48 @@ 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": float(feat.angle),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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=data.get("id") or None,
|
||||||
|
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=float(data.get("angle", 360.0)),
|
||||||
|
)
|
||||||
|
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,6 +223,9 @@ 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],
|
||||||
@@ -337,7 +383,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
|
||||||
|
|
||||||
@@ -582,6 +643,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]] = []
|
||||||
|
|||||||
@@ -206,6 +206,46 @@ 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
|
||||||
|
- "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
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Body:
|
class Body:
|
||||||
"""
|
"""
|
||||||
@@ -222,6 +262,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-
|
||||||
|
|||||||
+471
-177
@@ -6,7 +6,7 @@ import math
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Slot, QSize, QSettings
|
from PySide6.QtCore import Qt, Slot, QSize, QSettings
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
@@ -41,7 +41,7 @@ from PySide6.QtWidgets import (
|
|||||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
from fluency.io.project_io import load_project, project_zip_path, save_project
|
from fluency.io.project_io import load_project, project_zip_path, save_project
|
||||||
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane
|
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane, Feature
|
||||||
|
|
||||||
from fluency.ui.dialogs import (
|
from fluency.ui.dialogs import (
|
||||||
ExtrudeDialog,
|
ExtrudeDialog,
|
||||||
@@ -574,6 +574,158 @@ def _offset_polygon(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parametric feature replay ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_feature_history(body: Body) -> List[Feature]:
|
||||||
|
"""Return *body*'s feature list, migrating legacy bodies lazily.
|
||||||
|
|
||||||
|
Bodies saved before feature history existed only carry the flat
|
||||||
|
``source_sketch`` / ``extrude_*`` fields describing the LAST
|
||||||
|
operation. Migration synthesizes a feature list from them:
|
||||||
|
|
||||||
|
- plain extrude → a single "extrude" feature (replays cleanly,
|
||||||
|
no information was lost);
|
||||||
|
- cut / union → a frozen "base" snapshot of the current geometry
|
||||||
|
plus the cut/union feature. The previous operation's effect
|
||||||
|
stays baked into the snapshot (a permanent scar), but every
|
||||||
|
FUTURE sketch edit re-applies cleanly instead of duplicating.
|
||||||
|
"""
|
||||||
|
if body.features:
|
||||||
|
return body.features
|
||||||
|
if body.source_sketch is None or body.extrude_length is None:
|
||||||
|
return [] # imported / baked body — nothing parametric to replay
|
||||||
|
if body.extrude_cut or body.extrude_union:
|
||||||
|
if body.geometry is not None:
|
||||||
|
body.features.append(Feature(operation="base", geometry=body.geometry))
|
||||||
|
op = "cut" if body.extrude_cut else "union"
|
||||||
|
else:
|
||||||
|
op = "extrude"
|
||||||
|
body.features.append(
|
||||||
|
Feature(
|
||||||
|
operation=op,
|
||||||
|
sketch=body.source_sketch,
|
||||||
|
length=body.extrude_length,
|
||||||
|
symmetric=body.extrude_symmetric,
|
||||||
|
invert=body.extrude_invert,
|
||||||
|
through_all=body.extrude_through_all,
|
||||||
|
cut_all_bodies=body.extrude_cut_all_bodies,
|
||||||
|
face_index=body.extrude_face_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"Body '{body.name}': migrated legacy params to feature history")
|
||||||
|
return body.features
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) -> Optional[Any]:
|
||||||
|
"""Resolve the profile geometry for a feature's sketch.
|
||||||
|
|
||||||
|
Prefers the stored selected face (which can include holes);
|
||||||
|
falls back to the whole-sketch profile when the face topology
|
||||||
|
changed or no face was selected.
|
||||||
|
"""
|
||||||
|
face_geom: Optional[Any] = None
|
||||||
|
if feat.face_index is not None:
|
||||||
|
faces = occ_sketch.detect_faces()
|
||||||
|
if 0 <= feat.face_index < len(faces):
|
||||||
|
face_geom = occ_sketch.build_face_geometry(faces[feat.face_index])
|
||||||
|
elif faces:
|
||||||
|
# Face index out of range (topology changed) — fall back
|
||||||
|
# to the first face.
|
||||||
|
face_geom = occ_sketch.build_face_geometry(faces[0])
|
||||||
|
logger.info(
|
||||||
|
f"Body '{body.name}': face index {feat.face_index} invalid, fell back to face 0"
|
||||||
|
)
|
||||||
|
if face_geom is None:
|
||||||
|
face_geom = occ_sketch.get_geometry()
|
||||||
|
return face_geom
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_body_features(
|
||||||
|
kernel: OCGeometryKernel,
|
||||||
|
body: Body,
|
||||||
|
features: List[Feature],
|
||||||
|
through_all_length_fn: Callable[[Any, Sketch], float],
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Replay *features* in order and return the resulting geometry.
|
||||||
|
|
||||||
|
Returns *None* when the replay cannot complete (missing sketch,
|
||||||
|
empty profile, failed kernel op) — the caller then keeps the
|
||||||
|
body's previous geometry.
|
||||||
|
"""
|
||||||
|
geom: Optional[Any] = None
|
||||||
|
for feat in features:
|
||||||
|
if feat.operation == "base":
|
||||||
|
geom = feat.geometry
|
||||||
|
continue
|
||||||
|
|
||||||
|
sketch = feat.sketch
|
||||||
|
if sketch is None or sketch.occ_sketch is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Body '{body.name}': {feat.operation} feature has no sketch, replay aborted"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Re-solve the sketch so geometry reflects any edits.
|
||||||
|
sketch.apply_workplane()
|
||||||
|
sketch.solve()
|
||||||
|
|
||||||
|
face_geom = _feature_face_geometry(body, feat, sketch.occ_sketch)
|
||||||
|
if face_geom is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Body '{body.name}': no profile geometry for {feat.operation}, replay aborted"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if feat.operation == "revolve":
|
||||||
|
geom = kernel.revolve(face_geom, feat.angle)
|
||||||
|
if geom is None:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# extrude / cut / union all need the extruded profile as tool.
|
||||||
|
if feat.through_all and geom is not None:
|
||||||
|
# Pass-through: size the tool against the solid built so far.
|
||||||
|
length = through_all_length_fn(geom, sketch)
|
||||||
|
symmetric = True
|
||||||
|
invert = False
|
||||||
|
elif feat.operation == "cut" and geom is not None:
|
||||||
|
# Mirror _compute_extrude_result: a cut tool must go INTO
|
||||||
|
# the solid (the picked face's outward normal points AWAY),
|
||||||
|
# so force the extrude direction inward.
|
||||||
|
length = feat.length if feat.length is not None else 10.0
|
||||||
|
symmetric = feat.symmetric
|
||||||
|
invert = True
|
||||||
|
else:
|
||||||
|
length = feat.length if feat.length is not None else 10.0
|
||||||
|
symmetric = feat.symmetric
|
||||||
|
invert = feat.invert
|
||||||
|
|
||||||
|
tool_geom = kernel.extrude(face_geom, -length if invert else length, symmetric=symmetric)
|
||||||
|
if tool_geom is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if feat.operation == "extrude":
|
||||||
|
geom = tool_geom # plain extrude: the tool IS the result
|
||||||
|
elif feat.operation == "cut":
|
||||||
|
if geom is None:
|
||||||
|
logger.warning(f"Body '{body.name}': cut feature has no base, replay aborted")
|
||||||
|
return None
|
||||||
|
geom = kernel.boolean_difference(geom, tool_geom)
|
||||||
|
elif feat.operation == "union":
|
||||||
|
if geom is None:
|
||||||
|
logger.warning(f"Body '{body.name}': union feature has no base, replay aborted")
|
||||||
|
return None
|
||||||
|
geom = kernel.boolean_union(geom, tool_geom)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Body '{body.name}': unknown feature op '{feat.operation}', skipped")
|
||||||
|
|
||||||
|
if geom is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return geom
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window."""
|
"""Main application window."""
|
||||||
|
|
||||||
@@ -1021,6 +1173,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
|
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
|
||||||
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
|
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
|
||||||
|
self._sketch_widget.solver_warning.connect(self._on_solver_warning)
|
||||||
|
|
||||||
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
|
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
|
||||||
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
|
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
|
||||||
@@ -1108,6 +1261,37 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_con_vert.setChecked(True)
|
self._btn_con_vert.setChecked(True)
|
||||||
|
|
||||||
def _on_construct_change(self, checked):
|
def _on_construct_change(self, checked):
|
||||||
|
"""Handle the "Cstrct" toolbar button.
|
||||||
|
|
||||||
|
The button is checkable, so a click both toggles the construct
|
||||||
|
mode for *new* geometry and (when the user has hovered an
|
||||||
|
existing line) promotes that line to a construction line. The
|
||||||
|
line-conversion path lets a user "select" a line by hovering
|
||||||
|
it and then press the construction button to convert it — the
|
||||||
|
button stays in the "on" state so subsequent new geometry is
|
||||||
|
also created as construction.
|
||||||
|
|
||||||
|
If no line is hovered the click is the original pure
|
||||||
|
construct-mode toggle for new geometry.
|
||||||
|
"""
|
||||||
|
# 1) Try to convert the hovered line first. This mirrors the
|
||||||
|
# existing C-key shortcut (``_toggle_hovered_line_construction``
|
||||||
|
# on the sketch widget) but is exposed publicly so the
|
||||||
|
# toolbar button can drive the same action.
|
||||||
|
converted = self._sketch_widget.convert_hovered_line_to_construction()
|
||||||
|
if converted:
|
||||||
|
# A line was promoted — ensure the button reflects the
|
||||||
|
# "construction mode is on" state regardless of what the
|
||||||
|
# user just clicked. Without this, clicking the button
|
||||||
|
# to turn it OFF while a line was hovered would silently
|
||||||
|
# re-promote that line AND drop construct mode for new
|
||||||
|
# geometry, which is confusing. Forcing the button on
|
||||||
|
# makes the action unambiguous: "make this construction".
|
||||||
|
self._btn_construct.setChecked(True)
|
||||||
|
self._sketch_widget.set_construct_mode(True)
|
||||||
|
return
|
||||||
|
# 2) No line hovered — fall back to the original behaviour:
|
||||||
|
# toggle construct mode for *new* geometry.
|
||||||
self._sketch_widget.set_construct_mode(checked)
|
self._sketch_widget.set_construct_mode(checked)
|
||||||
|
|
||||||
def _on_constrain_done(self):
|
def _on_constrain_done(self):
|
||||||
@@ -1130,12 +1314,64 @@ class MainWindow(QMainWindow):
|
|||||||
btn.setChecked(False)
|
btn.setChecked(False)
|
||||||
self._sketch_widget.set_mode(None)
|
self._sketch_widget.set_mode(None)
|
||||||
|
|
||||||
|
def _on_solver_warning(self, message: str) -> None:
|
||||||
|
"""Show solver-failure messages in the status bar.
|
||||||
|
|
||||||
|
Connected to ``Sketch2DWidget.solver_warning``, which fires when
|
||||||
|
the SolveSpace solver returns a non-OKAY result (INCONSISTENT,
|
||||||
|
DIDNT_CONVERGE, …). Without this hook the geometry would just
|
||||||
|
silently stay put and the user would think the constraint had
|
||||||
|
no effect; with the status-bar message the failure is obvious
|
||||||
|
and includes a one-line hint (e.g. "the new constraint
|
||||||
|
conflicts with existing constraints").
|
||||||
|
|
||||||
|
The message stays visible for 8 seconds — long enough to read
|
||||||
|
but short enough not to be annoying if the user fixes the
|
||||||
|
issue and continues editing.
|
||||||
|
"""
|
||||||
|
self.statusBar().showMessage(f"⚠ {message}", 8000)
|
||||||
|
|
||||||
def _on_sketch_updated(self):
|
def _on_sketch_updated(self):
|
||||||
"""Mark bodies as needing update when their source sketch changes."""
|
"""Mark bodies as needing update when their source sketch changes.
|
||||||
|
|
||||||
|
A body is marked when ANY feature in its history references the
|
||||||
|
edited sketch (the flat ``source_sketch`` only mirrors the last
|
||||||
|
feature). The marking then propagates transitively: sketches
|
||||||
|
hosted on faces of a marked body follow its geometry, so bodies
|
||||||
|
built from THOSE sketches are marked too, and so on down the
|
||||||
|
dependency chain.
|
||||||
|
"""
|
||||||
if not self._current_component or not self._current_sketch:
|
if not self._current_component or not self._current_sketch:
|
||||||
return
|
return
|
||||||
|
sketch = self._current_sketch
|
||||||
|
|
||||||
|
def _body_sketches(b: Body) -> List[Sketch]:
|
||||||
|
out = [f.sketch for f in b.features if f.sketch is not None]
|
||||||
|
if b.source_sketch is not None:
|
||||||
|
out.append(b.source_sketch)
|
||||||
|
return out
|
||||||
|
|
||||||
|
affected: set = set()
|
||||||
for body in self._current_component.bodies.values():
|
for body in self._current_component.bodies.values():
|
||||||
if body.source_sketch is self._current_sketch and body.extrude_length is not None:
|
if sketch in _body_sketches(body):
|
||||||
|
affected.add(body.id)
|
||||||
|
|
||||||
|
# Transitive closure: a sketch sitting on a face of an affected
|
||||||
|
# body moves with it → bodies using that sketch are affected too.
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for body in self._current_component.bodies.values():
|
||||||
|
if body.id in affected:
|
||||||
|
continue
|
||||||
|
for s in _body_sketches(body):
|
||||||
|
if getattr(s, "_source_body_id", None) in affected:
|
||||||
|
affected.add(body.id)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
for body in self._current_component.bodies.values():
|
||||||
|
if body.id in affected:
|
||||||
body.needs_update = True
|
body.needs_update = True
|
||||||
self._refresh_lists()
|
self._refresh_lists()
|
||||||
# Update undo/redo menu actions
|
# Update undo/redo menu actions
|
||||||
@@ -1249,15 +1485,26 @@ class MainWindow(QMainWindow):
|
|||||||
self._body_list.addItem(item)
|
self._body_list.addItem(item)
|
||||||
|
|
||||||
def _update_and_redraw(self):
|
def _update_and_redraw(self):
|
||||||
"""Full pipeline: re-extrude from sketch, redraw, propagate to assembly.
|
"""Full pipeline: rebuild bodies, redraw, propagate to assembly.
|
||||||
|
|
||||||
Connected to the 'Update Body' button. Rebuilds body geometry
|
Connected to the 'Update Body' button. Bodies are rebuilt by
|
||||||
from source sketches, refreshes the component view, propagates
|
replaying their feature history; sketches hosted on body faces
|
||||||
changes to all assembly instances, and recalculates connectors.
|
are then re-projected and solved. Because a face-sketch's
|
||||||
|
geometry depends on the body it sits on, the bodies→sketches
|
||||||
|
cycle repeats until nothing moves anymore (bounded), so changes
|
||||||
|
to sketches early in the design propagate all the way down the
|
||||||
|
dependent chain. Then the view, assembly instances and
|
||||||
|
connectors are refreshed.
|
||||||
"""
|
"""
|
||||||
self._update_bodies_from_sketch()
|
MAX_PASSES = 5
|
||||||
self._update_sketches_from_bodies()
|
for _ in range(MAX_PASSES):
|
||||||
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
|
self._update_bodies_from_sketch()
|
||||||
|
if self._update_sketches_from_bodies() == 0:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Still converging after the cap — one last body pass so the
|
||||||
|
# final geometry is built from the freshest sketch state.
|
||||||
|
self._update_bodies_from_sketch()
|
||||||
self._redraw_bodies()
|
self._redraw_bodies()
|
||||||
self._propagate_to_assembly()
|
self._propagate_to_assembly()
|
||||||
self._recalculate_connectors()
|
self._recalculate_connectors()
|
||||||
@@ -1265,155 +1512,51 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_component_thumbnail(self._get_active_component_index())
|
self._update_component_thumbnail(self._get_active_component_index())
|
||||||
|
|
||||||
def _update_bodies_from_sketch(self):
|
def _update_bodies_from_sketch(self):
|
||||||
"""Re-extrude bodies whose source sketch has been edited.
|
"""Rebuild bodies by replaying their parametric feature history.
|
||||||
|
|
||||||
For every body in the current component that has a ``source_sketch``
|
Every body with a feature list is rebuilt FROM SCRATCH: the base
|
||||||
and stored extrude parameters, re-solve the sketch and rebuild the
|
feature (extrude / revolve / snapshot) is recomputed from its
|
||||||
body geometry. Cut / union operations are re-applied against the
|
source sketch, then each cut / union re-applies in order against
|
||||||
current target body.
|
the freshly built geometry. Because the base is rebuilt clean,
|
||||||
|
a moved sketch entity REPLACES its previous effect instead of
|
||||||
|
piling on top of it (e.g. a moved circle re-cuts one hole at the
|
||||||
|
new position — the old hole is gone).
|
||||||
|
|
||||||
|
Legacy bodies without a feature list are migrated lazily (see
|
||||||
|
:meth:`_ensure_feature_history`).
|
||||||
"""
|
"""
|
||||||
if not self._current_component:
|
if not self._current_component:
|
||||||
return
|
return
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for body_id, body in list(self._current_component.bodies.items()):
|
for body_id, body in list(self._current_component.bodies.items()):
|
||||||
if body.source_sketch is None or body.extrude_length is None:
|
features = _ensure_feature_history(body)
|
||||||
continue # not a re-extrudable body
|
if not features:
|
||||||
|
continue # imported / baked body — nothing parametric
|
||||||
|
|
||||||
sketch = body.source_sketch
|
|
||||||
if not sketch.occ_sketch:
|
|
||||||
logger.debug(f"Body '{body.name}': source sketch has no OCC entity, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Re-solve the sketch so geometry reflects any edits.
|
|
||||||
sketch.apply_workplane()
|
|
||||||
sketch.solve()
|
|
||||||
|
|
||||||
# Resolve the face geometry.
|
|
||||||
face_geom = None
|
|
||||||
if body.extrude_face_index is not None:
|
|
||||||
faces = sketch.occ_sketch.detect_faces()
|
|
||||||
if 0 <= body.extrude_face_index < len(faces):
|
|
||||||
face_geom = sketch.occ_sketch.build_face_geometry(
|
|
||||||
faces[body.extrude_face_index]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Face index out of range (topology changed) — fallback
|
|
||||||
# to first face if available.
|
|
||||||
if faces:
|
|
||||||
face_geom = sketch.occ_sketch.build_face_geometry(faces[0])
|
|
||||||
logger.info(
|
|
||||||
f"Body '{body.name}': face index {body.extrude_face_index} "
|
|
||||||
f"invalid, fell back to face 0"
|
|
||||||
)
|
|
||||||
if face_geom is None:
|
|
||||||
face_geom = sketch.occ_sketch.get_geometry()
|
|
||||||
if face_geom is None:
|
|
||||||
logger.warning(f"Body '{body.name}': no geometry from sketch, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Resolve the target body for cut / union.
|
|
||||||
target = None
|
|
||||||
if body.extrude_cut or body.extrude_union:
|
|
||||||
if (
|
|
||||||
body.extrude_target_body_id
|
|
||||||
and body.extrude_target_body_id in self._current_component.bodies
|
|
||||||
):
|
|
||||||
target = self._current_component.bodies[body.extrude_target_body_id]
|
|
||||||
else:
|
|
||||||
# Fallback: first body that isn't this one.
|
|
||||||
for bid, b in self._current_component.bodies.items():
|
|
||||||
if bid != body_id and b.geometry is not None:
|
|
||||||
target = b
|
|
||||||
break
|
|
||||||
|
|
||||||
# Handle cut_all_bodies: apply the cut to every body in the
|
|
||||||
# component, not just the target.
|
|
||||||
if body.extrude_cut and body.extrude_cut_all_bodies:
|
|
||||||
try:
|
|
||||||
if body.extrude_through_all and target is not None:
|
|
||||||
cut_length = self._through_all_length(target, sketch)
|
|
||||||
cut_symmetric = True
|
|
||||||
cut_invert = False
|
|
||||||
else:
|
|
||||||
cut_length = body.extrude_length or 10.0
|
|
||||||
cut_symmetric = body.extrude_symmetric
|
|
||||||
cut_invert = body.extrude_invert
|
|
||||||
tool_geom = self._kernel.extrude(
|
|
||||||
face_geom,
|
|
||||||
-cut_length if cut_invert else cut_length,
|
|
||||||
symmetric=cut_symmetric,
|
|
||||||
)
|
|
||||||
if tool_geom is None:
|
|
||||||
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
|
|
||||||
continue
|
|
||||||
cut_count = 0
|
|
||||||
for other_id, other in list(self._current_component.bodies.items()):
|
|
||||||
if other.geometry is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
other.geometry = self._kernel.boolean_difference(
|
|
||||||
other.geometry, tool_geom
|
|
||||||
)
|
|
||||||
other.needs_update = False
|
|
||||||
other.modified_at = datetime.now()
|
|
||||||
cut_count += 1
|
|
||||||
except Exception:
|
|
||||||
pass # body doesn't intersect tool, skip
|
|
||||||
body.needs_update = False
|
|
||||||
body.modified_at = datetime.now()
|
|
||||||
updated += 1
|
|
||||||
logger.info(
|
|
||||||
f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
|
|
||||||
continue # skip the single-target path below
|
|
||||||
|
|
||||||
# Compute the new result.
|
|
||||||
try:
|
try:
|
||||||
if body.extrude_through_all and target is not None:
|
new_geom = _replay_body_features(
|
||||||
length = self._through_all_length(target, sketch)
|
self._kernel, body, features, self._through_all_length_for_geometry
|
||||||
result = self._compute_extrude_result(
|
)
|
||||||
sketch,
|
|
||||||
face_geom,
|
|
||||||
length,
|
|
||||||
symmetric=True,
|
|
||||||
invert=False,
|
|
||||||
cut=body.extrude_cut,
|
|
||||||
union=body.extrude_union,
|
|
||||||
through_all=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
length = body.extrude_length
|
|
||||||
result = self._compute_extrude_result(
|
|
||||||
sketch,
|
|
||||||
face_geom,
|
|
||||||
length,
|
|
||||||
symmetric=body.extrude_symmetric,
|
|
||||||
invert=body.extrude_invert,
|
|
||||||
cut=body.extrude_cut,
|
|
||||||
union=body.extrude_union,
|
|
||||||
through_all=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result is None or result["result_geom"] is None:
|
|
||||||
logger.warning(f"Body '{body.name}': re-extrude produced no geometry")
|
|
||||||
continue
|
|
||||||
|
|
||||||
body.geometry = result["result_geom"]
|
|
||||||
body.needs_update = False
|
|
||||||
body.modified_at = datetime.now()
|
|
||||||
updated += 1
|
|
||||||
logger.info(f"Re-extruded body: {body.name}")
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
|
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if new_geom is None:
|
||||||
|
# Replay aborted — keep the previous geometry and the
|
||||||
|
# needs_update flag so the ⚠ marker stays visible.
|
||||||
|
continue
|
||||||
|
|
||||||
|
body.geometry = new_geom
|
||||||
|
body.needs_update = False
|
||||||
|
body.modified_at = datetime.now()
|
||||||
|
updated += 1
|
||||||
|
logger.info(f"Rebuilt body from features: {body.name}")
|
||||||
|
|
||||||
if updated > 0:
|
if updated > 0:
|
||||||
logger.info(f"Updated {updated} body(ies) from sketch")
|
logger.info(f"Updated {updated} body(ies) from sketch")
|
||||||
|
|
||||||
def _update_sketches_from_bodies(self) -> None:
|
def _update_sketches_from_bodies(self) -> int:
|
||||||
"""Re-project underlay construction lines from updated 3D bodies.
|
"""Re-project underlay construction lines from updated 3D bodies.
|
||||||
|
|
||||||
For every sketch in the current component that carries a
|
For every sketch in the current component that carries a
|
||||||
@@ -1423,9 +1566,13 @@ class MainWindow(QMainWindow):
|
|||||||
external entities *in place* (preserving entity ids so existing
|
external entities *in place* (preserving entity ids so existing
|
||||||
constraints survive). The solver is re-run so any user geometry
|
constraints survive). The solver is re-run so any user geometry
|
||||||
anchored to the underlay follows the body.
|
anchored to the underlay follows the body.
|
||||||
|
|
||||||
|
Returns the number of sketches whose underlay actually moved —
|
||||||
|
the caller (``_update_and_redraw``) uses this to decide whether
|
||||||
|
another body-rebuild pass is needed.
|
||||||
"""
|
"""
|
||||||
if not self._current_component:
|
if not self._current_component:
|
||||||
return
|
return 0
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
kernel = OCGeometryKernel()
|
kernel = OCGeometryKernel()
|
||||||
@@ -1493,6 +1640,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._sketch_widget.update()
|
self._sketch_widget.update()
|
||||||
if updated > 0:
|
if updated > 0:
|
||||||
logger.info("Re-projected underlays for %d sketch(es)", updated)
|
logger.info("Re-projected underlays for %d sketch(es)", updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
def _propagate_to_assembly(self):
|
def _propagate_to_assembly(self):
|
||||||
"""Refresh all assembly instances that reference the current component.
|
"""Refresh all assembly instances that reference the current component.
|
||||||
@@ -3777,6 +3925,45 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
||||||
|
|
||||||
|
def _record_feature(
|
||||||
|
self,
|
||||||
|
body: Body,
|
||||||
|
operation: str,
|
||||||
|
sketch: Optional[Sketch],
|
||||||
|
length: Optional[float] = None,
|
||||||
|
symmetric: bool = False,
|
||||||
|
invert: bool = False,
|
||||||
|
through_all: bool = False,
|
||||||
|
cut_all_bodies: bool = False,
|
||||||
|
face_index: Optional[int] = None,
|
||||||
|
angle: float = 360.0,
|
||||||
|
) -> None:
|
||||||
|
"""Append *operation* to *body*'s parametric feature history.
|
||||||
|
|
||||||
|
If the body has no features yet but already holds geometry (a
|
||||||
|
legacy body created before feature history existed), a frozen
|
||||||
|
"base" snapshot of the CURRENT geometry is inserted first so
|
||||||
|
replays start from a known state. Call this BEFORE assigning
|
||||||
|
the new geometry onto ``body.geometry`` — the snapshot must
|
||||||
|
capture the pre-operation state.
|
||||||
|
"""
|
||||||
|
if not body.features and body.geometry is not None and operation in ("cut", "union"):
|
||||||
|
body.features.append(Feature(operation="base", geometry=body.geometry))
|
||||||
|
logger.info(f"Body '{body.name}': snapshotted legacy geometry as feature base")
|
||||||
|
body.features.append(
|
||||||
|
Feature(
|
||||||
|
operation=operation,
|
||||||
|
sketch=sketch,
|
||||||
|
length=length,
|
||||||
|
symmetric=symmetric,
|
||||||
|
invert=invert,
|
||||||
|
through_all=through_all,
|
||||||
|
cut_all_bodies=cut_all_bodies,
|
||||||
|
face_index=face_index,
|
||||||
|
angle=angle,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_extrude_target(
|
def _resolve_extrude_target(
|
||||||
self, sketch: Sketch, exclude_body: Optional[Body] = None
|
self, sketch: Sketch, exclude_body: Optional[Body] = None
|
||||||
) -> Optional[Body]:
|
) -> Optional[Body]:
|
||||||
@@ -3804,22 +3991,45 @@ class MainWindow(QMainWindow):
|
|||||||
return body
|
return body
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _find_extrude_body_for_sketch(self, sketch: Sketch) -> Optional[Body]:
|
||||||
|
"""Return the existing body that was created from *sketch* via a
|
||||||
|
plain extrude, or *None* if no such body exists.
|
||||||
|
|
||||||
|
Used by the plain-extrude path of :meth:`_extrude_sketch` to decide
|
||||||
|
between "update the existing body in place" and "create a new
|
||||||
|
body". Without this, clicking Extrude a second time on the same
|
||||||
|
sketch stacks a fresh 44mm solid on top of the original 44mm
|
||||||
|
solid and the user perceives the apparent depth as 88mm.
|
||||||
|
"""
|
||||||
|
if self._current_component is None or sketch is None:
|
||||||
|
return None
|
||||||
|
for body in self._current_component.bodies.values():
|
||||||
|
if body.source_sketch is sketch and body.source_operation == "extrude":
|
||||||
|
return body
|
||||||
|
return None
|
||||||
|
|
||||||
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
|
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
|
||||||
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
||||||
*through* the target body.
|
*through* the target body. See :meth:`_through_all_length_for_geometry`.
|
||||||
|
"""
|
||||||
|
return self._through_all_length_for_geometry(target.geometry, sketch)
|
||||||
|
|
||||||
Computes the target body's bounding-box extent along the sketch's
|
def _through_all_length_for_geometry(self, geometry: Any, sketch: Sketch) -> float:
|
||||||
workplane normal direction ("extent" = how far the body reaches on
|
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
||||||
|
*through* a body geometry.
|
||||||
|
|
||||||
|
Computes the geometry's bounding-box extent along the sketch's
|
||||||
|
workplane normal direction ("extent" = how far the solid reaches on
|
||||||
either side of the face). With ``symmetric=True`` the kernel
|
either side of the face). With ``symmetric=True`` the kernel
|
||||||
extrudes ``± height/2``, so to clear the full ``extent`` on each
|
extrudes ``± height/2``, so to clear the full ``extent`` on each
|
||||||
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
|
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
|
||||||
on each side guarantees the tool pokes out past the body so the
|
on each side guarantees the tool pokes out past the solid so the
|
||||||
boolean reliably removes the through volume.
|
boolean reliably removes the through volume.
|
||||||
"""
|
"""
|
||||||
import numpy as _np
|
import numpy as _np
|
||||||
|
|
||||||
try:
|
try:
|
||||||
p_min, p_max = self._kernel.get_bounding_box(target.geometry)
|
p_min, p_max = self._kernel.get_bounding_box(geometry)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("through-all bbox failed", exc_info=True)
|
logger.debug("through-all bbox failed", exc_info=True)
|
||||||
return 2000.0 # generous fallback if bbox fails for any reason
|
return 2000.0 # generous fallback if bbox fails for any reason
|
||||||
@@ -4087,10 +4297,23 @@ class MainWindow(QMainWindow):
|
|||||||
for body in all_targets:
|
for body in all_targets:
|
||||||
try:
|
try:
|
||||||
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
|
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
|
||||||
body.geometry = new_geom
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
|
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
|
||||||
continue
|
continue
|
||||||
|
# Record the feature BEFORE committing the geometry so
|
||||||
|
# a legacy body snapshots its pre-cut state as base.
|
||||||
|
self._record_feature(
|
||||||
|
body,
|
||||||
|
"cut",
|
||||||
|
sketch,
|
||||||
|
length=length,
|
||||||
|
symmetric=symmetric,
|
||||||
|
invert=invert,
|
||||||
|
through_all=bool(through_all),
|
||||||
|
cut_all_bodies=True,
|
||||||
|
face_index=face_index,
|
||||||
|
)
|
||||||
|
body.geometry = new_geom
|
||||||
body.extrude_length = length
|
body.extrude_length = length
|
||||||
body.extrude_symmetric = symmetric
|
body.extrude_symmetric = symmetric
|
||||||
body.extrude_invert = invert
|
body.extrude_invert = invert
|
||||||
@@ -4111,7 +4334,19 @@ class MainWindow(QMainWindow):
|
|||||||
body_name = f"{updated_count} body(ies)"
|
body_name = f"{updated_count} body(ies)"
|
||||||
elif target is not None:
|
elif target is not None:
|
||||||
# Single-body cut / union: commit the result onto the *target*
|
# Single-body cut / union: commit the result onto the *target*
|
||||||
# body in place.
|
# body in place. Record the feature BEFORE committing so a
|
||||||
|
# legacy body snapshots its pre-op geometry as base.
|
||||||
|
self._record_feature(
|
||||||
|
target,
|
||||||
|
"cut" if cut else "union",
|
||||||
|
sketch,
|
||||||
|
length=length,
|
||||||
|
symmetric=symmetric,
|
||||||
|
invert=invert,
|
||||||
|
through_all=bool(through_all),
|
||||||
|
cut_all_bodies=False,
|
||||||
|
face_index=face_index,
|
||||||
|
)
|
||||||
target.geometry = result["result_geom"]
|
target.geometry = result["result_geom"]
|
||||||
# Store extrude params so the body can be rebuilt later.
|
# Store extrude params so the body can be rebuilt later.
|
||||||
target.extrude_length = length
|
target.extrude_length = length
|
||||||
@@ -4133,30 +4368,81 @@ class MainWindow(QMainWindow):
|
|||||||
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
|
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
|
||||||
body_name = target.name
|
body_name = target.name
|
||||||
else:
|
else:
|
||||||
# Plain extrude: create a new body for the extrusion.
|
# Plain extrude. If this sketch already produced an
|
||||||
body = self._current_component.add_body(
|
# existing extrude body, UPDATE that body in place rather
|
||||||
Body(
|
# than stacking a fresh 44mm solid on top of the old one
|
||||||
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
|
# — which the user perceives as "44mm looks like 88mm"
|
||||||
geometry=result["result_geom"],
|
# because the two coincident solids visually sum.
|
||||||
source_sketch=sketch,
|
existing = self._find_extrude_body_for_sketch(sketch)
|
||||||
source_operation="extrude",
|
if existing is not None:
|
||||||
extrude_length=length,
|
body = existing
|
||||||
extrude_symmetric=symmetric,
|
# Record the new feature BEFORE replacing geometry so
|
||||||
extrude_invert=invert,
|
# the parametric history reflects the latest op.
|
||||||
extrude_cut=False,
|
self._record_feature(
|
||||||
extrude_union=False,
|
body,
|
||||||
extrude_through_all=bool(through_all),
|
"extrude",
|
||||||
extrude_cut_all_bodies=False,
|
sketch,
|
||||||
extrude_face_index=face_index,
|
length=length,
|
||||||
|
symmetric=symmetric,
|
||||||
|
invert=invert,
|
||||||
|
through_all=bool(through_all),
|
||||||
|
face_index=face_index,
|
||||||
)
|
)
|
||||||
)
|
body.geometry = result["result_geom"]
|
||||||
self._mark_dirty()
|
body.extrude_length = length
|
||||||
logger.info(f"Created body: {body.name}")
|
body.extrude_symmetric = symmetric
|
||||||
logger.debug("Adding shape to OCC viewer")
|
body.extrude_invert = invert
|
||||||
shape = self._kernel._get_shape(body.geometry)
|
body.extrude_cut = False
|
||||||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
body.extrude_union = False
|
||||||
logger.info(f"Render object: {body.render_object}")
|
body.extrude_through_all = bool(through_all)
|
||||||
body_name = body.name
|
body.extrude_cut_all_bodies = False
|
||||||
|
body.extrude_face_index = face_index
|
||||||
|
body.source_sketch = sketch
|
||||||
|
body.source_operation = "extrude"
|
||||||
|
body.extrude_target_body_id = None
|
||||||
|
self._mark_dirty()
|
||||||
|
logger.info(f"Updated existing body in place: {body.name}")
|
||||||
|
if body.render_object is not None:
|
||||||
|
self._viewer_3d.remove_mesh(body.render_object)
|
||||||
|
shape = self._kernel._get_shape(body.geometry)
|
||||||
|
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||||
|
body_name = body.name
|
||||||
|
else:
|
||||||
|
# Plain extrude: create a new body for the extrusion.
|
||||||
|
body = self._current_component.add_body(
|
||||||
|
Body(
|
||||||
|
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
|
||||||
|
geometry=result["result_geom"],
|
||||||
|
source_sketch=sketch,
|
||||||
|
source_operation="extrude",
|
||||||
|
extrude_length=length,
|
||||||
|
extrude_symmetric=symmetric,
|
||||||
|
extrude_invert=invert,
|
||||||
|
extrude_cut=False,
|
||||||
|
extrude_union=False,
|
||||||
|
extrude_through_all=bool(through_all),
|
||||||
|
extrude_cut_all_bodies=False,
|
||||||
|
extrude_face_index=face_index,
|
||||||
|
features=[
|
||||||
|
Feature(
|
||||||
|
operation="extrude",
|
||||||
|
sketch=sketch,
|
||||||
|
length=length,
|
||||||
|
symmetric=symmetric,
|
||||||
|
invert=invert,
|
||||||
|
through_all=bool(through_all),
|
||||||
|
face_index=face_index,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._mark_dirty()
|
||||||
|
logger.info(f"Created body: {body.name}")
|
||||||
|
logger.debug("Adding shape to OCC viewer")
|
||||||
|
shape = self._kernel._get_shape(body.geometry)
|
||||||
|
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||||
|
logger.info(f"Render object: {body.render_object}")
|
||||||
|
body_name = body.name
|
||||||
|
|
||||||
self._refresh_lists()
|
self._refresh_lists()
|
||||||
self._update_component_thumbnail(self._get_active_component_index())
|
self._update_component_thumbnail(self._get_active_component_index())
|
||||||
@@ -4210,6 +4496,14 @@ class MainWindow(QMainWindow):
|
|||||||
geometry=body_geometry,
|
geometry=body_geometry,
|
||||||
source_sketch=sketch,
|
source_sketch=sketch,
|
||||||
source_operation="revolve",
|
source_operation="revolve",
|
||||||
|
features=[
|
||||||
|
Feature(
|
||||||
|
operation="revolve",
|
||||||
|
sketch=sketch,
|
||||||
|
angle=angle,
|
||||||
|
face_index=self._sketch_widget.get_selected_face_index(),
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
|
|||||||
+328
-69
@@ -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,16 +771,16 @@ 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:
|
||||||
point = QPoint(int(round(x)), int(round(y)))
|
continue
|
||||||
screen_point = self._world_to_screen(point)
|
x, y = xy
|
||||||
dist = math.sqrt(
|
point = QPoint(int(round(x)), int(round(y)))
|
||||||
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
|
screen_point = self._world_to_screen(point)
|
||||||
)
|
dist = math.sqrt((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
|
||||||
return nearest
|
return nearest
|
||||||
|
|
||||||
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
||||||
@@ -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,11 +1059,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:
|
||||||
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
continue
|
||||||
if dist < self._pick_tolerance_world(10):
|
x, y = xy
|
||||||
return entity
|
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
||||||
|
if dist < self._pick_tolerance_world(10):
|
||||||
|
return entity
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_line_entity_at(
|
def _get_line_entity_at(
|
||||||
@@ -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._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
|
# ``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)
|
||||||
# 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,12 +3564,14 @@ 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:
|
||||||
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
continue
|
||||||
painter.setPen(QPen(QColor("#fab387"), 1))
|
x, y = xy
|
||||||
painter.setBrush(QBrush(QColor("#fab387")))
|
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||||||
painter.drawEllipse(screen_pos, 4, 4)
|
painter.setPen(QPen(QColor("#fab387"), 1))
|
||||||
|
painter.setBrush(QBrush(QColor("#fab387")))
|
||||||
|
painter.drawEllipse(screen_pos, 4, 4)
|
||||||
|
|
||||||
# ── Lines ──
|
# ── Lines ──
|
||||||
for p1_ent, p2_ent in self._lines:
|
for p1_ent, p2_ent in self._lines:
|
||||||
@@ -3352,35 +3602,44 @@ 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()
|
||||||
tag_font = QFont("Monospace", 9)
|
if not self._is_drawing_tool_active():
|
||||||
painter.setFont(tag_font)
|
tag_font = QFont("Monospace", 9)
|
||||||
for tag in self._constraint_tags:
|
painter.setFont(tag_font)
|
||||||
rect: QRect = tag["rect"]
|
for tag in self._constraint_tags:
|
||||||
hovered = tag["idx"] == self._hovered_constraint_idx
|
rect: QRect = tag["rect"]
|
||||||
# Background pill so the label is readable over the sketch.
|
hovered = tag["idx"] == self._hovered_constraint_idx
|
||||||
painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
|
# Background pill so the label is readable over the sketch.
|
||||||
painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
|
painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
|
||||||
painter.drawRoundedRect(rect, 6, 6)
|
painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
|
||||||
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
|
painter.drawRoundedRect(rect, 6, 6)
|
||||||
painter.drawText(rect, Qt.AlignCenter, tag["label"])
|
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
|
||||||
|
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
|
||||||
for tag in self._constraint_tags:
|
# constraint. Gated together with the badges above so the
|
||||||
if "p1_world" in tag and "p2_world" in tag:
|
# whole constraint overlay hides as one unit while a tool is
|
||||||
try:
|
# active.
|
||||||
self._draw_technical_dimension(
|
for tag in self._constraint_tags:
|
||||||
painter,
|
if "p1_world" in tag and "p2_world" in tag:
|
||||||
tag["p1_world"],
|
try:
|
||||||
tag["p2_world"],
|
self._draw_technical_dimension(
|
||||||
tag["distance"],
|
painter,
|
||||||
)
|
tag["p1_world"],
|
||||||
except Exception:
|
tag["p2_world"],
|
||||||
import traceback
|
tag["distance"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# ── Circles ──
|
# ── Circles ──
|
||||||
for center_ent, radius in self._circles:
|
for center_ent, radius in self._circles:
|
||||||
|
|||||||
Reference in New Issue
Block a user