- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-07 15:44:53 +02:00
parent 7072dcee08
commit 655df71cd8
8 changed files with 619 additions and 126 deletions
+55 -44
View File
@@ -7,8 +7,11 @@
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<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$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.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/sketch_solver.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/sketch_solver.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/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" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
@@ -46,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" />
@@ -94,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$" />
@@ -480,7 +483,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1785941246695</updated> <updated>1785941246695</updated>
</task> </task>
<option name="localTasksCounter" value="46" /> <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">
+7
View File
@@ -1233,6 +1233,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="6" column="1">
<widget class="QPushButton" name="pb_mirror_op">
<property name="text">
<string>Mirror</string>
</property>
</widget>
</item>
</layout> </layout>
</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))
+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)
+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 ===")
+98 -37
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,7 +1245,7 @@ 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
@@ -1261,6 +1266,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
@@ -1278,8 +1285,44 @@ class Sketch2DWidget(QWidget):
best, best_dist = entity, dist best, best_dist = entity, dist
return best if best is not None else ref_best 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.
@@ -1307,6 +1350,12 @@ class Sketch2DWidget(QWidget):
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
@@ -2201,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
@@ -2222,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)
@@ -3350,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]
@@ -3406,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:
@@ -3423,19 +3480,20 @@ 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:
# Clicking the same point twice would create a point-to-itself
# distance which the solver can never satisfy for a non-zero
# value — ignore the repeat so the user can reposition instead.
if point_ent in self._selected_entities: if point_ent in self._selected_entities:
self.update() self.update()
return return
# Point clicked: collect points; constraint applied after 2nd point.
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)
@@ -3517,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)
@@ -3593,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)
@@ -3616,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)
@@ -3642,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: