2 Commits

Author SHA1 Message Date
bklronin 655df71cd8 - arc improvements, fillets, operations, bodys 2026-08-07 15:44:53 +02:00
bklronin 7072dcee08 - arc improvements, fillets, operations, bodys 2026-08-05 18:53:48 +02:00
10 changed files with 945 additions and 188 deletions
+58 -49
View File
@@ -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[{
&quot;keyToString&quot;: { "keyToString": {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;, "Python.2dtest.executor": "Run",
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;, "Python.3d_windows.executor": "Run",
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;, "Python.Unnamed.executor": "Run",
&quot;Python.base.executor&quot;: &quot;Run&quot;, "Python.base.executor": "Run",
&quot;Python.data_model.executor&quot;: &quot;Run&quot;, "Python.data_model.executor": "Run",
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;, "Python.debug_dragging.executor": "Run",
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;, "Python.draw_widget2d.executor": "Run",
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;, "Python.draw_widget_solve.executor": "Run",
&quot;Python.fluency.executor&quot;: &quot;Run&quot;, "Python.fluency.executor": "Run",
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;, "Python.fluencyb.executor": "Run",
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;, "Python.gl_widget.executor": "Run",
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;, "Python.gui_ui.executor": "Run",
&quot;Python.kernel.executor&quot;: &quot;Run&quot;, "Python.kernel.executor": "Run",
&quot;Python.main.executor&quot;: &quot;Run&quot;, "Python.main.executor": "Run",
&quot;Python.main_window.executor&quot;: &quot;Run&quot;, "Python.main_window.executor": "Run",
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;, "Python.meshtest.executor": "Run",
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;, "Python.occ_renderer.executor": "Run",
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;, "Python.occ_to_mesh.executor": "Run",
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;, "Python.render_backend.executor": "Run",
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;, "Python.side_fluency.executor": "Run",
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;, "Python.simple_mesh.executor": "Run",
&quot;Python.sketch.executor&quot;: &quot;Run&quot;, "Python.sketch.executor": "Run",
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;, "Python.vtk_widget.executor": "Run",
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;, "Python.vulkan.executor": "Run",
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, "RunOnceActivity.OpenProjectViewOnStart": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;, "codeWithMe.voiceChat.enabledByDefault": "false",
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;, "git-widget-placeholder": "feature/occ-migration",
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency&quot;, "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot; "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">
+37 -30
View File
@@ -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>
+38 -32
View File
@@ -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))
+89 -3
View File
@@ -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:
+6
View File
@@ -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:
+9
View File
@@ -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)
+17 -7
View File
@@ -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
+240
View File
@@ -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.
+156 -3
View File
@@ -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
View File
@@ -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)