- 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">
<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$/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/sketch_solver.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/sketch_solver.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/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" />
</list>
<option name="SHOW_DIALOG" value="false" />
@@ -46,47 +49,47 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.main_window.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.base.executor": "Run",
"Python.data_model.executor": "Run",
"Python.debug_dragging.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.gui_ui.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.main_window.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.occ_to_mesh.executor": "Run",
"Python.render_backend.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.sketch.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
}</component>
}]]></component>
<component name="QodanaReportsService">
<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" />
@@ -94,11 +97,11 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
@@ -480,7 +483,15 @@
<option name="project" value="LOCAL" />
<updated>1785941246695</updated>
</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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
+7
View File
@@ -1233,6 +1233,13 @@
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QPushButton" name="pb_mirror_op">
<property name="text">
<string>Mirror</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
+38 -32
View File
@@ -628,50 +628,55 @@ class Ui_fluencyCAD(object):
self.groupBox.setMaximumSize(QSize(200, 16777215))
self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.pb_chamfer_op = QPushButton(self.groupBox)
self.pb_chamfer_op.setObjectName(u"pb_chamfer_op")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 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.gridLayout_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
self.pb_fillet_op = QPushButton(self.groupBox)
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
self.gridLayout_3.addWidget(self.pb_fillet_op, 3, 0, 1, 1)
self.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.setObjectName(u"pb_extrdop")
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.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_chamfer = QPushButton(self.groupBox)
self.pb_chamfer.setObjectName(u"pb_chamfer")
self.pb_arrayop = QPushButton(self.groupBox)
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_thread.setObjectName(u"pb_thread")
self.pb_combop = QPushButton(self.groupBox)
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)
@@ -922,15 +927,16 @@ class Ui_fluencyCAD(object):
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", 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_chamfer_op.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", 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_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_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", 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,
"spacing": feat.spacing,
"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),
spacing=_to_float(data.get("spacing"), 10.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")
if sid and sid in sketches:
+18 -9
View File
@@ -229,17 +229,21 @@ class Feature:
- "chamfer": bevel a set of edges of the running geometry
(same fields as "fillet"; ``radius`` holds the
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
circular array. ``pattern_type`` is "linear" or
"circular"; ``count`` is the total number of items
(original + copies). Linear uses ``direction``
(unit vector) and ``spacing`` (mm between adjacent
items); circular reuses ``axis`` + ``origin`` for the
rotation axis and ``angle`` for the total angular
span in degrees (copies evenly distributed).
circular array. ``pattern_type`` is "linear" or
"circular"; ``count`` is the total number of items
(original + copies). Linear uses ``direction``
(unit vector) and ``spacing`` (mm between adjacent
items); circular reuses ``axis`` + ``origin`` for the
rotation axis and ``angle`` for the total angular
span in degrees (copies evenly distributed).
- "base": frozen geometry snapshot (``geometry`` field) — used
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
@@ -281,6 +285,11 @@ class Feature:
spacing: float = 10.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)
+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):
"""Modal dialog to choose the orientation of a new workplane.
+156 -3
View File
@@ -52,6 +52,7 @@ from fluency.ui.dialogs import (
ExtrudeDialog,
FilletDialog,
OffsetDialog,
MirrorDialog,
RevolveDialog,
ThreadDialog,
WorkplaneOrientationDialog,
@@ -1106,6 +1107,24 @@ def _replay_body_features(
return None
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
if sketch is None or sketch.occ_sketch is None:
logger.warning(
@@ -1736,10 +1755,11 @@ class MainWindow(QMainWindow):
self._btn_array = ui.pb_arrayop
self._btn_fillet = ui.pb_fillet_op
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_thread = ui.pb_thread
self._btn_thread.setCheckable(True)
self._btn_mirror = ui.pb_mirror_op
# ── Export ──
self._btn_export_stl = ui.pushButton_2
self._btn_export_step = ui.pb_export_step
@@ -1930,6 +1950,7 @@ class MainWindow(QMainWindow):
# Generic buttons
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_offset.clicked.connect(self._offset_sketch)
# 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), "?"
)
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":
return f"{index + 1}. Base (baked geometry)"
return f"{index + 1}. {op.title()}"
@@ -4425,6 +4449,137 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(f"{kind} array applied — {count} item(s)", 5000)
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 ─────────────────────────────────────────────────────
@staticmethod
@@ -4897,8 +5052,6 @@ class MainWindow(QMainWindow):
5000,
)
def _pattern_array_placeholder(self):
pass
def _add_sketch_to_component(self):
logger.info("=== ADD SKETCH TO COMPONENT ===")
+99 -38
View File
@@ -863,19 +863,17 @@ class Sketch2DWidget(QWidget):
constraint multi-select, then emits ``constrain_done`` so the
toolbar buttons uncheck themselves.
"""
if not self._is_drawing_tool_active():
return
self._mode = None
was_active = self._is_drawing_tool_active()
self._draw_buffer = []
self._dynamic_line_end = None
self._selected_entities = []
self._hovered_constraint_idx = -1
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
# Invalidate any pending dimension-input prompt so the value
# dialog (if one is open elsewhere) doesn't latch onto stale state.
self._pending_distance_val = None
self.constrain_done.emit()
if was_active:
self._mode = None
self.constrain_done.emit()
self.update()
def set_construct_mode(self, enabled: bool):
@@ -930,13 +928,12 @@ class Sketch2DWidget(QWidget):
# ─── Snapping ─────────────────────────────────────────────────────────
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
# Delegates to _find_nearest_point_entity so the position snap and
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15, exclude_ids: Optional[set[int]] = None) -> Optional[QPoint]:
# the constraint target ALWAYS agree — previously this loop ignored
# underlay visibility, so a hidden underlay corner would snap the
# cursor position while the entity lookup returned None and no
# 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:
return None
xy = self._flat_xy(entity.geometry)
@@ -946,7 +943,7 @@ class Sketch2DWidget(QWidget):
return QPoint(int(round(x)), int(round(y)))
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]:
"""Find the nearest point entity to a screen position.
@@ -962,6 +959,8 @@ class Sketch2DWidget(QWidget):
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
@@ -1066,7 +1065,7 @@ class Sketch2DWidget(QWidget):
return end
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
# Reset auto-constraint tracking
self._snap_point_target = None
@@ -1074,10 +1073,16 @@ class Sketch2DWidget(QWidget):
self._snap_horizontal = 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:
self._snap_point_target = self._find_nearest_point_entity(pos)
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):
mid_snap = self._find_midpoint_snap(pos)
if mid_snap:
@@ -1240,7 +1245,7 @@ class Sketch2DWidget(QWidget):
# ─── 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.
External (underlay) points are pickable when the underlay is visible
@@ -1261,6 +1266,8 @@ class Sketch2DWidget(QWidget):
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
@@ -1278,8 +1285,44 @@ class Sketch2DWidget(QWidget):
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(
self, world_pos: QPoint
self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
"""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))
if is_ext and not self._underlay_visible:
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:
x1, y1 = p1_ent.geometry
x2, y2 = p2_ent.geometry
@@ -2201,9 +2250,15 @@ class Sketch2DWidget(QWidget):
self.update()
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(
event.pos(),
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
exclude_ids=_exclude_ids,
)
world_snapped = (
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":
self._handle_select_click(world_snapped)
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":
self._handle_constraint_horizontal(world_snapped)
elif self._mode == "constrain_vertical":
self._handle_constraint_vertical(world_snapped)
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":
self._handle_constraint_midpoint(world_snapped)
self._handle_constraint_midpoint(world_snapped, exclude_ids=_exclude_ids)
elif self._mode == "constrain_perpendicular":
self._handle_constraint_perpendicular(world_snapped)
elif self._mode == "constrain_parallel":
self._handle_constraint_parallel(world_snapped)
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":
self._handle_constraint_symmetric(world_snapped)
self._handle_constraint_symmetric(world_snapped, exclude_ids=_exclude_ids)
elif self._mode == "constrain_diameter":
self._handle_constraint_diameter(world_snapped)
@@ -3350,10 +3405,13 @@ class Sketch2DWidget(QWidget):
# ─── Constraint handlers (with solver calls) ──────────────────────────
def _handle_constraint_coincident(self, world_pos: QPoint):
ent = self._get_point_entity_at(world_pos)
def _handle_constraint_coincident(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
if ent is None:
return
if ent in self._selected_entities:
self.update()
return
self._selected_entities.append(ent)
if len(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)
if line_ent is None:
return
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
if self._sketch:
@@ -3423,19 +3480,20 @@ class Sketch2DWidget(QWidget):
self.constrain_done.emit()
self.update()
def _handle_constraint_distance(self, world_pos: QPoint):
point_ent = self._get_point_entity_at(world_pos)
def _handle_constraint_distance(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
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:
# 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:
self.update()
return
# Point clicked: collect points; constraint applied after 2nd point.
self._selected_entities.append(point_ent)
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:
p1_ent, p2_ent = line_hit
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
@@ -3517,12 +3575,12 @@ class Sketch2DWidget(QWidget):
self.constrain_done.emit()
self.update()
def _handle_constraint_midpoint(self, world_pos: QPoint):
point_ent = self._get_point_entity_at(world_pos)
def _handle_constraint_midpoint(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
if point_ent and not self._selected_entities:
self._selected_entities.append(point_ent)
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:
p1_ent, p2_ent = line_hit
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
@@ -3593,13 +3651,13 @@ class Sketch2DWidget(QWidget):
self.constrain_done.emit()
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_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:
self._selected_entities.append(point_ent)
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:
p1_ent, p2_ent = line_hit
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
@@ -3616,11 +3674,11 @@ class Sketch2DWidget(QWidget):
self.constrain_done.emit()
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."""
# Click 3: mirror line
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:
p1_ent, p2_ent = line_hit
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
@@ -3642,8 +3700,11 @@ class Sketch2DWidget(QWidget):
self.update()
return
# 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 in self._selected_entities:
self.update()
return
self._selected_entities.append(point_ent)
n_selected = len(self._selected_entities)
if n_selected < 2: