Compare commits
2 Commits
1e9b0cab1c
...
655df71cd8
| Author | SHA1 | Date | |
|---|---|---|---|
| 655df71cd8 | |||
| 7072dcee08 |
Generated
+58
-49
@@ -8,18 +8,11 @@
|
|||||||
<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/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/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/gui_ui.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$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -56,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">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"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",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
|
||||||
"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" />
|
||||||
@@ -104,11 +97,11 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
<recent name="$PROJECT_DIR$/modules" />
|
|
||||||
</key>
|
</key>
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
@@ -482,7 +475,23 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1785915859137</updated>
|
<updated>1785915859137</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="45" />
|
<task id="LOCAL-00045" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785941246695</created>
|
||||||
|
<option name="number" value="00045" />
|
||||||
|
<option name="presentableId" value="LOCAL-00045" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785941246695</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00046" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785948828454</created>
|
||||||
|
<option name="number" value="00046" />
|
||||||
|
<option name="presentableId" value="LOCAL-00046" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785948828454</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="47" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|||||||
@@ -1170,41 +1170,27 @@
|
|||||||
<string>Modify</string>
|
<string>Modify</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QGridLayout" name="gridLayout_3">
|
<layout class="QGridLayout" name="gridLayout_3">
|
||||||
<item row="1" column="0">
|
<item row="2" column="1">
|
||||||
<widget class="QPushButton" name="pb_combop">
|
<widget class="QPushButton" name="pb_chamfer_op">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Comb</string>
|
<string>Chamfer</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="2" column="0">
|
<item row="2" column="0">
|
||||||
<widget class="QPushButton" name="pb_arrayop">
|
|
||||||
<property name="text">
|
|
||||||
<string>Array</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</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">
|
<widget class="QPushButton" name="pb_fillet_op">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Fillet</string>
|
<string>Fillet</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="6" column="0">
|
||||||
|
<widget class="QPushButton" name="pb_thread">
|
||||||
|
<property name="text">
|
||||||
|
<string>Thread</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QPushButton" name="pb_extrdop">
|
<widget class="QPushButton" name="pb_extrdop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
@@ -1212,6 +1198,13 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="4" column="1">
|
||||||
|
<widget class="QPushButton" name="pb_revop">
|
||||||
|
<property name="text">
|
||||||
|
<string>Rev</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">
|
||||||
@@ -1219,17 +1212,31 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="3" column="1">
|
<item row="4" column="0">
|
||||||
<widget class="QPushButton" name="pb_chamfer">
|
<widget class="QPushButton" name="pb_arrayop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Chamfer</string>
|
<string>Arry</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="4" column="0">
|
<item row="1" column="0">
|
||||||
<widget class="QPushButton" name="pb_thread">
|
<widget class="QPushButton" name="pb_combop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Thread</string>
|
<string>Comb</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="QPushButton" name="pb_moveop">
|
||||||
|
<property name="text">
|
||||||
|
<string>Mve</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="6" column="1">
|
||||||
|
<widget class="QPushButton" name="pb_mirror_op">
|
||||||
|
<property name="text">
|
||||||
|
<string>Mirror</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -628,50 +628,55 @@ 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_combop = QPushButton(self.groupBox)
|
self.pb_chamfer_op = QPushButton(self.groupBox)
|
||||||
self.pb_combop.setObjectName(u"pb_combop")
|
self.pb_chamfer_op.setObjectName(u"pb_chamfer_op")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
|
||||||
|
|
||||||
self.pb_arrayop = QPushButton(self.groupBox)
|
|
||||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
|
||||||
|
|
||||||
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 = QPushButton(self.groupBox)
|
||||||
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
|
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_fillet_op, 3, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_fillet_op, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_thread = QPushButton(self.groupBox)
|
||||||
|
self.pb_thread.setObjectName(u"pb_thread")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_thread, 6, 0, 1, 1)
|
||||||
|
|
||||||
self.pb_extrdop = QPushButton(self.groupBox)
|
self.pb_extrdop = QPushButton(self.groupBox)
|
||||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 4, 1, 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_chamfer = QPushButton(self.groupBox)
|
self.pb_arrayop = QPushButton(self.groupBox)
|
||||||
self.pb_chamfer.setObjectName(u"pb_chamfer")
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_chamfer, 3, 1, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_arrayop, 4, 0, 1, 1)
|
||||||
|
|
||||||
self.pb_thread = QPushButton(self.groupBox)
|
self.pb_combop = QPushButton(self.groupBox)
|
||||||
self.pb_thread.setObjectName(u"pb_thread")
|
self.pb_combop.setObjectName(u"pb_combop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_thread, 4, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_combop, 1, 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_mirror_op = QPushButton(self.groupBox)
|
||||||
|
self.pb_mirror_op.setObjectName(u"pb_mirror_op")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_mirror_op, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
@@ -922,15 +927,16 @@ 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_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
self.pb_chamfer_op.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
|
||||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", 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_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_chamfer.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
|
|
||||||
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.pb_mirror_op.setText(QCoreApplication.translate("fluencyCAD", u"Mirror", None))
|
||||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
||||||
|
|||||||
@@ -952,6 +952,16 @@ class OCCSketch(SketchInterface):
|
|||||||
elif ctype == "distance":
|
elif ctype == "distance":
|
||||||
if h(ids[0]) is None or h(ids[1]) is None:
|
if h(ids[0]) is None or h(ids[1]) is None:
|
||||||
return False
|
return False
|
||||||
|
ent0 = self._entities.get(ids[0])
|
||||||
|
ent1 = self._entities.get(ids[1])
|
||||||
|
# Normalise (line, point) -> (point, line) like constrain_distance
|
||||||
|
# does, and drop legacy line-line entries the solver can't hold.
|
||||||
|
if ent0 is not None and ent1 is not None:
|
||||||
|
if ent0.entity_type == "line" and ent1.entity_type == "line":
|
||||||
|
return False
|
||||||
|
if ent0.entity_type == "line" and ent1.entity_type == "point":
|
||||||
|
self._solver.distance(h(ids[1]), h(ids[0]), params[0], self._wp)
|
||||||
|
else:
|
||||||
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
|
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
|
||||||
elif ctype == "angle":
|
elif ctype == "angle":
|
||||||
if h(ids[0]) is None or h(ids[1]) is None:
|
if h(ids[0]) is None or h(ids[1]) is None:
|
||||||
@@ -1175,16 +1185,92 @@ class OCCSketch(SketchInterface):
|
|||||||
entity.constraints.append("vrt")
|
entity.constraints.append("vrt")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _point_line_signed_offset(
|
||||||
|
self, point_ent: OCCSketchEntity, line_ent: OCCSketchEntity
|
||||||
|
) -> float:
|
||||||
|
"""Signed perpendicular offset of *point_ent* from *line_ent*, in the
|
||||||
|
solver's sign convention — i.e. the ``valA`` that pins the point on
|
||||||
|
its current side of the line.
|
||||||
|
|
||||||
|
Mirrors SolveSpace's ``PT_LINE_DISTANCE`` equation exactly
|
||||||
|
(a = line start, b = line end, d = a - b):
|
||||||
|
|
||||||
|
proj = dv·(ua − u) − du·(va − v), m = |d|
|
||||||
|
offset = proj / m
|
||||||
|
|
||||||
|
Returns 0.0 when the line is degenerate or the point lies on it
|
||||||
|
(the side is then undefined — callers fall back to the unsigned
|
||||||
|
value).
|
||||||
|
"""
|
||||||
|
if line_ent.id not in self._lines:
|
||||||
|
return 0.0
|
||||||
|
sid, eid2 = self._lines[line_ent.id]
|
||||||
|
s_ent = self._entities.get(sid)
|
||||||
|
e_ent = self._entities.get(eid2)
|
||||||
|
if s_ent is None or e_ent is None or not s_ent.geometry or not e_ent.geometry:
|
||||||
|
return 0.0
|
||||||
|
ua, va = s_ent.geometry
|
||||||
|
ub, vb = e_ent.geometry
|
||||||
|
u, v = point_ent.geometry
|
||||||
|
du = ua - ub
|
||||||
|
dv = va - vb
|
||||||
|
m = math.hypot(du, dv)
|
||||||
|
if m < 1e-12:
|
||||||
|
return 0.0
|
||||||
|
return (dv * (ua - u) - du * (va - v)) / m
|
||||||
|
|
||||||
def constrain_distance(
|
def constrain_distance(
|
||||||
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Constrain distance between two entities."""
|
"""Constrain distance between two entities.
|
||||||
|
|
||||||
|
python-solvespace's ``distance`` accepts point-point and point-line
|
||||||
|
(in that order) only, so line-point pairs are normalised and line-line
|
||||||
|
pairs are rejected with a warning instead of raising TypeError.
|
||||||
|
|
||||||
|
A point constrained to itself with a non-zero value is always
|
||||||
|
inconsistent — reject it up front so the UI never creates a constraint
|
||||||
|
the solver cannot satisfy.
|
||||||
|
"""
|
||||||
e1 = self._entities.get(entity1.id)
|
e1 = self._entities.get(entity1.id)
|
||||||
e2 = self._entities.get(entity2.id)
|
e2 = self._entities.get(entity2.id)
|
||||||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||||||
return False
|
return False
|
||||||
self._solver.distance(e1.handle, e2.handle, distance, self._wp)
|
if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
|
||||||
self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
|
logger.warning("distance: refusing point-to-itself constraint")
|
||||||
|
return False
|
||||||
|
|
||||||
|
t1, t2 = e1.entity_type, e2.entity_type
|
||||||
|
if t1 == "line" and t2 == "line":
|
||||||
|
logger.warning(
|
||||||
|
"distance: line-to-line distance is not supported by the solver "
|
||||||
|
"(select a point and a line instead)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# python-solvespace only accepts (point, line) ordering.
|
||||||
|
if t1 == "line" and t2 == "point":
|
||||||
|
e1, e2 = e2, e1
|
||||||
|
entity1, entity2 = entity2, entity1
|
||||||
|
|
||||||
|
# Point-to-line distance is SIGNED in SolveSpace: the constraint
|
||||||
|
# equation is (signed perpendicular offset) = valA, so a positive
|
||||||
|
# valA always drives the point onto one fixed side of the line
|
||||||
|
# (for a vertical line, always +x). Pass a value whose sign
|
||||||
|
# matches the side the point is currently on so the constraint
|
||||||
|
# pins it there instead of flipping it across the line. The sign
|
||||||
|
# is recorded with the value so a solver rebuild reproduces the
|
||||||
|
# same side.
|
||||||
|
solver_value = distance
|
||||||
|
if e2.entity_type == "line" and e1.entity_type == "point" and distance != 0.0:
|
||||||
|
signed = self._point_line_signed_offset(e1, e2)
|
||||||
|
if abs(signed) > 1e-9:
|
||||||
|
solver_value = math.copysign(distance, signed)
|
||||||
|
|
||||||
|
self._solver.distance(e1.handle, e2.handle, solver_value, self._wp)
|
||||||
|
# Record in the normalised (point-first) order so replayed / legacy
|
||||||
|
# constraint logs are consistent.
|
||||||
|
self._record_constraint("distance", (entity1.id, entity2.id), (solver_value,))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
|
|||||||
"count": feat.count,
|
"count": feat.count,
|
||||||
"spacing": feat.spacing,
|
"spacing": feat.spacing,
|
||||||
"direction": [float(v) for v in (feat.direction or (1, 0, 0))],
|
"direction": [float(v) for v in (feat.direction or (1, 0, 0))],
|
||||||
|
"mirror_plane_origin": [float(v) for v in (feat.mirror_plane_origin or (0.0, 0.0, 0.0))],
|
||||||
|
"mirror_plane_normal": [float(v) for v in (feat.mirror_plane_normal or (1, 0, 0))],
|
||||||
|
"keep_original": bool(feat.keep_original),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -257,6 +260,9 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
|
|||||||
count=int(data.get("count") or 2),
|
count=int(data.get("count") or 2),
|
||||||
spacing=_to_float(data.get("spacing"), 10.0),
|
spacing=_to_float(data.get("spacing"), 10.0),
|
||||||
direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
|
direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
|
||||||
|
mirror_plane_origin=tuple(float(v) for v in (data.get("mirror_plane_origin") or (0.0, 0.0, 0.0))),
|
||||||
|
mirror_plane_normal=tuple(float(v) for v in (data.get("mirror_plane_normal") or (1, 0, 0))),
|
||||||
|
keep_original=bool(data.get("keep_original", True)),
|
||||||
)
|
)
|
||||||
sid = data.get("sketch_id")
|
sid = data.get("sketch_id")
|
||||||
if sid and sid in sketches:
|
if sid and sid in sketches:
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ class Feature:
|
|||||||
- "chamfer": bevel a set of edges of the running geometry
|
- "chamfer": bevel a set of edges of the running geometry
|
||||||
(same fields as "fillet"; ``radius`` holds the
|
(same fields as "fillet"; ``radius`` holds the
|
||||||
chamfer size)
|
chamfer size)
|
||||||
|
- "mirror": mirror the running solid across a plane defined by
|
||||||
|
``mirror_plane_origin`` and ``mirror_plane_normal``.
|
||||||
|
When ``keep_original`` is True (the default), the
|
||||||
|
original solid is boolean-union'd with its mirror
|
||||||
- "array" / "pattern": repeat the running solid in a linear or
|
- "array" / "pattern": repeat the running solid in a linear or
|
||||||
circular array. ``pattern_type`` is "linear" or
|
circular array. ``pattern_type`` is "linear" or
|
||||||
"circular"; ``count`` is the total number of items
|
"circular"; ``count`` is the total number of items
|
||||||
@@ -281,6 +285,11 @@ class Feature:
|
|||||||
spacing: float = 10.0
|
spacing: float = 10.0
|
||||||
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# "mirror" feature only: mirror plane definition.
|
||||||
|
mirror_plane_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
mirror_plane_normal: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
keep_original: bool = True
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
|
|||||||
def constrain_distance(
|
def constrain_distance(
|
||||||
self, entity_a, entity_b, distance: float
|
self, entity_a, entity_b, distance: float
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Constrain distance between point-point or point-line."""
|
"""Constrain distance between point-point or point-line.
|
||||||
|
|
||||||
|
python-solvespace's ``distance`` accepts (point, line) ordering only,
|
||||||
|
so line-point pairs are normalised; line-line pairs and a point
|
||||||
|
constrained to itself are rejected (the solver cannot hold them).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
|
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||||
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
|
logger.warning("distance: line-to-line distance is not supported")
|
||||||
|
return False
|
||||||
|
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
# Normalise to (point, line) ordering.
|
||||||
|
entity_a, entity_b = entity_b, entity_a
|
||||||
|
if entity_a is entity_b and distance != 0.0:
|
||||||
|
logger.warning("distance: refusing point-to-itself constraint")
|
||||||
|
return False
|
||||||
|
handle_a = entity_a.handle
|
||||||
|
handle_b = entity_b.handle
|
||||||
|
|
||||||
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
|
||||||
self.distance(handle_b, handle_a, distance, self.wp)
|
|
||||||
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -641,6 +641,246 @@ class ArrayDialog(QDialog):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MirrorDialog(QDialog):
|
||||||
|
"""Dialog for the mirror tool: plane selection with live preview.
|
||||||
|
|
||||||
|
Mirrors the selected body across a plane. The user can pick a
|
||||||
|
plane from:
|
||||||
|
|
||||||
|
- A list of existing workplanes (combo box, passed by the caller)
|
||||||
|
- Six axis-aligned presets (XY, XZ, YZ and their negatives)
|
||||||
|
- Custom origin + normal enterable through spinboxes
|
||||||
|
|
||||||
|
``keep_original`` (checked by default) boolean-unions the original
|
||||||
|
solid with its mirror image so you get a symmetric part from half
|
||||||
|
the modeling work.
|
||||||
|
|
||||||
|
A live-preview callback (``set_preview_callback``) fires on every
|
||||||
|
change so the host can show the mirrored shape in the 3D view
|
||||||
|
before committing.
|
||||||
|
|
||||||
|
``get_values()`` returns a dict:
|
||||||
|
``{"mirror_plane_origin", "mirror_plane_normal", "keep_original"}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: Optional[QWidget] = None,
|
||||||
|
workplanes: Optional[list] = None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Mirror Options")
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
|
||||||
|
self._preview_callback: Optional[Callable[[Any], None]] = None
|
||||||
|
self._workplanes: list = workplanes if workplanes is not None else []
|
||||||
|
|
||||||
|
# Preset plane definitions: (label, normal, origin)
|
||||||
|
self._presets: list = [
|
||||||
|
("XY (Top)", (0, 0, 1), (0, 0, 0)),
|
||||||
|
("XZ (Front)", (0, 1, 0), (0, 0, 0)),
|
||||||
|
("YZ (Right)", (1, 0, 0), (0, 0, 0)),
|
||||||
|
("-XY (Bottom)", (0, 0, -1), (0, 0, 0)),
|
||||||
|
("-XZ (Back)", (0, -1, 0), (0, 0, 0)),
|
||||||
|
("-YZ (Left)", (-1, 0, 0), (0, 0, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ── Plane source ──
|
||||||
|
src_layout = QHBoxLayout()
|
||||||
|
src_layout.addWidget(QLabel("Mirror plane:"))
|
||||||
|
self._wp_combo = QComboBox()
|
||||||
|
if self._workplanes:
|
||||||
|
for name, _origin, _normal in self._workplanes:
|
||||||
|
self._wp_combo.addItem(name)
|
||||||
|
else:
|
||||||
|
self._wp_combo.addItem("(no workplanes)")
|
||||||
|
self._wp_combo.setEnabled(False)
|
||||||
|
self._wp_combo.setToolTip("Pick an existing workplane as the mirror plane.")
|
||||||
|
src_layout.addWidget(self._wp_combo, 1)
|
||||||
|
layout.addLayout(src_layout)
|
||||||
|
|
||||||
|
# ── Preset radio buttons ──
|
||||||
|
preset_label = QLabel("Preset planes:")
|
||||||
|
layout.addWidget(preset_label)
|
||||||
|
|
||||||
|
self._preset_group = QButtonGroup(self)
|
||||||
|
preset_grid = QGridLayout()
|
||||||
|
for idx, (label, normal, origin) in enumerate(self._presets):
|
||||||
|
btn = QRadioButton(label)
|
||||||
|
btn.setChecked(idx == 0) # XY default
|
||||||
|
self._preset_group.addButton(btn, idx)
|
||||||
|
preset_grid.addWidget(btn, idx // 2, idx % 2)
|
||||||
|
layout.addLayout(preset_grid)
|
||||||
|
|
||||||
|
# ── Custom origin ──
|
||||||
|
line1 = QFrame()
|
||||||
|
line1.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line1.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
layout.addWidget(line1)
|
||||||
|
|
||||||
|
origin_label = QLabel("Plane origin (mm):")
|
||||||
|
layout.addWidget(origin_label)
|
||||||
|
origin_row = QHBoxLayout()
|
||||||
|
origin_row.addWidget(QLabel("X:"))
|
||||||
|
self._origin_x = QDoubleSpinBox()
|
||||||
|
self._origin_x.setRange(-100000.0, 100000.0)
|
||||||
|
self._origin_x.setDecimals(2)
|
||||||
|
self._origin_x.setValue(0.0)
|
||||||
|
origin_row.addWidget(self._origin_x)
|
||||||
|
origin_row.addWidget(QLabel("Y:"))
|
||||||
|
self._origin_y = QDoubleSpinBox()
|
||||||
|
self._origin_y.setRange(-100000.0, 100000.0)
|
||||||
|
self._origin_y.setDecimals(2)
|
||||||
|
self._origin_y.setValue(0.0)
|
||||||
|
origin_row.addWidget(self._origin_y)
|
||||||
|
origin_row.addWidget(QLabel("Z:"))
|
||||||
|
self._origin_z = QDoubleSpinBox()
|
||||||
|
self._origin_z.setRange(-100000.0, 100000.0)
|
||||||
|
self._origin_z.setDecimals(2)
|
||||||
|
self._origin_z.setValue(0.0)
|
||||||
|
origin_row.addWidget(self._origin_z)
|
||||||
|
layout.addLayout(origin_row)
|
||||||
|
|
||||||
|
# ── Custom normal ──
|
||||||
|
normal_label = QLabel("Plane normal:")
|
||||||
|
layout.addWidget(normal_label)
|
||||||
|
normal_row = QHBoxLayout()
|
||||||
|
normal_row.addWidget(QLabel("X:"))
|
||||||
|
self._normal_x = QDoubleSpinBox()
|
||||||
|
self._normal_x.setRange(-1.0, 1.0)
|
||||||
|
self._normal_x.setSingleStep(1.0)
|
||||||
|
self._normal_x.setDecimals(0)
|
||||||
|
self._normal_x.setValue(0.0)
|
||||||
|
normal_row.addWidget(self._normal_x)
|
||||||
|
normal_row.addWidget(QLabel("Y:"))
|
||||||
|
self._normal_y = QDoubleSpinBox()
|
||||||
|
self._normal_y.setRange(-1.0, 1.0)
|
||||||
|
self._normal_y.setSingleStep(1.0)
|
||||||
|
self._normal_y.setDecimals(0)
|
||||||
|
self._normal_y.setValue(0.0)
|
||||||
|
normal_row.addWidget(self._normal_y)
|
||||||
|
normal_row.addWidget(QLabel("Z:"))
|
||||||
|
self._normal_z = QDoubleSpinBox()
|
||||||
|
self._normal_z.setRange(-1.0, 1.0)
|
||||||
|
self._normal_z.setSingleStep(1.0)
|
||||||
|
self._normal_z.setDecimals(0)
|
||||||
|
self._normal_z.setValue(1.0)
|
||||||
|
normal_row.addWidget(self._normal_z)
|
||||||
|
layout.addLayout(normal_row)
|
||||||
|
|
||||||
|
# ── Keep original ──
|
||||||
|
self._keep_original_cb = QCheckBox("Keep original")
|
||||||
|
self._keep_original_cb.setChecked(True)
|
||||||
|
self._keep_original_cb.setToolTip(
|
||||||
|
"When checked, the original solid is kept and union'd with its mirror image."
|
||||||
|
)
|
||||||
|
layout.addWidget(self._keep_original_cb)
|
||||||
|
|
||||||
|
# ── Buttons ──
|
||||||
|
line2 = QFrame()
|
||||||
|
line2.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
layout.addWidget(line2)
|
||||||
|
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
ok_btn = QPushButton("Mirror")
|
||||||
|
ok_btn.clicked.connect(self.accept)
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_layout.addWidget(ok_btn)
|
||||||
|
btn_layout.addWidget(cancel_btn)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
# ── Wire signals ──
|
||||||
|
self._wp_combo.currentIndexChanged.connect(self._on_workplane_selected)
|
||||||
|
self._preset_group.buttonClicked.connect(self._on_preset_changed)
|
||||||
|
for w in (self._origin_x, self._origin_y, self._origin_z,
|
||||||
|
self._normal_x, self._normal_y, self._normal_z):
|
||||||
|
w.valueChanged.connect(self._emit_preview)
|
||||||
|
self._keep_original_cb.toggled.connect(self._emit_preview)
|
||||||
|
|
||||||
|
# Set initial values from the first preset (XY)
|
||||||
|
self._on_preset_changed(self._preset_group.button(0))
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_workplane_selected(self, index: int) -> None:
|
||||||
|
"""Fill origin / normal from the selected workplane."""
|
||||||
|
if index < 0 or index >= len(self._workplanes):
|
||||||
|
return
|
||||||
|
_name, origin, normal = self._workplanes[index]
|
||||||
|
self._origin_x.setValue(float(origin[0]))
|
||||||
|
self._origin_y.setValue(float(origin[1]))
|
||||||
|
self._origin_z.setValue(float(origin[2]))
|
||||||
|
self._normal_x.setValue(float(normal[0]))
|
||||||
|
self._normal_y.setValue(float(normal[1]))
|
||||||
|
self._normal_z.setValue(float(normal[2]))
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _on_preset_changed(self, btn: QRadioButton) -> None:
|
||||||
|
"""Fill origin / normal from the selected preset."""
|
||||||
|
idx = self._preset_group.id(btn)
|
||||||
|
if idx < 0 or idx >= len(self._presets):
|
||||||
|
return
|
||||||
|
_label, normal, origin = self._presets[idx]
|
||||||
|
self._origin_x.setValue(float(origin[0]))
|
||||||
|
self._origin_y.setValue(float(origin[1]))
|
||||||
|
self._origin_z.setValue(float(origin[2]))
|
||||||
|
self._normal_x.setValue(float(normal[0]))
|
||||||
|
self._normal_y.setValue(float(normal[1]))
|
||||||
|
self._normal_z.setValue(float(normal[2]))
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self) -> None:
|
||||||
|
"""Fire the preview callback (if installed) with current values."""
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
values = self.get_values()
|
||||||
|
self._preview_callback(values)
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
|
||||||
|
"""Install a callback for live 3D preview of the mirrored shape.
|
||||||
|
|
||||||
|
The callback receives a ``dict`` with keys
|
||||||
|
``mirror_plane_origin``, ``mirror_plane_normal``, and
|
||||||
|
``keep_original``, or *None* to clear the preview.
|
||||||
|
"""
|
||||||
|
self._preview_callback = callback
|
||||||
|
if callback is not None:
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def get_values(self) -> Dict[str, Any]:
|
||||||
|
"""Return the current mirror parameters as a dict.
|
||||||
|
|
||||||
|
Keys: ``mirror_plane_origin`` (3-tuple), ``mirror_plane_normal``
|
||||||
|
(3-tuple, auto-normalized), ``keep_original`` (bool).
|
||||||
|
"""
|
||||||
|
normal = (
|
||||||
|
self._normal_x.value(),
|
||||||
|
self._normal_y.value(),
|
||||||
|
self._normal_z.value(),
|
||||||
|
)
|
||||||
|
# Auto-normalize the normal vector (zero-vector → +Z).
|
||||||
|
import math as _math
|
||||||
|
nx, ny, nz = normal
|
||||||
|
norm = _math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
if norm < 1e-12:
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
else:
|
||||||
|
normal = (nx / norm, ny / norm, nz / norm)
|
||||||
|
return {
|
||||||
|
"mirror_plane_origin": (
|
||||||
|
self._origin_x.value(),
|
||||||
|
self._origin_y.value(),
|
||||||
|
self._origin_z.value(),
|
||||||
|
),
|
||||||
|
"mirror_plane_normal": normal,
|
||||||
|
"keep_original": self._keep_original_cb.isChecked(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WorkplaneOrientationDialog(QDialog):
|
class WorkplaneOrientationDialog(QDialog):
|
||||||
"""Modal dialog to choose the orientation of a new workplane.
|
"""Modal dialog to choose the orientation of a new workplane.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from fluency.ui.dialogs import (
|
|||||||
ExtrudeDialog,
|
ExtrudeDialog,
|
||||||
FilletDialog,
|
FilletDialog,
|
||||||
OffsetDialog,
|
OffsetDialog,
|
||||||
|
MirrorDialog,
|
||||||
RevolveDialog,
|
RevolveDialog,
|
||||||
ThreadDialog,
|
ThreadDialog,
|
||||||
WorkplaneOrientationDialog,
|
WorkplaneOrientationDialog,
|
||||||
@@ -1106,6 +1107,24 @@ def _replay_body_features(
|
|||||||
return None
|
return None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if feat.operation == "mirror":
|
||||||
|
# Mirror needs no sketch — it reflects the running solid.
|
||||||
|
if geom is None:
|
||||||
|
logger.warning(f"Body '{body.name}': mirror feature has no base, replay aborted")
|
||||||
|
return None
|
||||||
|
normal = feat.mirror_plane_normal if feat.mirror_plane_normal is not None else (1, 0, 0)
|
||||||
|
origin = feat.mirror_plane_origin if feat.mirror_plane_origin is not None else (0.0, 0.0, 0.0)
|
||||||
|
mirrored = kernel.mirror(geom, normal, origin)
|
||||||
|
if mirrored is None:
|
||||||
|
return None
|
||||||
|
if feat.keep_original:
|
||||||
|
geom = kernel.boolean_union(geom, mirrored)
|
||||||
|
else:
|
||||||
|
geom = mirrored
|
||||||
|
if geom is None:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
|
||||||
sketch = feat.sketch
|
sketch = feat.sketch
|
||||||
if sketch is None or sketch.occ_sketch is None:
|
if sketch is None or sketch.occ_sketch is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1736,10 +1755,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_array = ui.pb_arrayop
|
self._btn_array = ui.pb_arrayop
|
||||||
self._btn_fillet = ui.pb_fillet_op
|
self._btn_fillet = ui.pb_fillet_op
|
||||||
self._btn_fillet.setCheckable(True)
|
self._btn_fillet.setCheckable(True)
|
||||||
self._btn_chamfer = ui.pb_chamfer
|
self._btn_chamfer = ui.pb_chamfer_op
|
||||||
self._btn_chamfer.setCheckable(True)
|
self._btn_chamfer.setCheckable(True)
|
||||||
self._btn_thread = ui.pb_thread
|
self._btn_thread = ui.pb_thread
|
||||||
self._btn_thread.setCheckable(True)
|
self._btn_thread.setCheckable(True)
|
||||||
|
self._btn_mirror = ui.pb_mirror_op
|
||||||
# ── Export ──
|
# ── Export ──
|
||||||
self._btn_export_stl = ui.pushButton_2
|
self._btn_export_stl = ui.pushButton_2
|
||||||
self._btn_export_step = ui.pb_export_step
|
self._btn_export_step = ui.pb_export_step
|
||||||
@@ -1930,6 +1950,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Generic buttons
|
# Generic buttons
|
||||||
self._btn_move.clicked.connect(self._translate_body)
|
self._btn_move.clicked.connect(self._translate_body)
|
||||||
|
self._btn_mirror.clicked.connect(self._mirror_body)
|
||||||
self._btn_array.clicked.connect(self._pattern_array)
|
self._btn_array.clicked.connect(self._pattern_array)
|
||||||
self._btn_offset.clicked.connect(self._offset_sketch)
|
self._btn_offset.clicked.connect(self._offset_sketch)
|
||||||
# Per-body hide/show toggle: the user clicks pb_body_hide next
|
# Per-body hide/show toggle: the user clicks pb_body_hide next
|
||||||
@@ -2285,6 +2306,9 @@ class MainWindow(QMainWindow):
|
|||||||
next((i for i, v in enumerate(direction) if v), -1), "?"
|
next((i for i, v in enumerate(direction) if v), -1), "?"
|
||||||
)
|
)
|
||||||
return f"{index + 1}. Linear pattern {count}× ({spacing:g} mm along {axis_name})"
|
return f"{index + 1}. Linear pattern {count}× ({spacing:g} mm along {axis_name})"
|
||||||
|
if op == "mirror":
|
||||||
|
keep = " (keep original)" if feat.keep_original else ""
|
||||||
|
return f"{index + 1}. Mirror{keep}"
|
||||||
if op == "base":
|
if op == "base":
|
||||||
return f"{index + 1}. Base (baked geometry)"
|
return f"{index + 1}. Base (baked geometry)"
|
||||||
return f"{index + 1}. {op.title()}"
|
return f"{index + 1}. {op.title()}"
|
||||||
@@ -4425,6 +4449,137 @@ class MainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage(f"{kind} array applied — {count} item(s)", 5000)
|
self.statusBar().showMessage(f"{kind} array applied — {count} item(s)", 5000)
|
||||||
logger.info(f"Applied {kind.lower()} array ({count} items) to '{body.name}'")
|
logger.info(f"Applied {kind.lower()} array ({count} items) to '{body.name}'")
|
||||||
|
|
||||||
|
# ─── Mirror body ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _mirror_body(self) -> None:
|
||||||
|
"""Mirror the selected body across a plane (workplane or preset).
|
||||||
|
|
||||||
|
Opens the MirrorDialog asking for the mirror plane; on OK the body's
|
||||||
|
geometry is replaced by the mirrored copy (optionally union'd with
|
||||||
|
the original, so you get a symmetric part from half the work). A
|
||||||
|
``mirror`` feature is appended to the feature history so Update Body
|
||||||
|
and save/load replay it parametrically.
|
||||||
|
"""
|
||||||
|
body = self._selected_body
|
||||||
|
if body is None or body.geometry is None:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"No Body",
|
||||||
|
"Select a body first — mirror reflects the selected body.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gather workplane data for the dialog: list of (name, origin, normal).
|
||||||
|
wp_list: list = []
|
||||||
|
if self._current_component is not None:
|
||||||
|
wp_list = [
|
||||||
|
(wp.name, wp.origin, wp.normal)
|
||||||
|
for wp in self._current_component.workplanes.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
dialog = MirrorDialog(self, workplanes=wp_list)
|
||||||
|
|
||||||
|
def _preview(values: Any) -> None:
|
||||||
|
if values is None:
|
||||||
|
self._viewer_3d.clear_preview()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
normal = values["mirror_plane_normal"]
|
||||||
|
origin = values["mirror_plane_origin"]
|
||||||
|
keep = values["keep_original"]
|
||||||
|
mirrored = self._kernel.mirror(body.geometry, normal, origin)
|
||||||
|
if mirrored is None:
|
||||||
|
self._viewer_3d.clear_preview()
|
||||||
|
return
|
||||||
|
if keep:
|
||||||
|
result = self._kernel.boolean_union(body.geometry, mirrored)
|
||||||
|
else:
|
||||||
|
result = mirrored
|
||||||
|
if result is not None:
|
||||||
|
shape = self._kernel._get_shape(result)
|
||||||
|
self._viewer_3d.show_preview(shape)
|
||||||
|
else:
|
||||||
|
self._viewer_3d.clear_preview()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("mirror preview failed", exc_info=True)
|
||||||
|
self._viewer_3d.clear_preview()
|
||||||
|
|
||||||
|
dialog.set_preview_callback(_preview)
|
||||||
|
|
||||||
|
if dialog.exec():
|
||||||
|
values = dialog.get_values()
|
||||||
|
normal = values["mirror_plane_normal"]
|
||||||
|
origin = values["mirror_plane_origin"]
|
||||||
|
keep_original = values["keep_original"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
mirrored = self._kernel.mirror(body.geometry, normal, origin)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"Mirror failed: {exc}")
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, "Mirror Failed", f"Could not mirror the body: {exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if mirrored is None:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, "Mirror Failed", "The mirror operation produced no geometry."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if keep_original:
|
||||||
|
try:
|
||||||
|
result_geom = self._kernel.boolean_union(body.geometry, mirrored)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"Mirror union failed: {exc}")
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, "Mirror Failed",
|
||||||
|
f"Could not union original with mirror: {exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if result_geom is None:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, "Mirror Failed", "Union produced no geometry."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
result_geom = mirrored
|
||||||
|
|
||||||
|
# Record the operation in the feature history.
|
||||||
|
features = _ensure_feature_history(body)
|
||||||
|
if not features and body.geometry is not None:
|
||||||
|
features.append(Feature(operation="base", geometry=body.geometry))
|
||||||
|
features.append(
|
||||||
|
Feature(
|
||||||
|
operation="mirror",
|
||||||
|
mirror_plane_origin=origin,
|
||||||
|
mirror_plane_normal=normal,
|
||||||
|
keep_original=keep_original,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
body.geometry = result_geom
|
||||||
|
body.needs_update = False
|
||||||
|
body.modified_at = datetime.now()
|
||||||
|
self._mark_dirty()
|
||||||
|
|
||||||
|
# Re-render the body in place.
|
||||||
|
if body.render_object is not None:
|
||||||
|
self._viewer_3d.remove_mesh(body.render_object)
|
||||||
|
new_shape = self._kernel._get_shape(body.geometry)
|
||||||
|
body.render_object = self._viewer_3d.show_shape(
|
||||||
|
new_shape, body.color, body.name
|
||||||
|
)
|
||||||
|
|
||||||
|
self._refresh_lists()
|
||||||
|
self._update_component_thumbnail(self._get_active_component_index())
|
||||||
|
keep_msg = " (keep original)" if keep_original else ""
|
||||||
|
self.statusBar().showMessage(f"Mirror applied{keep_msg}", 5000)
|
||||||
|
logger.info(f"Applied mirror to '{body.name}'{keep_msg}")
|
||||||
|
|
||||||
|
# Preview already cleared by the dialog's hideEvent; clear again
|
||||||
|
# in case the dialog was rejected programmatically.
|
||||||
|
self._viewer_3d.clear_preview()
|
||||||
|
|
||||||
# ─── Offset sketch ─────────────────────────────────────────────────────
|
# ─── Offset sketch ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -4897,8 +5052,6 @@ class MainWindow(QMainWindow):
|
|||||||
5000,
|
5000,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pattern_array_placeholder(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _add_sketch_to_component(self):
|
def _add_sketch_to_component(self):
|
||||||
logger.info("=== ADD SKETCH TO COMPONENT ===")
|
logger.info("=== ADD SKETCH TO COMPONENT ===")
|
||||||
|
|||||||
+280
-49
@@ -863,18 +863,16 @@ class Sketch2DWidget(QWidget):
|
|||||||
constraint multi-select, then emits ``constrain_done`` so the
|
constraint multi-select, then emits ``constrain_done`` so the
|
||||||
toolbar buttons uncheck themselves.
|
toolbar buttons uncheck themselves.
|
||||||
"""
|
"""
|
||||||
if not self._is_drawing_tool_active():
|
was_active = self._is_drawing_tool_active()
|
||||||
return
|
|
||||||
self._mode = None
|
|
||||||
self._draw_buffer = []
|
self._draw_buffer = []
|
||||||
self._dynamic_line_end = None
|
self._dynamic_line_end = None
|
||||||
self._selected_entities = []
|
self._selected_entities = []
|
||||||
self._hovered_constraint_idx = -1
|
self._hovered_constraint_idx = -1
|
||||||
self._arc_accum_sweep = 0.0
|
self._arc_accum_sweep = 0.0
|
||||||
self._arc_prev_angle = None
|
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._pending_distance_val = None
|
||||||
|
if was_active:
|
||||||
|
self._mode = None
|
||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -930,13 +928,12 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Snapping ─────────────────────────────────────────────────────────
|
# ─── Snapping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15, exclude_ids: Optional[set[int]] = None) -> Optional[QPoint]:
|
||||||
# Delegates to _find_nearest_point_entity so the position snap and
|
|
||||||
# the constraint target ALWAYS agree — previously this loop ignored
|
# the constraint target ALWAYS agree — previously this loop ignored
|
||||||
# underlay visibility, so a hidden underlay corner would snap the
|
# underlay visibility, so a hidden underlay corner would snap the
|
||||||
# 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, exclude_ids=exclude_ids)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
return None
|
return None
|
||||||
xy = self._flat_xy(entity.geometry)
|
xy = self._flat_xy(entity.geometry)
|
||||||
@@ -946,7 +943,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
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(
|
||||||
self, pos: QPoint, max_distance: int = 15
|
self, pos: QPoint, max_distance: int = 15, exclude_ids: Optional[set[int]] = None
|
||||||
) -> Optional[OCCSketchEntity]:
|
) -> Optional[OCCSketchEntity]:
|
||||||
"""Find the nearest point entity to a screen position.
|
"""Find the nearest point entity to a screen position.
|
||||||
|
|
||||||
@@ -962,6 +959,8 @@ 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 exclude_ids and entity.id in exclude_ids:
|
||||||
|
continue
|
||||||
xy = self._flat_xy(entity.geometry)
|
xy = self._flat_xy(entity.geometry)
|
||||||
if xy is None:
|
if xy is None:
|
||||||
continue
|
continue
|
||||||
@@ -1066,7 +1065,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
return end
|
return end
|
||||||
return QPoint(start.x(), end.y())
|
return QPoint(start.x(), end.y())
|
||||||
|
|
||||||
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
|
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None, exclude_ids: Optional[set[int]] = None) -> QPoint:
|
||||||
result = pos
|
result = pos
|
||||||
# Reset auto-constraint tracking
|
# Reset auto-constraint tracking
|
||||||
self._snap_point_target = None
|
self._snap_point_target = None
|
||||||
@@ -1074,10 +1073,16 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._snap_horizontal = False
|
self._snap_horizontal = False
|
||||||
self._snap_vertical = False
|
self._snap_vertical = False
|
||||||
|
|
||||||
point_snap = self._find_nearest_point(pos)
|
point_snap = self._find_nearest_point(pos, exclude_ids=exclude_ids)
|
||||||
if point_snap:
|
if point_snap:
|
||||||
self._snap_point_target = self._find_nearest_point_entity(pos)
|
self._snap_point_target = self._find_nearest_point_entity(pos)
|
||||||
return self._world_to_screen(point_snap)
|
return self._world_to_screen(point_snap)
|
||||||
|
# When exclude_ids is active (constraint multi-select), don't fall
|
||||||
|
# through to midpoint / line snap — those redirect the click to a
|
||||||
|
# position the handler's point lookup won't find. The handler uses
|
||||||
|
# its own world-space entity lookup with the same exclude set.
|
||||||
|
if exclude_ids:
|
||||||
|
return result
|
||||||
if self._snap_mode.get("mpoint", False):
|
if self._snap_mode.get("mpoint", False):
|
||||||
mid_snap = self._find_midpoint_snap(pos)
|
mid_snap = self._find_midpoint_snap(pos)
|
||||||
if mid_snap:
|
if mid_snap:
|
||||||
@@ -1240,27 +1245,84 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Solver helpers ───────────────────────────────────────────────────
|
# ─── Solver helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
|
def _get_point_entity_at(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None) -> Optional[OCCSketchEntity]:
|
||||||
"""Find nearest point entity to world position.
|
"""Find nearest point entity to world position.
|
||||||
|
|
||||||
External (underlay) points are pickable when the underlay is visible
|
External (underlay) points are pickable when the underlay is visible
|
||||||
so the user can use them as constraint anchors (e.g. the corner of a
|
so the user can use them as constraint anchors (e.g. the corner of a
|
||||||
projected face); they're skipped when the underlay is hidden.
|
projected face); they're skipped when the underlay is hidden.
|
||||||
|
|
||||||
|
When several points are within tolerance (e.g. a user-drawn corner
|
||||||
|
sitting exactly on the centerline origin), user-drawn points win
|
||||||
|
over centerline / external reference points — otherwise a click on
|
||||||
|
your own corner would silently grab the reference point underneath
|
||||||
|
it and the distance would apply to the wrong entity.
|
||||||
"""
|
"""
|
||||||
|
best: Optional[OCCSketchEntity] = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
ref_best: Optional[OCCSketchEntity] = None
|
||||||
|
ref_best_dist = float("inf")
|
||||||
|
tolerance = self._pick_tolerance_world(10)
|
||||||
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 exclude_ids and entity.id in exclude_ids:
|
||||||
|
continue
|
||||||
xy = self._flat_xy(entity.geometry)
|
xy = self._flat_xy(entity.geometry)
|
||||||
if xy is None:
|
if xy is None:
|
||||||
continue
|
continue
|
||||||
x, y = xy
|
x, y = xy
|
||||||
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
||||||
if dist < self._pick_tolerance_world(10):
|
if dist >= tolerance:
|
||||||
return entity
|
continue
|
||||||
return None
|
if self._is_external(entity) or self._is_centerline(entity):
|
||||||
|
# Reference geometry — fallback only, never preferred.
|
||||||
|
if dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = entity, dist
|
||||||
|
else:
|
||||||
|
# User-drawn point — preferred over reference geometry.
|
||||||
|
if dist < best_dist:
|
||||||
|
best, best_dist = entity, dist
|
||||||
|
return best if best is not None else ref_best
|
||||||
|
|
||||||
|
def _get_point_entity_at_fallback(
|
||||||
|
self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None
|
||||||
|
) -> Optional[OCCSketchEntity]:
|
||||||
|
"""Same as :meth:`_get_point_entity_at` but with 3× tolerance.
|
||||||
|
|
||||||
|
Used by the distance constraint handler when the standard lookup fails
|
||||||
|
during multi-select — the snap may not have fired (the excluded entity
|
||||||
|
was the only one within screen range), so ``world_snapped`` is the raw
|
||||||
|
click position which can be further from the intended target than the
|
||||||
|
snapped position would have been.
|
||||||
|
"""
|
||||||
|
best: Optional[OCCSketchEntity] = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
ref_best: Optional[OCCSketchEntity] = None
|
||||||
|
ref_best_dist = float("inf")
|
||||||
|
tolerance = self._pick_tolerance_world(30)
|
||||||
|
for entity in self._points:
|
||||||
|
if self._is_external(entity) and not self._underlay_visible:
|
||||||
|
continue
|
||||||
|
if exclude_ids and entity.id in exclude_ids:
|
||||||
|
continue
|
||||||
|
xy = self._flat_xy(entity.geometry)
|
||||||
|
if xy is None:
|
||||||
|
continue
|
||||||
|
x, y = xy
|
||||||
|
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
||||||
|
if dist >= tolerance:
|
||||||
|
continue
|
||||||
|
if self._is_external(entity) or self._is_centerline(entity):
|
||||||
|
if dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = entity, dist
|
||||||
|
else:
|
||||||
|
if dist < best_dist:
|
||||||
|
best, best_dist = entity, dist
|
||||||
|
return best if best is not None else ref_best
|
||||||
|
|
||||||
def _get_line_entity_at(
|
def _get_line_entity_at(
|
||||||
self, world_pos: QPoint
|
self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None
|
||||||
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
|
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
|
||||||
"""Find a line near the given world position.
|
"""Find a line near the given world position.
|
||||||
|
|
||||||
@@ -1273,12 +1335,27 @@ class Sketch2DWidget(QWidget):
|
|||||||
uses perpendicular distance to the infinite line (no segment
|
uses perpendicular distance to the infinite line (no segment
|
||||||
clamping) with a zoom-adjusted tolerance so they are pickable at
|
clamping) with a zoom-adjusted tolerance so they are pickable at
|
||||||
any zoom level.
|
any zoom level.
|
||||||
|
|
||||||
|
When several lines are within tolerance (e.g. a user-drawn edge
|
||||||
|
lying exactly on the X axis), user-drawn lines win over centerline
|
||||||
|
/ external reference lines — otherwise a click on your own edge
|
||||||
|
would silently grab the reference axis underneath it.
|
||||||
"""
|
"""
|
||||||
|
best: Optional[Tuple[OCCSketchEntity, OCCSketchEntity]] = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
ref_best: Optional[Tuple[OCCSketchEntity, OCCSketchEntity]] = None
|
||||||
|
ref_best_dist = float("inf")
|
||||||
for p1_ent, p2_ent in self._lines:
|
for p1_ent, p2_ent in self._lines:
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
is_ext = bool(line_ent is not None and self._is_external(line_ent))
|
is_ext = bool(line_ent is not None and self._is_external(line_ent))
|
||||||
if is_ext and not self._underlay_visible:
|
if is_ext and not self._underlay_visible:
|
||||||
continue
|
continue
|
||||||
|
# NOTE: exclude_ids is accepted for API consistency but NOT applied
|
||||||
|
# here — it contains POINT ids (the already-selected entity for
|
||||||
|
# multi-click constraint tools). Filtering lines by their endpoint
|
||||||
|
# ids would prevent the user from picking a line that shares the
|
||||||
|
# selected point, which is a valid operation for point-on-line /
|
||||||
|
# midpoint / distance constraints.
|
||||||
if p1_ent.geometry and p2_ent.geometry:
|
if p1_ent.geometry and p2_ent.geometry:
|
||||||
x1, y1 = p1_ent.geometry
|
x1, y1 = p1_ent.geometry
|
||||||
x2, y2 = p2_ent.geometry
|
x2, y2 = p2_ent.geometry
|
||||||
@@ -1300,7 +1377,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
# perpendicular distance directly (no segment clamping).
|
# perpendicular distance directly (no segment clamping).
|
||||||
tol = self._pick_tolerance_world(12)
|
tol = self._pick_tolerance_world(12)
|
||||||
if perp_dist < tol:
|
if perp_dist < tol:
|
||||||
return (p1_ent, p2_ent)
|
cand = (p1_ent, p2_ent)
|
||||||
|
if perp_dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = cand, perp_dist
|
||||||
else:
|
else:
|
||||||
# Regular lines: clamp the projection to the segment
|
# Regular lines: clamp the projection to the segment
|
||||||
# and check distance to that clamped point.
|
# and check distance to that clamped point.
|
||||||
@@ -1317,8 +1396,15 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
|
self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
|
||||||
)
|
)
|
||||||
if seg_dist < tol:
|
if seg_dist < tol:
|
||||||
return (p1_ent, p2_ent)
|
cand = (p1_ent, p2_ent)
|
||||||
return None
|
# Prefer user-drawn lines over reference geometry.
|
||||||
|
if is_ext:
|
||||||
|
if seg_dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = cand, seg_dist
|
||||||
|
else:
|
||||||
|
if seg_dist < best_dist:
|
||||||
|
best, best_dist = cand, seg_dist
|
||||||
|
return best if best is not None else ref_best
|
||||||
|
|
||||||
def _find_line_sketch_entity(
|
def _find_line_sketch_entity(
|
||||||
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
|
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
|
||||||
@@ -1399,6 +1485,43 @@ class Sketch2DWidget(QWidget):
|
|||||||
x1, y1 = s_ent.geometry
|
x1, y1 = s_ent.geometry
|
||||||
x2, y2 = e_ent.geometry
|
x2, y2 = e_ent.geometry
|
||||||
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
||||||
|
def _line_world_endpoints(
|
||||||
|
self, line_id: int
|
||||||
|
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||||
|
"""World-space endpoint coordinates of the line with the given id."""
|
||||||
|
if not self._sketch or line_id not in self._sketch._lines:
|
||||||
|
return None
|
||||||
|
sid, eid2 = self._sketch._lines[line_id]
|
||||||
|
s_ent = self._sketch._entities.get(sid)
|
||||||
|
e_ent = self._sketch._entities.get(eid2)
|
||||||
|
if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry:
|
||||||
|
return None
|
||||||
|
x1, y1 = s_ent.geometry
|
||||||
|
x2, y2 = e_ent.geometry
|
||||||
|
if not all(isinstance(v, (int, float)) for v in (x1, y1, x2, y2)):
|
||||||
|
return None
|
||||||
|
return (float(x1), float(y1)), (float(x2), float(y2))
|
||||||
|
|
||||||
|
def _perpendicular_foot_world(self, point: QPoint, line_id: int) -> Optional[QPoint]:
|
||||||
|
"""Perpendicular foot of *point* projected onto the line, world-space.
|
||||||
|
|
||||||
|
The foot lies on the *infinite* line through the two endpoints — when
|
||||||
|
the point projects beyond the segment the dimension line legitimately
|
||||||
|
extends past the endpoint. Returns None if the line geometry is
|
||||||
|
unavailable.
|
||||||
|
"""
|
||||||
|
ends = self._line_world_endpoints(line_id)
|
||||||
|
if ends is None:
|
||||||
|
return None
|
||||||
|
(x1, y1), (x2, y2) = ends
|
||||||
|
dx = x2 - x1
|
||||||
|
dy = y2 - y1
|
||||||
|
denom = dx * dx + dy * dy
|
||||||
|
if denom < 1e-12:
|
||||||
|
# Degenerate (zero-length) line — fall back to its start point.
|
||||||
|
return QPoint(int(round(x1)), int(round(y1)))
|
||||||
|
t = ((point.x() - x1) * dx + (point.y() - y1) * dy) / denom
|
||||||
|
return QPoint(int(round(x1 + t * dx)), int(round(y1 + t * dy)))
|
||||||
|
|
||||||
def _point_world(self, pid: int) -> Optional[QPoint]:
|
def _point_world(self, pid: int) -> Optional[QPoint]:
|
||||||
"""World-space position of the point entity with the given id.
|
"""World-space position of the point entity with the given id.
|
||||||
@@ -1508,13 +1631,32 @@ class Sketch2DWidget(QWidget):
|
|||||||
anchor = self._line_world_mid(ids[1])
|
anchor = self._line_world_mid(ids[1])
|
||||||
label = "mid"
|
label = "mid"
|
||||||
elif ctype == "distance":
|
elif ctype == "distance":
|
||||||
# Distance may be point-to-point OR point-to-line (e.g.
|
# Distance may be point-to-point OR point-to-line. For a
|
||||||
# point-on-line coincident surfaces as a coincident entry;
|
# point-to-line distance the dimension line must run from
|
||||||
# a future point-to-line distance would do the same).
|
# the selected point to its perpendicular foot on the line
|
||||||
# Use _entity_anchor so a line id routes to the line
|
# (i.e. at 90° to the line) — NOT to the line's midpoint,
|
||||||
# midpoint instead of crashing on round(<tuple>).
|
# which would render the measured distance at an arbitrary
|
||||||
|
# angle. Use _entity_anchor so a line id routes to the
|
||||||
|
# line midpoint as a fallback instead of crashing on
|
||||||
|
# round(<tuple>).
|
||||||
a = self._entity_anchor(ids[0])
|
a = self._entity_anchor(ids[0])
|
||||||
b = self._entity_anchor(ids[1])
|
b = self._entity_anchor(ids[1])
|
||||||
|
ent_a = self._sketch._entities.get(ids[0])
|
||||||
|
ent_b = self._sketch._entities.get(ids[1])
|
||||||
|
ta = ent_a.entity_type if ent_a is not None else None
|
||||||
|
tb = ent_b.entity_type if ent_b is not None else None
|
||||||
|
if ta == "point" and tb == "line":
|
||||||
|
if a is not None:
|
||||||
|
foot = self._perpendicular_foot_world(a, ids[1])
|
||||||
|
if foot is not None:
|
||||||
|
b = foot
|
||||||
|
elif ta == "line" and tb == "point":
|
||||||
|
# Legacy (line, point) log ordering — same geometry,
|
||||||
|
# mirrored.
|
||||||
|
if b is not None:
|
||||||
|
foot = self._perpendicular_foot_world(b, ids[0])
|
||||||
|
if foot is not None:
|
||||||
|
a = foot
|
||||||
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
||||||
if a is not None and b is not None:
|
if a is not None and b is not None:
|
||||||
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
||||||
@@ -1599,7 +1741,10 @@ class Sketch2DWidget(QWidget):
|
|||||||
if tag_p1 is not None and tag_p2 is not None:
|
if tag_p1 is not None and tag_p2 is not None:
|
||||||
tag_entry["p1_world"] = tag_p1
|
tag_entry["p1_world"] = tag_p1
|
||||||
tag_entry["p2_world"] = tag_p2
|
tag_entry["p2_world"] = tag_p2
|
||||||
tag_entry["distance"] = params[0] if params else 0.0
|
# The stored value carries the side-sign (negative
|
||||||
|
# when the point is on the "negative" side of the
|
||||||
|
# line) — the dimension text shows the magnitude.
|
||||||
|
tag_entry["distance"] = abs(params[0]) if params else 0.0
|
||||||
tags.append(tag_entry)
|
tags.append(tag_entry)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Catch any failure while building this one tag (bad
|
# Catch any failure while building this one tag (bad
|
||||||
@@ -2105,9 +2250,15 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Exclude already-selected entities from snap/lookup during constraint multi-select
|
||||||
|
# so the user can pick a second entity distinct from the first.
|
||||||
|
_exclude_ids: Optional[set[int]] = None
|
||||||
|
if self._mode and self._mode.startswith("constrain_") and self._selected_entities:
|
||||||
|
_exclude_ids = {e.id for e in self._selected_entities}
|
||||||
snapped_pos = self._apply_all_snaps(
|
snapped_pos = self._apply_all_snaps(
|
||||||
event.pos(),
|
event.pos(),
|
||||||
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
|
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
|
||||||
|
exclude_ids=_exclude_ids,
|
||||||
)
|
)
|
||||||
world_snapped = (
|
world_snapped = (
|
||||||
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
|
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
|
||||||
@@ -2126,23 +2277,23 @@ class Sketch2DWidget(QWidget):
|
|||||||
elif self._mode == "select":
|
elif self._mode == "select":
|
||||||
self._handle_select_click(world_snapped)
|
self._handle_select_click(world_snapped)
|
||||||
elif self._mode == "constrain_coincident":
|
elif self._mode == "constrain_coincident":
|
||||||
self._handle_constraint_coincident(world_snapped)
|
self._handle_constraint_coincident(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_horizontal":
|
elif self._mode == "constrain_horizontal":
|
||||||
self._handle_constraint_horizontal(world_snapped)
|
self._handle_constraint_horizontal(world_snapped)
|
||||||
elif self._mode == "constrain_vertical":
|
elif self._mode == "constrain_vertical":
|
||||||
self._handle_constraint_vertical(world_snapped)
|
self._handle_constraint_vertical(world_snapped)
|
||||||
elif self._mode == "constrain_distance":
|
elif self._mode == "constrain_distance":
|
||||||
self._handle_constraint_distance(world_snapped)
|
self._handle_constraint_distance(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_midpoint":
|
elif self._mode == "constrain_midpoint":
|
||||||
self._handle_constraint_midpoint(world_snapped)
|
self._handle_constraint_midpoint(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_perpendicular":
|
elif self._mode == "constrain_perpendicular":
|
||||||
self._handle_constraint_perpendicular(world_snapped)
|
self._handle_constraint_perpendicular(world_snapped)
|
||||||
elif self._mode == "constrain_parallel":
|
elif self._mode == "constrain_parallel":
|
||||||
self._handle_constraint_parallel(world_snapped)
|
self._handle_constraint_parallel(world_snapped)
|
||||||
elif self._mode == "constrain_ptline":
|
elif self._mode == "constrain_ptline":
|
||||||
self._handle_constraint_ptline(world_snapped)
|
self._handle_constraint_ptline(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_symmetric":
|
elif self._mode == "constrain_symmetric":
|
||||||
self._handle_constraint_symmetric(world_snapped)
|
self._handle_constraint_symmetric(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_diameter":
|
elif self._mode == "constrain_diameter":
|
||||||
self._handle_constraint_diameter(world_snapped)
|
self._handle_constraint_diameter(world_snapped)
|
||||||
|
|
||||||
@@ -3254,10 +3405,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Constraint handlers (with solver calls) ──────────────────────────
|
# ─── Constraint handlers (with solver calls) ──────────────────────────
|
||||||
|
|
||||||
def _handle_constraint_coincident(self, world_pos: QPoint):
|
def _handle_constraint_coincident(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
ent = self._get_point_entity_at(world_pos)
|
ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if ent is None:
|
if ent is None:
|
||||||
return
|
return
|
||||||
|
if ent in self._selected_entities:
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
self._selected_entities.append(ent)
|
self._selected_entities.append(ent)
|
||||||
if len(self._selected_entities) >= 2:
|
if len(self._selected_entities) >= 2:
|
||||||
e1, e2 = self._selected_entities[:2]
|
e1, e2 = self._selected_entities[:2]
|
||||||
@@ -3310,7 +3464,6 @@ class Sketch2DWidget(QWidget):
|
|||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is None:
|
if line_ent is None:
|
||||||
return
|
return
|
||||||
# Save state before adding constraint
|
|
||||||
if self._undo_manager:
|
if self._undo_manager:
|
||||||
self._undo_manager.save_state()
|
self._undo_manager.save_state()
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
@@ -3327,18 +3480,62 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_distance(self, world_pos: QPoint):
|
def _handle_constraint_distance(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
|
if point_ent is None:
|
||||||
|
# The snap may not have fired (excluded entity was the only one
|
||||||
|
# within screen range, or the standard tolerance is too tight after
|
||||||
|
# a viewport change like re-projecting a face). Retry with 3×.
|
||||||
|
point_ent = self._get_point_entity_at_fallback(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent:
|
if point_ent:
|
||||||
# Point clicked: collect points; constraint applied after 2nd point.
|
if point_ent in self._selected_entities:
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
else:
|
else:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is not None:
|
if line_ent is not None:
|
||||||
# Line clicked: constrain its length (endpoint distance).
|
if self._selected_entities:
|
||||||
|
# A point is already selected: this line is the
|
||||||
|
# SECOND entity of a point-to-line distance.
|
||||||
|
prev_ent = self._selected_entities[0]
|
||||||
|
dist, ok = QInputDialog.getDouble(
|
||||||
|
self,
|
||||||
|
"Point-to-line distance",
|
||||||
|
"Distance (mm):",
|
||||||
|
self._constraint_distance_value,
|
||||||
|
0,
|
||||||
|
10000,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
if ok and self._sketch:
|
||||||
|
# Save state before adding constraint
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
|
self._sketch.constrain_distance(prev_ent, line_ent, dist)
|
||||||
|
self._solve_and_sync()
|
||||||
|
logger.info(f"Point-line distance {dist:.2f}mm")
|
||||||
|
self._selected_entities = []
|
||||||
|
self._mode = None
|
||||||
|
self.constrain_done.emit()
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
|
# No selection: a lone line click sets its length
|
||||||
|
# (distance between its endpoint points). Reference
|
||||||
|
# lines (centerlines / underlay projections) are fixed
|
||||||
|
# and have no meaningful "length" — skip them; the
|
||||||
|
# user must pick a point first to constrain against
|
||||||
|
# them (point-to-line distance).
|
||||||
|
if self._is_centerline(line_ent) or self._is_external(line_ent):
|
||||||
|
logger.info(
|
||||||
|
"distance: select a point first, then this reference line "
|
||||||
|
"for a point-to-line distance"
|
||||||
|
)
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
dist, ok = QInputDialog.getDouble(
|
dist, ok = QInputDialog.getDouble(
|
||||||
self,
|
self,
|
||||||
"Distance",
|
"Distance",
|
||||||
@@ -3378,12 +3575,12 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_midpoint(self, world_pos: QPoint):
|
def _handle_constraint_midpoint(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent and not self._selected_entities:
|
if point_ent and not self._selected_entities:
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
elif self._selected_entities:
|
elif self._selected_entities:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
@@ -3454,13 +3651,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_ptline(self, world_pos: QPoint):
|
def _handle_constraint_ptline(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
"""Point-on-line coincident (point on line)."""
|
"""Point-on-line coincident (point on line)."""
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent and not self._selected_entities:
|
if point_ent and not self._selected_entities:
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
elif self._selected_entities:
|
elif self._selected_entities:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
@@ -3477,11 +3674,11 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_symmetric(self, world_pos: QPoint):
|
def _handle_constraint_symmetric(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
"""Symmetric constraint: select entity1, entity2, then mirror line."""
|
"""Symmetric constraint: select entity1, entity2, then mirror line."""
|
||||||
# Click 3: mirror line
|
# Click 3: mirror line
|
||||||
if len(self._selected_entities) == 2:
|
if len(self._selected_entities) == 2:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
@@ -3503,8 +3700,11 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
return
|
return
|
||||||
# Clicks 1-2: select point entities
|
# Clicks 1-2: select point entities
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent:
|
if point_ent:
|
||||||
|
if point_ent in self._selected_entities:
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
n_selected = len(self._selected_entities)
|
n_selected = len(self._selected_entities)
|
||||||
if n_selected < 2:
|
if n_selected < 2:
|
||||||
@@ -4026,6 +4226,32 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.setPen(QPen(QColor("#cdd6f4"), 2))
|
painter.setPen(QPen(QColor("#cdd6f4"), 2))
|
||||||
painter.drawLine(sp1, sp2)
|
painter.drawLine(sp1, sp2)
|
||||||
|
|
||||||
|
# ── Selected-entity highlight (multi-click constraint tools) ──
|
||||||
|
# While collecting entities for a distance constraint the first pick
|
||||||
|
# gets highlighted so the user can see it registered before clicking
|
||||||
|
# the second entity. Points get a bright ring, lines a bright
|
||||||
|
# overlay stroke.
|
||||||
|
if self._selected_entities and self._is_drawing_tool_active():
|
||||||
|
for ent in self._selected_entities:
|
||||||
|
if ent.entity_type == "point":
|
||||||
|
xy = self._flat_xy(ent.geometry)
|
||||||
|
if xy is None:
|
||||||
|
continue
|
||||||
|
x, y = xy
|
||||||
|
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||||||
|
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||||||
|
painter.setBrush(Qt.NoBrush)
|
||||||
|
painter.drawEllipse(sp, 9, 9)
|
||||||
|
elif ent.entity_type == "line":
|
||||||
|
p1e, p2e = self._get_line_endpoints(ent)
|
||||||
|
if p1e and p2e and p1e.geometry and p2e.geometry:
|
||||||
|
x1, y1 = p1e.geometry
|
||||||
|
x2, y2 = p2e.geometry
|
||||||
|
sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
|
||||||
|
sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
|
||||||
|
painter.setPen(QPen(QColor("#f9e2af"), 3))
|
||||||
|
painter.drawLine(sp1, sp2)
|
||||||
|
|
||||||
# ── 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
|
# While a drawing or constraint tool is active the badges are hidden
|
||||||
@@ -4382,9 +4608,14 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
|
|
||||||
# ── Selected entities ──
|
# ── Selected entities ──
|
||||||
|
# Point entities only (rings); line selection is highlighted by the
|
||||||
|
# dedicated constraint-selection block above. Guard with _flat_xy so
|
||||||
|
# a line entity (tuple-of-tuples geometry) can never reach round().
|
||||||
for entity in self._selected_entities:
|
for entity in self._selected_entities:
|
||||||
if entity.geometry:
|
xy = self._flat_xy(entity.geometry)
|
||||||
x, y = entity.geometry
|
if xy is None:
|
||||||
|
continue
|
||||||
|
x, y = xy
|
||||||
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||||||
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||||||
painter.setBrush(Qt.NoBrush)
|
painter.setBrush(Qt.NoBrush)
|
||||||
|
|||||||
Reference in New Issue
Block a user