- added "measurement lines"

This commit is contained in:
bklronin
2026-08-02 20:58:43 +02:00
parent 0daa6152ee
commit baa2fd5d47
8 changed files with 1842 additions and 410 deletions
+52 -47
View File
@@ -4,18 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added contrain context menu&#10;- improved line pickability.">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added &quot;measurement lines&quot;">
<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.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/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$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -52,47 +49,47 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<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",
"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 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/src/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>
}</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" />
@@ -446,7 +443,15 @@
<option name="project" value="LOCAL" />
<updated>1783976498520</updated>
</task>
<option name="localTasksCounter" value="41" />
<task id="LOCAL-00041" summary="- added &quot;measurement lines&quot;">
<option name="closed" value="true" />
<created>1785094789247</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1785094789248</updated>
</task>
<option name="localTasksCounter" value="42" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -467,7 +472,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
@@ -492,6 +496,7 @@
<MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" />
<option name="LAST_COMMIT_MESSAGE" value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<option name="LAST_COMMIT_MESSAGE" value="- added &quot;measurement lines&quot;" />
</component>
</project>
+43 -18
View File
@@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>2551</width>
<height>1248</height>
<height>1265</height>
</rect>
</property>
<property name="sizePolicy">
@@ -218,7 +218,7 @@
</sizepolicy>
</property>
<property name="currentIndex">
<number>1</number>
<number>0</number>
</property>
<widget class="QWidget" name="sketch_tab">
<attribute name="title">
@@ -372,6 +372,10 @@
<property name="text">
<string>Pt_Pt</string>
</property>
<property name="icon">
<iconset>
<normaloff>icons/pt_pt.png</normaloff>icons/pt_pt.png</iconset>
</property>
<property name="checkable">
<bool>true</bool>
</property>
@@ -1260,17 +1264,10 @@
<string>Modify</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="2" column="1">
<widget class="QPushButton" name="pb_revop">
<item row="1" column="0">
<widget class="QPushButton" name="pb_combop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
<string>Extrd</string>
<string>Comb</string>
</property>
</widget>
</item>
@@ -1281,6 +1278,34 @@
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<property name="text">
<string>Mve</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pb_revop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="pb_fillet_op">
<property name="text">
<string>Fillet</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
<string>Extrd</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="pb_cutop">
<property name="text">
@@ -1288,17 +1313,17 @@
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="pb_combop">
<item row="3" column="1">
<widget class="QPushButton" name="pb_face_op">
<property name="text">
<string>Comb</string>
<string>Phase</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<item row="4" column="0">
<widget class="QPushButton" name="pb_thread">
<property name="text">
<string>Mve</string>
<string>Thread</string>
</property>
</widget>
</item>
+40 -19
View File
@@ -26,7 +26,7 @@ class Ui_fluencyCAD(object):
def setupUi(self, fluencyCAD):
if not fluencyCAD.objectName():
fluencyCAD.setObjectName(u"fluencyCAD")
fluencyCAD.resize(2551, 1248)
fluencyCAD.resize(2551, 1265)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
@@ -249,6 +249,9 @@ class Ui_fluencyCAD(object):
self.gridLayout_4.setObjectName(u"gridLayout_4")
self.pb_con_ptpt = QPushButton(self.groupBox_3)
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
icon = QIcon()
icon.addFile(u"icons/pt_pt.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.pb_con_ptpt.setIcon(icon)
self.pb_con_ptpt.setCheckable(True)
self.pb_con_ptpt.setAutoExclusive(False)
@@ -668,35 +671,50 @@ 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_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 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.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.pb_fillet_op = QPushButton(self.groupBox)
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
self.gridLayout_3.addWidget(self.pb_fillet_op, 3, 0, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_cutop = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.pb_face_op = QPushButton(self.groupBox)
self.pb_face_op.setObjectName(u"pb_face_op")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.gridLayout_3.addWidget(self.pb_face_op, 3, 1, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.pb_thread = QPushButton(self.groupBox)
self.pb_thread.setObjectName(u"pb_thread")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.gridLayout_3.addWidget(self.pb_thread, 4, 0, 1, 1)
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
@@ -909,12 +927,15 @@ 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_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None))
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi
File diff suppressed because it is too large Load Diff
+76 -1
View File
@@ -42,6 +42,7 @@ from fluency.models.data_model import (
Body,
Component,
Connector,
Feature,
Project,
Sketch,
Workplane,
@@ -163,6 +164,48 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
return wp
def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"""Serialize one parametric feature (sketch id + params).
"base" snapshot features are NOT serialized here their frozen
geometry is written as a separate STEP member (``base_geometry_ref``)
and the ``features_base_snapshot`` flag on the body marks that the
list starts with one.
"""
return {
"id": feat.id,
"operation": feat.operation,
"sketch_id": feat.sketch.id if feat.sketch is not None else None,
"length": feat.length,
"symmetric": bool(feat.symmetric),
"invert": bool(feat.invert),
"through_all": bool(feat.through_all),
"cut_all_bodies": bool(feat.cut_all_bodies),
"face_index": feat.face_index,
"angle": float(feat.angle),
}
def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Feature:
"""Deserialize a feature, resolving its sketch reference against the
component's already-loaded sketches."""
feat = Feature(
id=data.get("id") or None,
operation=data.get("operation", "extrude"),
length=data.get("length"),
symmetric=bool(data.get("symmetric", False)),
invert=bool(data.get("invert", False)),
through_all=bool(data.get("through_all", False)),
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
face_index=data.get("face_index"),
angle=float(data.get("angle", 360.0)),
)
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
return feat
def _body_to_dict(body: Body) -> Dict[str, Any]:
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
once the STEP file is written."""
@@ -180,6 +223,9 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"extrude_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id,
"features": [_feature_to_dict(f) for f in body.features if f.operation != "base"],
"features_base_snapshot": bool(body.features and body.features[0].operation == "base"),
"base_geometry_ref": None, # filled in by save_project
"position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
@@ -337,7 +383,22 @@ def _component_from_dict(
src_id = body_data.get("source_sketch_id")
if src_id and src_id in comp.sketches:
src_sketch = comp.sketches[src_id]
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
body = _body_from_dict(body_data, body_geometry_loader, src_sketch)
# Parametric feature history (new files). Old files carry no
# "features" key — the body keeps an empty list and is migrated
# lazily at update time (see ``_ensure_feature_history``).
for f_data in body_data.get("features") or []:
body.features.append(_feature_from_dict(f_data, comp.sketches))
if body_data.get("features_base_snapshot") and body.features:
# The list was saved WITHOUT its leading "base" snapshot;
# restore it from the dedicated STEP member.
base_geom: Optional[OCCGeometryObject] = None
base_ref = body_data.get("base_geometry_ref")
if base_ref and body_geometry_loader is not None:
base_geom = body_geometry_loader(base_ref)
if base_geom is not None:
body.features.insert(0, Feature(operation="base", geometry=base_geom))
comp.bodies[bid] = body
return comp
@@ -582,6 +643,20 @@ def save_project(
arcname = f"bodies/{body_id}.step"
body_files.append((arcname, step_bytes))
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
# Base-snapshot STEP for migrated legacy bodies whose feature
# list starts with a frozen "base" geometry snapshot.
if (
body.features
and body.features[0].operation == "base"
and body.features[0].geometry is not None
):
base_bytes = _write_step_for_body(kernel, body.features[0].geometry)
if base_bytes is not None:
base_arcname = f"bodies/{body_id}_base.step"
body_files.append((base_arcname, base_bytes))
manifest["components"][comp_id]["bodies"][body_id]["base_geometry_ref"] = (
base_arcname
)
# Per-sketch STEP files (solved face geometry).
sketch_files: List[Tuple[str, bytes]] = []
+46
View File
@@ -206,6 +206,46 @@ class Sketch:
self.modified_at = datetime.now()
@dataclass
class Feature:
"""
One operation in a body's parametric feature history.
Bodies rebuild their geometry by replaying their ordered feature
list from scratch (see ``Body.features``). This is what makes
sketch edits propagate: a moved circle re-cuts at the new position
on a freshly rebuilt base instead of adding to the previous result.
``operation`` is one of:
- "extrude": base solid, ``kernel.extrude`` of the sketch profile
- "revolve": base solid, ``kernel.revolve`` of the sketch profile
- "cut": boolean difference of the running geometry with the
extruded sketch profile
- "union": boolean union of the running geometry with the
extruded sketch profile
- "base": frozen geometry snapshot (``geometry`` field) used
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
operation: str = "extrude"
sketch: Optional[Sketch] = None # runtime ref; serialized as sketch_id
length: Optional[float] = None
symmetric: bool = False
invert: bool = False
through_all: bool = False
cut_all_bodies: bool = False
face_index: Optional[int] = None # which sketch face was selected
angle: float = 360.0 # revolve only (degrees)
# "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None
created_at: datetime = field(default_factory=datetime.now)
@dataclass
class Body:
"""
@@ -222,6 +262,12 @@ class Body:
source_sketch: Optional[Sketch] = None
source_operation: str = "extrude"
# Parametric feature history. When non-empty, the body is rebuilt
# from scratch by replaying these features in order; the flat
# extrude_* / source_* fields below then only mirror the LAST
# feature for backward compatibility (old files, old code paths).
features: List[Feature] = field(default_factory=list)
# Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto-
+471 -177
View File
@@ -6,7 +6,7 @@ import math
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Slot, QSize, QSettings
from PySide6.QtGui import (
@@ -41,7 +41,7 @@ from PySide6.QtWidgets import (
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
from fluency.io.project_io import load_project, project_zip_path, save_project
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane, Feature
from fluency.ui.dialogs import (
ExtrudeDialog,
@@ -574,6 +574,158 @@ def _offset_polygon(
return result
# ── Parametric feature replay ──────────────────────────────────────────────
def _ensure_feature_history(body: Body) -> List[Feature]:
"""Return *body*'s feature list, migrating legacy bodies lazily.
Bodies saved before feature history existed only carry the flat
``source_sketch`` / ``extrude_*`` fields describing the LAST
operation. Migration synthesizes a feature list from them:
- plain extrude a single "extrude" feature (replays cleanly,
no information was lost);
- cut / union a frozen "base" snapshot of the current geometry
plus the cut/union feature. The previous operation's effect
stays baked into the snapshot (a permanent scar), but every
FUTURE sketch edit re-applies cleanly instead of duplicating.
"""
if body.features:
return body.features
if body.source_sketch is None or body.extrude_length is None:
return [] # imported / baked body — nothing parametric to replay
if body.extrude_cut or body.extrude_union:
if body.geometry is not None:
body.features.append(Feature(operation="base", geometry=body.geometry))
op = "cut" if body.extrude_cut else "union"
else:
op = "extrude"
body.features.append(
Feature(
operation=op,
sketch=body.source_sketch,
length=body.extrude_length,
symmetric=body.extrude_symmetric,
invert=body.extrude_invert,
through_all=body.extrude_through_all,
cut_all_bodies=body.extrude_cut_all_bodies,
face_index=body.extrude_face_index,
)
)
logger.info(f"Body '{body.name}': migrated legacy params to feature history")
return body.features
def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) -> Optional[Any]:
"""Resolve the profile geometry for a feature's sketch.
Prefers the stored selected face (which can include holes);
falls back to the whole-sketch profile when the face topology
changed or no face was selected.
"""
face_geom: Optional[Any] = None
if feat.face_index is not None:
faces = occ_sketch.detect_faces()
if 0 <= feat.face_index < len(faces):
face_geom = occ_sketch.build_face_geometry(faces[feat.face_index])
elif faces:
# Face index out of range (topology changed) — fall back
# to the first face.
face_geom = occ_sketch.build_face_geometry(faces[0])
logger.info(
f"Body '{body.name}': face index {feat.face_index} invalid, fell back to face 0"
)
if face_geom is None:
face_geom = occ_sketch.get_geometry()
return face_geom
def _replay_body_features(
kernel: OCGeometryKernel,
body: Body,
features: List[Feature],
through_all_length_fn: Callable[[Any, Sketch], float],
) -> Optional[Any]:
"""Replay *features* in order and return the resulting geometry.
Returns *None* when the replay cannot complete (missing sketch,
empty profile, failed kernel op) the caller then keeps the
body's previous geometry.
"""
geom: Optional[Any] = None
for feat in features:
if feat.operation == "base":
geom = feat.geometry
continue
sketch = feat.sketch
if sketch is None or sketch.occ_sketch is None:
logger.warning(
f"Body '{body.name}': {feat.operation} feature has no sketch, replay aborted"
)
return None
# Re-solve the sketch so geometry reflects any edits.
sketch.apply_workplane()
sketch.solve()
face_geom = _feature_face_geometry(body, feat, sketch.occ_sketch)
if face_geom is None:
logger.warning(
f"Body '{body.name}': no profile geometry for {feat.operation}, replay aborted"
)
return None
if feat.operation == "revolve":
geom = kernel.revolve(face_geom, feat.angle)
if geom is None:
return None
continue
# extrude / cut / union all need the extruded profile as tool.
if feat.through_all and geom is not None:
# Pass-through: size the tool against the solid built so far.
length = through_all_length_fn(geom, sketch)
symmetric = True
invert = False
elif feat.operation == "cut" and geom is not None:
# Mirror _compute_extrude_result: a cut tool must go INTO
# the solid (the picked face's outward normal points AWAY),
# so force the extrude direction inward.
length = feat.length if feat.length is not None else 10.0
symmetric = feat.symmetric
invert = True
else:
length = feat.length if feat.length is not None else 10.0
symmetric = feat.symmetric
invert = feat.invert
tool_geom = kernel.extrude(face_geom, -length if invert else length, symmetric=symmetric)
if tool_geom is None:
return None
if feat.operation == "extrude":
geom = tool_geom # plain extrude: the tool IS the result
elif feat.operation == "cut":
if geom is None:
logger.warning(f"Body '{body.name}': cut feature has no base, replay aborted")
return None
geom = kernel.boolean_difference(geom, tool_geom)
elif feat.operation == "union":
if geom is None:
logger.warning(f"Body '{body.name}': union feature has no base, replay aborted")
return None
geom = kernel.boolean_union(geom, tool_geom)
else:
logger.warning(f"Body '{body.name}': unknown feature op '{feat.operation}', skipped")
if geom is None:
return None
return geom
class MainWindow(QMainWindow):
"""Main application window."""
@@ -1021,6 +1173,7 @@ class MainWindow(QMainWindow):
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
self._sketch_widget.solver_warning.connect(self._on_solver_warning)
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
@@ -1108,6 +1261,37 @@ class MainWindow(QMainWindow):
self._btn_con_vert.setChecked(True)
def _on_construct_change(self, checked):
"""Handle the "Cstrct" toolbar button.
The button is checkable, so a click both toggles the construct
mode for *new* geometry and (when the user has hovered an
existing line) promotes that line to a construction line. The
line-conversion path lets a user "select" a line by hovering
it and then press the construction button to convert it the
button stays in the "on" state so subsequent new geometry is
also created as construction.
If no line is hovered the click is the original pure
construct-mode toggle for new geometry.
"""
# 1) Try to convert the hovered line first. This mirrors the
# existing C-key shortcut (``_toggle_hovered_line_construction``
# on the sketch widget) but is exposed publicly so the
# toolbar button can drive the same action.
converted = self._sketch_widget.convert_hovered_line_to_construction()
if converted:
# A line was promoted — ensure the button reflects the
# "construction mode is on" state regardless of what the
# user just clicked. Without this, clicking the button
# to turn it OFF while a line was hovered would silently
# re-promote that line AND drop construct mode for new
# geometry, which is confusing. Forcing the button on
# makes the action unambiguous: "make this construction".
self._btn_construct.setChecked(True)
self._sketch_widget.set_construct_mode(True)
return
# 2) No line hovered — fall back to the original behaviour:
# toggle construct mode for *new* geometry.
self._sketch_widget.set_construct_mode(checked)
def _on_constrain_done(self):
@@ -1130,12 +1314,64 @@ class MainWindow(QMainWindow):
btn.setChecked(False)
self._sketch_widget.set_mode(None)
def _on_solver_warning(self, message: str) -> None:
"""Show solver-failure messages in the status bar.
Connected to ``Sketch2DWidget.solver_warning``, which fires when
the SolveSpace solver returns a non-OKAY result (INCONSISTENT,
DIDNT_CONVERGE, ). Without this hook the geometry would just
silently stay put and the user would think the constraint had
no effect; with the status-bar message the failure is obvious
and includes a one-line hint (e.g. "the new constraint
conflicts with existing constraints").
The message stays visible for 8 seconds long enough to read
but short enough not to be annoying if the user fixes the
issue and continues editing.
"""
self.statusBar().showMessage(f"{message}", 8000)
def _on_sketch_updated(self):
"""Mark bodies as needing update when their source sketch changes."""
"""Mark bodies as needing update when their source sketch changes.
A body is marked when ANY feature in its history references the
edited sketch (the flat ``source_sketch`` only mirrors the last
feature). The marking then propagates transitively: sketches
hosted on faces of a marked body follow its geometry, so bodies
built from THOSE sketches are marked too, and so on down the
dependency chain.
"""
if not self._current_component or not self._current_sketch:
return
sketch = self._current_sketch
def _body_sketches(b: Body) -> List[Sketch]:
out = [f.sketch for f in b.features if f.sketch is not None]
if b.source_sketch is not None:
out.append(b.source_sketch)
return out
affected: set = set()
for body in self._current_component.bodies.values():
if body.source_sketch is self._current_sketch and body.extrude_length is not None:
if sketch in _body_sketches(body):
affected.add(body.id)
# Transitive closure: a sketch sitting on a face of an affected
# body moves with it → bodies using that sketch are affected too.
changed = True
while changed:
changed = False
for body in self._current_component.bodies.values():
if body.id in affected:
continue
for s in _body_sketches(body):
if getattr(s, "_source_body_id", None) in affected:
affected.add(body.id)
changed = True
break
for body in self._current_component.bodies.values():
if body.id in affected:
body.needs_update = True
self._refresh_lists()
# Update undo/redo menu actions
@@ -1249,15 +1485,26 @@ class MainWindow(QMainWindow):
self._body_list.addItem(item)
def _update_and_redraw(self):
"""Full pipeline: re-extrude from sketch, redraw, propagate to assembly.
"""Full pipeline: rebuild bodies, redraw, propagate to assembly.
Connected to the 'Update Body' button. Rebuilds body geometry
from source sketches, refreshes the component view, propagates
changes to all assembly instances, and recalculates connectors.
Connected to the 'Update Body' button. Bodies are rebuilt by
replaying their feature history; sketches hosted on body faces
are then re-projected and solved. Because a face-sketch's
geometry depends on the body it sits on, the bodiessketches
cycle repeats until nothing moves anymore (bounded), so changes
to sketches early in the design propagate all the way down the
dependent chain. Then the view, assembly instances and
connectors are refreshed.
"""
self._update_bodies_from_sketch()
self._update_sketches_from_bodies()
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
MAX_PASSES = 5
for _ in range(MAX_PASSES):
self._update_bodies_from_sketch()
if self._update_sketches_from_bodies() == 0:
break
else:
# Still converging after the cap — one last body pass so the
# final geometry is built from the freshest sketch state.
self._update_bodies_from_sketch()
self._redraw_bodies()
self._propagate_to_assembly()
self._recalculate_connectors()
@@ -1265,155 +1512,51 @@ class MainWindow(QMainWindow):
self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self):
"""Re-extrude bodies whose source sketch has been edited.
"""Rebuild bodies by replaying their parametric feature history.
For every body in the current component that has a ``source_sketch``
and stored extrude parameters, re-solve the sketch and rebuild the
body geometry. Cut / union operations are re-applied against the
current target body.
Every body with a feature list is rebuilt FROM SCRATCH: the base
feature (extrude / revolve / snapshot) is recomputed from its
source sketch, then each cut / union re-applies in order against
the freshly built geometry. Because the base is rebuilt clean,
a moved sketch entity REPLACES its previous effect instead of
piling on top of it (e.g. a moved circle re-cuts one hole at the
new position the old hole is gone).
Legacy bodies without a feature list are migrated lazily (see
:meth:`_ensure_feature_history`).
"""
if not self._current_component:
return
updated = 0
for body_id, body in list(self._current_component.bodies.items()):
if body.source_sketch is None or body.extrude_length is None:
continue # not a re-extrudable body
features = _ensure_feature_history(body)
if not features:
continue # imported / baked body — nothing parametric
sketch = body.source_sketch
if not sketch.occ_sketch:
logger.debug(f"Body '{body.name}': source sketch has no OCC entity, skipping")
continue
# Re-solve the sketch so geometry reflects any edits.
sketch.apply_workplane()
sketch.solve()
# Resolve the face geometry.
face_geom = None
if body.extrude_face_index is not None:
faces = sketch.occ_sketch.detect_faces()
if 0 <= body.extrude_face_index < len(faces):
face_geom = sketch.occ_sketch.build_face_geometry(
faces[body.extrude_face_index]
)
else:
# Face index out of range (topology changed) — fallback
# to first face if available.
if faces:
face_geom = sketch.occ_sketch.build_face_geometry(faces[0])
logger.info(
f"Body '{body.name}': face index {body.extrude_face_index} "
f"invalid, fell back to face 0"
)
if face_geom is None:
face_geom = sketch.occ_sketch.get_geometry()
if face_geom is None:
logger.warning(f"Body '{body.name}': no geometry from sketch, skipping")
continue
# Resolve the target body for cut / union.
target = None
if body.extrude_cut or body.extrude_union:
if (
body.extrude_target_body_id
and body.extrude_target_body_id in self._current_component.bodies
):
target = self._current_component.bodies[body.extrude_target_body_id]
else:
# Fallback: first body that isn't this one.
for bid, b in self._current_component.bodies.items():
if bid != body_id and b.geometry is not None:
target = b
break
# Handle cut_all_bodies: apply the cut to every body in the
# component, not just the target.
if body.extrude_cut and body.extrude_cut_all_bodies:
try:
if body.extrude_through_all and target is not None:
cut_length = self._through_all_length(target, sketch)
cut_symmetric = True
cut_invert = False
else:
cut_length = body.extrude_length or 10.0
cut_symmetric = body.extrude_symmetric
cut_invert = body.extrude_invert
tool_geom = self._kernel.extrude(
face_geom,
-cut_length if cut_invert else cut_length,
symmetric=cut_symmetric,
)
if tool_geom is None:
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
continue
cut_count = 0
for other_id, other in list(self._current_component.bodies.items()):
if other.geometry is None:
continue
try:
other.geometry = self._kernel.boolean_difference(
other.geometry, tool_geom
)
other.needs_update = False
other.modified_at = datetime.now()
cut_count += 1
except Exception:
pass # body doesn't intersect tool, skip
body.needs_update = False
body.modified_at = datetime.now()
updated += 1
logger.info(
f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
)
except Exception as exc:
logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
continue # skip the single-target path below
# Compute the new result.
try:
if body.extrude_through_all and target is not None:
length = self._through_all_length(target, sketch)
result = self._compute_extrude_result(
sketch,
face_geom,
length,
symmetric=True,
invert=False,
cut=body.extrude_cut,
union=body.extrude_union,
through_all=True,
)
else:
length = body.extrude_length
result = self._compute_extrude_result(
sketch,
face_geom,
length,
symmetric=body.extrude_symmetric,
invert=body.extrude_invert,
cut=body.extrude_cut,
union=body.extrude_union,
through_all=False,
)
if result is None or result["result_geom"] is None:
logger.warning(f"Body '{body.name}': re-extrude produced no geometry")
continue
body.geometry = result["result_geom"]
body.needs_update = False
body.modified_at = datetime.now()
updated += 1
logger.info(f"Re-extruded body: {body.name}")
new_geom = _replay_body_features(
self._kernel, body, features, self._through_all_length_for_geometry
)
except Exception as exc:
logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
continue
if new_geom is None:
# Replay aborted — keep the previous geometry and the
# needs_update flag so the ⚠ marker stays visible.
continue
body.geometry = new_geom
body.needs_update = False
body.modified_at = datetime.now()
updated += 1
logger.info(f"Rebuilt body from features: {body.name}")
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
def _update_sketches_from_bodies(self) -> None:
def _update_sketches_from_bodies(self) -> int:
"""Re-project underlay construction lines from updated 3D bodies.
For every sketch in the current component that carries a
@@ -1423,9 +1566,13 @@ class MainWindow(QMainWindow):
external entities *in place* (preserving entity ids so existing
constraints survive). The solver is re-run so any user geometry
anchored to the underlay follows the body.
Returns the number of sketches whose underlay actually moved
the caller (``_update_and_redraw``) uses this to decide whether
another body-rebuild pass is needed.
"""
if not self._current_component:
return
return 0
from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel()
@@ -1493,6 +1640,7 @@ class MainWindow(QMainWindow):
self._sketch_widget.update()
if updated > 0:
logger.info("Re-projected underlays for %d sketch(es)", updated)
return updated
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
@@ -3777,6 +3925,45 @@ class MainWindow(QMainWindow):
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
def _record_feature(
self,
body: Body,
operation: str,
sketch: Optional[Sketch],
length: Optional[float] = None,
symmetric: bool = False,
invert: bool = False,
through_all: bool = False,
cut_all_bodies: bool = False,
face_index: Optional[int] = None,
angle: float = 360.0,
) -> None:
"""Append *operation* to *body*'s parametric feature history.
If the body has no features yet but already holds geometry (a
legacy body created before feature history existed), a frozen
"base" snapshot of the CURRENT geometry is inserted first so
replays start from a known state. Call this BEFORE assigning
the new geometry onto ``body.geometry`` the snapshot must
capture the pre-operation state.
"""
if not body.features and body.geometry is not None and operation in ("cut", "union"):
body.features.append(Feature(operation="base", geometry=body.geometry))
logger.info(f"Body '{body.name}': snapshotted legacy geometry as feature base")
body.features.append(
Feature(
operation=operation,
sketch=sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=through_all,
cut_all_bodies=cut_all_bodies,
face_index=face_index,
angle=angle,
)
)
def _resolve_extrude_target(
self, sketch: Sketch, exclude_body: Optional[Body] = None
) -> Optional[Body]:
@@ -3804,22 +3991,45 @@ class MainWindow(QMainWindow):
return body
return None
def _find_extrude_body_for_sketch(self, sketch: Sketch) -> Optional[Body]:
"""Return the existing body that was created from *sketch* via a
plain extrude, or *None* if no such body exists.
Used by the plain-extrude path of :meth:`_extrude_sketch` to decide
between "update the existing body in place" and "create a new
body". Without this, clicking Extrude a second time on the same
sketch stacks a fresh 44mm solid on top of the original 44mm
solid and the user perceives the apparent depth as 88mm.
"""
if self._current_component is None or sketch is None:
return None
for body in self._current_component.bodies.values():
if body.source_sketch is sketch and body.source_operation == "extrude":
return body
return None
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
*through* the target body.
*through* the target body. See :meth:`_through_all_length_for_geometry`.
"""
return self._through_all_length_for_geometry(target.geometry, sketch)
Computes the target body's bounding-box extent along the sketch's
workplane normal direction ("extent" = how far the body reaches on
def _through_all_length_for_geometry(self, geometry: Any, sketch: Sketch) -> float:
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
*through* a body geometry.
Computes the geometry's bounding-box extent along the sketch's
workplane normal direction ("extent" = how far the solid reaches on
either side of the face). With ``symmetric=True`` the kernel
extrudes ``± height/2``, so to clear the full ``extent`` on each
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
on each side guarantees the tool pokes out past the body so the
on each side guarantees the tool pokes out past the solid so the
boolean reliably removes the through volume.
"""
import numpy as _np
try:
p_min, p_max = self._kernel.get_bounding_box(target.geometry)
p_min, p_max = self._kernel.get_bounding_box(geometry)
except Exception:
logger.debug("through-all bbox failed", exc_info=True)
return 2000.0 # generous fallback if bbox fails for any reason
@@ -4087,10 +4297,23 @@ class MainWindow(QMainWindow):
for body in all_targets:
try:
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
body.geometry = new_geom
except Exception as exc:
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
continue
# Record the feature BEFORE committing the geometry so
# a legacy body snapshots its pre-cut state as base.
self._record_feature(
body,
"cut",
sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
cut_all_bodies=True,
face_index=face_index,
)
body.geometry = new_geom
body.extrude_length = length
body.extrude_symmetric = symmetric
body.extrude_invert = invert
@@ -4111,7 +4334,19 @@ class MainWindow(QMainWindow):
body_name = f"{updated_count} body(ies)"
elif target is not None:
# Single-body cut / union: commit the result onto the *target*
# body in place.
# body in place. Record the feature BEFORE committing so a
# legacy body snapshots its pre-op geometry as base.
self._record_feature(
target,
"cut" if cut else "union",
sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
cut_all_bodies=False,
face_index=face_index,
)
target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
@@ -4133,30 +4368,81 @@ class MainWindow(QMainWindow):
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
body_name = target.name
else:
# Plain extrude: create a new body for the extrusion.
body = self._current_component.add_body(
Body(
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
geometry=result["result_geom"],
source_sketch=sketch,
source_operation="extrude",
extrude_length=length,
extrude_symmetric=symmetric,
extrude_invert=invert,
extrude_cut=False,
extrude_union=False,
extrude_through_all=bool(through_all),
extrude_cut_all_bodies=False,
extrude_face_index=face_index,
# Plain extrude. If this sketch already produced an
# existing extrude body, UPDATE that body in place rather
# than stacking a fresh 44mm solid on top of the old one
# — which the user perceives as "44mm looks like 88mm"
# because the two coincident solids visually sum.
existing = self._find_extrude_body_for_sketch(sketch)
if existing is not None:
body = existing
# Record the new feature BEFORE replacing geometry so
# the parametric history reflects the latest op.
self._record_feature(
body,
"extrude",
sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
face_index=face_index,
)
)
self._mark_dirty()
logger.info(f"Created body: {body.name}")
logger.debug("Adding shape to OCC viewer")
shape = self._kernel._get_shape(body.geometry)
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
logger.info(f"Render object: {body.render_object}")
body_name = body.name
body.geometry = result["result_geom"]
body.extrude_length = length
body.extrude_symmetric = symmetric
body.extrude_invert = invert
body.extrude_cut = False
body.extrude_union = False
body.extrude_through_all = bool(through_all)
body.extrude_cut_all_bodies = False
body.extrude_face_index = face_index
body.source_sketch = sketch
body.source_operation = "extrude"
body.extrude_target_body_id = None
self._mark_dirty()
logger.info(f"Updated existing body in place: {body.name}")
if body.render_object is not None:
self._viewer_3d.remove_mesh(body.render_object)
shape = self._kernel._get_shape(body.geometry)
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
body_name = body.name
else:
# Plain extrude: create a new body for the extrusion.
body = self._current_component.add_body(
Body(
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
geometry=result["result_geom"],
source_sketch=sketch,
source_operation="extrude",
extrude_length=length,
extrude_symmetric=symmetric,
extrude_invert=invert,
extrude_cut=False,
extrude_union=False,
extrude_through_all=bool(through_all),
extrude_cut_all_bodies=False,
extrude_face_index=face_index,
features=[
Feature(
operation="extrude",
sketch=sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
face_index=face_index,
)
],
)
)
self._mark_dirty()
logger.info(f"Created body: {body.name}")
logger.debug("Adding shape to OCC viewer")
shape = self._kernel._get_shape(body.geometry)
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
logger.info(f"Render object: {body.render_object}")
body_name = body.name
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
@@ -4210,6 +4496,14 @@ class MainWindow(QMainWindow):
geometry=body_geometry,
source_sketch=sketch,
source_operation="revolve",
features=[
Feature(
operation="revolve",
sketch=sketch,
angle=angle,
face_index=self._sketch_widget.get_selected_face_index(),
)
],
)
)
self._mark_dirty()
+328 -69
View File
@@ -97,6 +97,12 @@ class Sketch2DWidget(QWidget):
constrain_done = Signal()
sketch_updated = Signal()
# Emitted when the SolveSpace solver returns a non-OKAY result
# (INCONSISTENT, DIDNT_CONVERGE, or an exception). The payload is
# a short human-readable explanation; the main window hooks this up
# to the status bar so the user sees the failure instead of the
# geometry silently staying put.
solver_warning = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
@@ -524,6 +530,24 @@ class Sketch2DWidget(QWidget):
return False
return entity.id in self._sketch._centerline_ids
@staticmethod
def _flat_xy(geometry: Any) -> Optional[Tuple[float, float]]:
"""Return ``(x, y)`` when *geometry* is a flat 2-tuple of numbers (a point).
Guards the point-iteration loops against malformed point geometry
(e.g. circle-shaped ``((cx, cy), r)`` left behind by a legacy
load or constraint replay) so a bad entity degrades to "not
snappable / not drawn" instead of crashing ``round()``.
"""
if (
isinstance(geometry, (tuple, list))
and len(geometry) == 2
and isinstance(geometry[0], (int, float))
and isinstance(geometry[1], (int, float))
):
return (float(geometry[0]), float(geometry[1]))
return None
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
@@ -624,6 +648,45 @@ class Sketch2DWidget(QWidget):
self.setCursor(Qt.ArrowCursor)
self.update()
def _is_drawing_tool_active(self) -> bool:
"""Return True while a drawing or constraint tool is in progress.
Used to decide whether to render the constraint badges / dimension
lines in ``paintEvent``. They are visually loud and frequently
overlap the geometry the user is currently trying to place, so we
suppress them while an operation is mid-flight and bring them back
as soon as the operation is finished, cancelled, or never started
(``mode is None`` or ``mode == "select"``).
"""
if self._mode is None or self._mode == "select":
return False
return True
def _cancel_active_tool(self) -> None:
"""Abort the currently active drawing / constraint tool.
Single source of truth for "deselect the current tool" called
from the Escape key handler, the right-click cancel branch, and
anywhere else that needs to drop the user back to a neutral state.
Resets the draw buffer, arc-sweep tracker, and any in-progress
constraint multi-select, then emits ``constrain_done`` so the
toolbar buttons uncheck themselves.
"""
if not self._is_drawing_tool_active():
return
self._mode = None
self._draw_buffer = []
self._dynamic_line_end = None
self._selected_entities = []
self._hovered_constraint_idx = -1
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
# Invalidate any pending dimension-input prompt so the value
# dialog (if one is open elsewhere) doesn't latch onto stale state.
self._pending_distance_val = None
self.constrain_done.emit()
self.update()
def set_construct_mode(self, enabled: bool):
self._is_construct = enabled
@@ -683,9 +746,12 @@ class Sketch2DWidget(QWidget):
# 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)
if entity is None or entity.geometry is None:
if entity is None:
return None
x, y = entity.geometry
xy = self._flat_xy(entity.geometry)
if xy is None:
return None
x, y = xy
return QPoint(int(round(x)), int(round(y)))
def _find_nearest_point_entity(
@@ -705,16 +771,16 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if self._is_external(entity) and not self._underlay_visible:
continue
if entity.geometry:
x, y = entity.geometry
point = QPoint(int(round(x)), int(round(y)))
screen_point = self._world_to_screen(point)
dist = math.sqrt(
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
)
if dist < min_dist:
min_dist = dist
nearest = entity
xy = self._flat_xy(entity.geometry)
if xy is None:
continue
x, y = xy
point = QPoint(int(round(x)), int(round(y)))
screen_point = self._world_to_screen(point)
dist = math.sqrt((pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2)
if dist < min_dist:
min_dist = dist
nearest = entity
return nearest
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
@@ -890,9 +956,12 @@ class Sketch2DWidget(QWidget):
nearest_entity = None
min_dist = self._snap_distance
for entity in self._points:
if entity.id in exclude_ids or not entity.geometry:
if entity.id in exclude_ids:
continue
x, y = entity.geometry
xy = self._flat_xy(entity.geometry)
if xy is None:
continue
x, y = xy
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
if d < min_dist:
@@ -990,11 +1059,13 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if self._is_external(entity) and not self._underlay_visible:
continue
if entity.geometry:
x, y = entity.geometry
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
if dist < self._pick_tolerance_world(10):
return entity
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 < self._pick_tolerance_world(10):
return entity
return None
def _get_line_entity_at(
@@ -1440,7 +1511,28 @@ class Sketch2DWidget(QWidget):
return None
def _sync_solved_positions(self):
"""Sync solver positions back to UI points and lines."""
"""Sync solver positions back to UI points, lines, AND arcs.
The UI keeps its own ``self._arcs`` list (a snapshot of
``(centre, radius, start, end, sweep)`` tuples built in
:meth:`_rebuild_from_sketch`) for fast access in
:meth:`paintEvent`. The radius in that list was a frozen copy
of the value at creation time before the sketch solver was
wired up to the arc entity, it never changed after a solve, so
arcs visually stayed the same size even when the user resized
the rectangle they were attached to.
Now that the sketch solver owns the arc (and refreshes the
radius from the current centrestart distance after every
solve in :meth:`OCCSketch._sync_solved_positions`), we mirror
that refresh here: read each arc's current radius / sweep
straight from the authoritative ``self._sketch._arcs`` dict and
update the corresponding tuple in ``self._arcs`` in place.
Tuples are immutable so we replace each entry with a freshly-
built tuple rather than mutating an item the ``paintEvent``
loop keeps working without re-running ``_rebuild_from_sketch``.
"""
if not self._sketch:
return
for entity in self._points:
@@ -1454,12 +1546,66 @@ class Sketch2DWidget(QWidget):
for p1_ent, p2_ent in self._lines:
if p1_ent.geometry and p2_ent.geometry:
pass # geometry already updated via point sync
# Refresh arc radii / sweeps from the sketch so a coincident-
# constrained arc (e.g. attached to a rectangle's corner) resizes
# with the rectangle. See OCCSketch._sync_solved_positions.
if self._arcs:
import math as _m
new_arcs = []
for centre_ent, _radius, start_ent, end_ent, _sweep in self._arcs:
arc_id = None
for aid, adata in self._sketch._arcs.items():
if adata.get("center") == centre_ent.id:
arc_id = aid
break
if arc_id is None:
# The sketch no longer has this arc (deleted by
# ``delete_point`` / project load). Drop it from
# the UI list so paintEvent doesn't draw a ghost.
continue
adata = self._sketch._arcs[arc_id]
new_radius = float(adata.get("radius", _radius))
new_sweep = adata.get("sweep", _sweep)
if (
new_sweep is None
and start_ent.geometry
and end_ent.geometry
and centre_ent.geometry
):
# Legacy arc without a stored sweep — fall back to the
# same shortest-path inference that paintEvent uses.
sx, sy = start_ent.geometry
ex, ey = end_ent.geometry
cx, cy = centre_ent.geometry
sa = _m.atan2(sy - cy, sx - cx)
ea = _m.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _m.pi:
new_sweep -= 2 * _m.pi
while new_sweep < -_m.pi:
new_sweep += 2 * _m.pi
new_arcs.append((centre_ent, new_radius, start_ent, end_ent, new_sweep))
self._arcs = new_arcs
def _solve_and_sync(self) -> bool:
"""Solve constraints, sync positions, update UI. Returns True if solved OK."""
"""Solve constraints, sync positions, update UI. Returns True if solved OK.
On a non-OKAY solve result (INCONSISTENT, DIDNT_CONVERGE, ...)
the geometry is left untouched and :attr:`solver_warning` is
emitted with a one-line human-readable explanation sourced from
:attr:`OCCSketch.last_solve_status`. The main window hooks this
to the status bar so the user sees the failure instead of the
geometry silently staying put.
"""
if not self._sketch:
return True
ok = self._sketch.solve()
if not ok:
# Surface the failure to the user. Status text comes from
# OCCSketch.last_solve_status (e.g. "inconsistent: the new
# constraint conflicts with existing constraints").
self.solver_warning.emit(f"Solver failed — {self._sketch.last_solve_status}")
self._sync_solved_positions()
self.update()
return ok
@@ -1635,16 +1781,13 @@ class Sketch2DWidget(QWidget):
return
if event.button() == Qt.RightButton:
# Only clear drawing-state, preserve any point selection for the
# context menu (contextMenuEvent is invoked by Qt after this).
if self._mode in ("line", "rectangle", "circle", "arc", "slot"):
self._mode = None
self._draw_buffer = []
self._dynamic_line_end = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
self.constrain_done.emit()
# Do NOT return — let Qt deliver the contextMenuEvent.
# Right-click cancels the active drawing/constraint tool (same
# behaviour as pressing Escape). Routed through the shared
# helper so right-click, Escape, and any future cancel path
# all perform the exact same teardown. We only suppress the
# tool itself — the context menu (if any) is still delivered
# by Qt afterwards, so we do NOT return here.
self._cancel_active_tool()
return
if event.button() == Qt.LeftButton:
@@ -1957,6 +2100,32 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync()
self._snap_point_target = None
else:
# Lock the moved anchor in its new location with a
# ``dragged`` constraint. ``set_positions`` alone only
# seeds the solver's initial state; with the many DOFs
# a free sketch typically has, the solver reverts the
# position to minimise the L2 parameter change. Adding
# ``dragged`` makes the move a hard constraint so the
# position sticks — and lets the arc constraint (now
# live in the solver, see OCCSketch._sync_solved_positions)
# propagate the change into a new centre/radius.
#
# Why only the anchor? ``_collect_connected_points``
# already moves every connected point by the same delta
# in mouseMoveEvent; locking all of them would freeze
# the entire connected component (e.g. a 4-corner
# rectangle becomes a rigid body and individual corners
# can no longer be dragged to resize it). Locking just
# the anchor lets the other points adjust under the
# line / arc / coincident constraints, which is what
# the user expects from dragging a single corner.
if self._move_anchor is not None and self._move_anchor.geometry is not None:
ax, ay = self._move_anchor.geometry
if not self._sketch.is_entity_dragged(self._move_anchor.id):
self._sketch.constrain_fixed(self._move_anchor)
# constrain_fixed reads the current params via
# the dragged() call, so re-sync to be safe.
self._solve_and_sync()
# Auto-constrain: snap → coincident / point-on-line
target = None
if self._snap_point_target is not None:
@@ -2054,6 +2223,17 @@ class Sketch2DWidget(QWidget):
event.accept()
return
# Escape cancels the active drawing/constraint tool. This is
# the user-facing counterpart to right-click: drop the in-progress
# operation (without losing the entities already placed) and
# uncheck the toolbar button so the constraint badges / dimension
# overlay reappears.
if event.key() == Qt.Key_Escape and not event.modifiers():
if self._is_drawing_tool_active():
self._cancel_active_tool()
event.accept()
return
super().keyPressEvent(event)
def contextMenuEvent(self, event):
@@ -2247,6 +2427,54 @@ class Sketch2DWidget(QWidget):
self.sketch_updated.emit()
self.update()
def convert_hovered_line_to_construction(self) -> bool:
"""Convert the hovered line to a construction line.
Public counterpart of ``_toggle_hovered_line_construction`` that
always sets the line to construction (not toggle). Wired to the
toolbar "Cstrct" button so that selecting an existing line and
pressing the button promotes it to a construction line the
same behaviour the C key provides via toggle, but with
button-as-action semantics.
Only the line entity's ``is_construction`` flag is changed; its
endpoint points are left alone so that other lines sharing the
same endpoint are unaffected. External (underlay) lines and
centerlines are rejected (matching the toggle behaviour).
Returns ``True`` if a line was converted, ``False`` otherwise
(no sketch, no hover, external/centerline, or already
construction). Callers use the return value to decide whether
the button should be left in the "on" state.
"""
line_ent = self._hovered_line_entity
if line_ent is None or self._sketch is None:
return False
# External (underlay) lines are reference geometry from the source
# face — they can't be promoted individually.
if getattr(line_ent, "is_external", False):
logger.debug("Refusing to convert external (underlay) line to construction")
return False
# Centerlines are permanent reference axes — refuse promotion.
if self._is_centerline(line_ent):
logger.debug("Refusing to convert centerline to construction")
return False
# Already construction — nothing to do, but still report success
# so the caller keeps the button in a consistent "on" state.
if getattr(line_ent, "is_construction", False):
return True
# Save state before converting
if self._undo_manager:
self._undo_manager.save_state()
line_ent.is_construction = True
logger.info(f"Converted line {line_ent.id} to construction via toolbar")
self._hovered_line = None
self._hovered_line_entity = None
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
return True
# ─── Drawing handlers ─────────────────────────────────────────────────
def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None:
@@ -2987,15 +3215,34 @@ class Sketch2DWidget(QWidget):
if self._undo_manager:
self._undo_manager.save_state()
new_radius = diameter / 2.0
# Update the circle's radius in the sketch
self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
# Update the circle's radius in the sketch. Note that
# ``self._circles`` holds (center_point_entity, radius)
# — ``c_ent`` is the CENTER point, not the circle. The
# sketch's ``_circles`` dict is keyed by circle id with
# values (center_id, radius), so we must find the circle
# entity whose center is the clicked point and route
# through ``constrain_diameter``. The old code wrote a
# bogus entry keyed by the center point id and left the
# real circle entry untouched with the OLD radius — that
# ghost circle kept forming a face and made hole cuts
# use the stale diameter.
circle_found = False
for cid, (center_id, _old_r) in self._sketch._circles.items():
if center_id == c_ent.id:
circle_ent = self._sketch._entities.get(cid)
if circle_ent is not None:
self._sketch.constrain_diameter(circle_ent, diameter)
circle_found = True
break
if not circle_found:
# Defensive fallback: keep the direct update rather
# than silently dropping the user's input.
self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
# Update the local cache
for i, (ent, rad) in enumerate(self._circles):
if ent.id == c_ent.id:
self._circles[i] = (ent, new_radius)
break
# Record constraint for undo/redo
self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = []
@@ -3285,8 +3532,9 @@ class Sketch2DWidget(QWidget):
# at the endpoints far out of view.
if self._is_centerline(entity):
continue
if entity.geometry:
x, y = entity.geometry
xy = self._flat_xy(entity.geometry)
if xy is not None:
x, y = xy
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
if entity.is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1))
@@ -3316,12 +3564,14 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if not self._is_external(entity):
continue
if entity.geometry:
x, y = entity.geometry
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387")))
painter.drawEllipse(screen_pos, 4, 4)
xy = self._flat_xy(entity.geometry)
if xy is None:
continue
x, y = xy
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387")))
painter.drawEllipse(screen_pos, 4, 4)
# ── Lines ──
for p1_ent, p2_ent in self._lines:
@@ -3352,35 +3602,44 @@ class Sketch2DWidget(QWidget):
# ── Constraint tags (log-driven; drawn upright in screen space) ──
# Tags are recomputed here so paint stays in sync with the latest solve.
# While a drawing or constraint tool is active the badges are hidden
# because their pills/extension lines routinely cover the very
# geometry the user is trying to place — they reappear as soon as
# the operation finishes, Escape is pressed, or the tool is
# otherwise deselected (see ``_is_drawing_tool_active``).
self._constraint_tags = self._compute_constraint_tags()
tag_font = QFont("Monospace", 9)
painter.setFont(tag_font)
for tag in self._constraint_tags:
rect: QRect = tag["rect"]
hovered = tag["idx"] == self._hovered_constraint_idx
# Background pill so the label is readable over the sketch.
painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
painter.drawRoundedRect(rect, 6, 6)
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
painter.drawText(rect, Qt.AlignCenter, tag["label"])
if not self._is_drawing_tool_active():
tag_font = QFont("Monospace", 9)
painter.setFont(tag_font)
for tag in self._constraint_tags:
rect: QRect = tag["rect"]
hovered = tag["idx"] == self._hovered_constraint_idx
# Background pill so the label is readable over the sketch.
painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
painter.drawRoundedRect(rect, 6, 6)
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
painter.drawText(rect, Qt.AlignCenter, tag["label"])
# ── Technical dimension lines for distance constraints ──
# Draw proper measurement lines (extension lines + dimension line
# with arrowheads + centred text) for every distance constraint.
for tag in self._constraint_tags:
if "p1_world" in tag and "p2_world" in tag:
try:
self._draw_technical_dimension(
painter,
tag["p1_world"],
tag["p2_world"],
tag["distance"],
)
except Exception:
import traceback
# ── Technical dimension lines for distance constraints ──
# Draw proper measurement lines (extension lines + dimension
# line with arrowheads + centred text) for every distance
# constraint. Gated together with the badges above so the
# whole constraint overlay hides as one unit while a tool is
# active.
for tag in self._constraint_tags:
if "p1_world" in tag and "p2_world" in tag:
try:
self._draw_technical_dimension(
painter,
tag["p1_world"],
tag["p2_world"],
tag["distance"],
)
except Exception:
import traceback
traceback.print_exc()
traceback.print_exc()
# ── Circles ──
for center_ent, radius in self._circles: