- 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" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <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$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" /> <change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/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/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -52,47 +49,47 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Python.2dtest.executor": "Run", &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
"Python.3d_windows.executor": "Run", &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
"Python.Unnamed.executor": "Run", &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
"Python.base.executor": "Run", &quot;Python.base.executor&quot;: &quot;Run&quot;,
"Python.data_model.executor": "Run", &quot;Python.data_model.executor&quot;: &quot;Run&quot;,
"Python.debug_dragging.executor": "Run", &quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
"Python.draw_widget2d.executor": "Run", &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
"Python.draw_widget_solve.executor": "Run", &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
"Python.fluency.executor": "Run", &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
"Python.fluencyb.executor": "Run", &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
"Python.gl_widget.executor": "Run", &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
"Python.gui_ui.executor": "Run", &quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
"Python.kernel.executor": "Run", &quot;Python.kernel.executor&quot;: &quot;Run&quot;,
"Python.main.executor": "Run", &quot;Python.main.executor&quot;: &quot;Run&quot;,
"Python.main_window.executor": "Run", &quot;Python.main_window.executor&quot;: &quot;Run&quot;,
"Python.meshtest.executor": "Run", &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
"Python.occ_renderer.executor": "Run", &quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
"Python.occ_to_mesh.executor": "Run", &quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
"Python.render_backend.executor": "Run", &quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
"Python.side_fluency.executor": "Run", &quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
"Python.simple_mesh.executor": "Run", &quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
"Python.sketch.executor": "Run", &quot;Python.sketch.executor&quot;: &quot;Run&quot;,
"Python.vtk_widget.executor": "Run", &quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
"Python.vulkan.executor": "Run", &quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"git-widget-placeholder": "feature/occ-migration", &quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency", &quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" &quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
} }
}]]></component> }</component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -446,7 +443,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783976498520</updated> <updated>1783976498520</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -467,7 +472,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" /> <MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" /> <MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" /> <MESSAGE value="- changing compos including sketches and bodies" />
@@ -492,6 +496,7 @@
<MESSAGE value="- added renderer" /> <MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" /> <MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" /> <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> </component>
</project> </project>
+43 -18
View File
@@ -7,7 +7,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>2551</width> <width>2551</width>
<height>1248</height> <height>1265</height>
</rect> </rect>
</property> </property>
<property name="sizePolicy"> <property name="sizePolicy">
@@ -218,7 +218,7 @@
</sizepolicy> </sizepolicy>
</property> </property>
<property name="currentIndex"> <property name="currentIndex">
<number>1</number> <number>0</number>
</property> </property>
<widget class="QWidget" name="sketch_tab"> <widget class="QWidget" name="sketch_tab">
<attribute name="title"> <attribute name="title">
@@ -372,6 +372,10 @@
<property name="text"> <property name="text">
<string>Pt_Pt</string> <string>Pt_Pt</string>
</property> </property>
<property name="icon">
<iconset>
<normaloff>icons/pt_pt.png</normaloff>icons/pt_pt.png</iconset>
</property>
<property name="checkable"> <property name="checkable">
<bool>true</bool> <bool>true</bool>
</property> </property>
@@ -1260,17 +1264,10 @@
<string>Modify</string> <string>Modify</string>
</property> </property>
<layout class="QGridLayout" name="gridLayout_3"> <layout class="QGridLayout" name="gridLayout_3">
<item row="2" column="1"> <item row="1" column="0">
<widget class="QPushButton" name="pb_revop"> <widget class="QPushButton" name="pb_combop">
<property name="text"> <property name="text">
<string>Rev</string> <string>Comb</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
<string>Extrd</string>
</property> </property>
</widget> </widget>
</item> </item>
@@ -1281,6 +1278,34 @@
</property> </property>
</widget> </widget>
</item> </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"> <item row="0" column="1">
<widget class="QPushButton" name="pb_cutop"> <widget class="QPushButton" name="pb_cutop">
<property name="text"> <property name="text">
@@ -1288,17 +1313,17 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="0"> <item row="3" column="1">
<widget class="QPushButton" name="pb_combop"> <widget class="QPushButton" name="pb_face_op">
<property name="text"> <property name="text">
<string>Comb</string> <string>Phase</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="1"> <item row="4" column="0">
<widget class="QPushButton" name="pb_moveop"> <widget class="QPushButton" name="pb_thread">
<property name="text"> <property name="text">
<string>Mve</string> <string>Thread</string>
</property> </property>
</widget> </widget>
</item> </item>
+40 -19
View File
@@ -26,7 +26,7 @@ class Ui_fluencyCAD(object):
def setupUi(self, fluencyCAD): def setupUi(self, fluencyCAD):
if not fluencyCAD.objectName(): if not fluencyCAD.objectName():
fluencyCAD.setObjectName(u"fluencyCAD") fluencyCAD.setObjectName(u"fluencyCAD")
fluencyCAD.resize(2551, 1248) fluencyCAD.resize(2551, 1265)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
@@ -249,6 +249,9 @@ class Ui_fluencyCAD(object):
self.gridLayout_4.setObjectName(u"gridLayout_4") self.gridLayout_4.setObjectName(u"gridLayout_4")
self.pb_con_ptpt = QPushButton(self.groupBox_3) self.pb_con_ptpt = QPushButton(self.groupBox_3)
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt") 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.setCheckable(True)
self.pb_con_ptpt.setAutoExclusive(False) self.pb_con_ptpt.setAutoExclusive(False)
@@ -668,35 +671,50 @@ class Ui_fluencyCAD(object):
self.groupBox.setMaximumSize(QSize(200, 16777215)) self.groupBox.setMaximumSize(QSize(200, 16777215))
self.gridLayout_3 = QGridLayout(self.groupBox) self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3") self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_revop = QPushButton(self.groupBox) self.pb_combop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop") self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1) self.gridLayout_3.addWidget(self.pb_combop, 1, 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_arrayop = QPushButton(self.groupBox) self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop") self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1) 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 = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop") self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1) self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_combop = QPushButton(self.groupBox) self.pb_face_op = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop") 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_thread = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop") 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) 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.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None)) self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None)) self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_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_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_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.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None)) self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi # 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, Body,
Component, Component,
Connector, Connector,
Feature,
Project, Project,
Sketch, Sketch,
Workplane, Workplane,
@@ -163,6 +164,48 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
return wp 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]: def _body_to_dict(body: Body) -> Dict[str, Any]:
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer """Body serialization. ``geometry_ref`` is set later by the ZIP writer
once the STEP file is written.""" 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_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index, "extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id, "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), "position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation), "rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8], "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") src_id = body_data.get("source_sketch_id")
if src_id and src_id in comp.sketches: if src_id and src_id in comp.sketches:
src_sketch = comp.sketches[src_id] 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 return comp
@@ -582,6 +643,20 @@ def save_project(
arcname = f"bodies/{body_id}.step" arcname = f"bodies/{body_id}.step"
body_files.append((arcname, step_bytes)) body_files.append((arcname, step_bytes))
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname 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). # Per-sketch STEP files (solved face geometry).
sketch_files: List[Tuple[str, bytes]] = [] sketch_files: List[Tuple[str, bytes]] = []
+46
View File
@@ -206,6 +206,46 @@ class Sketch:
self.modified_at = datetime.now() 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 @dataclass
class Body: class Body:
""" """
@@ -222,6 +262,12 @@ class Body:
source_sketch: Optional[Sketch] = None source_sketch: Optional[Sketch] = None
source_operation: str = "extrude" 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 # Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body # its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto- # 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 logging
import os import os
from datetime import datetime 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.QtCore import Qt, Slot, QSize, QSettings
from PySide6.QtGui import ( from PySide6.QtGui import (
@@ -41,7 +41,7 @@ from PySide6.QtWidgets import (
from fluency.geometry_occ.kernel import OCGeometryKernel from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
from fluency.io.project_io import load_project, project_zip_path, save_project 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 ( from fluency.ui.dialogs import (
ExtrudeDialog, ExtrudeDialog,
@@ -574,6 +574,158 @@ def _offset_polygon(
return result 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): class MainWindow(QMainWindow):
"""Main application window.""" """Main application window."""
@@ -1021,6 +1173,7 @@ class MainWindow(QMainWindow):
self._sketch_widget.constrain_done.connect(self._on_constrain_done) self._sketch_widget.constrain_done.connect(self._on_constrain_done)
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated) 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._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
self._body_list.currentItemChanged.connect(self._on_body_list_changed) self._body_list.currentItemChanged.connect(self._on_body_list_changed)
@@ -1108,6 +1261,37 @@ class MainWindow(QMainWindow):
self._btn_con_vert.setChecked(True) self._btn_con_vert.setChecked(True)
def _on_construct_change(self, checked): 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) self._sketch_widget.set_construct_mode(checked)
def _on_constrain_done(self): def _on_constrain_done(self):
@@ -1130,12 +1314,64 @@ class MainWindow(QMainWindow):
btn.setChecked(False) btn.setChecked(False)
self._sketch_widget.set_mode(None) 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): 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: if not self._current_component or not self._current_sketch:
return 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(): 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 body.needs_update = True
self._refresh_lists() self._refresh_lists()
# Update undo/redo menu actions # Update undo/redo menu actions
@@ -1249,15 +1485,26 @@ class MainWindow(QMainWindow):
self._body_list.addItem(item) self._body_list.addItem(item)
def _update_and_redraw(self): 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 Connected to the 'Update Body' button. Bodies are rebuilt by
from source sketches, refreshes the component view, propagates replaying their feature history; sketches hosted on body faces
changes to all assembly instances, and recalculates connectors. 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() MAX_PASSES = 5
self._update_sketches_from_bodies() for _ in range(MAX_PASSES):
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved 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._redraw_bodies()
self._propagate_to_assembly() self._propagate_to_assembly()
self._recalculate_connectors() self._recalculate_connectors()
@@ -1265,155 +1512,51 @@ class MainWindow(QMainWindow):
self._update_component_thumbnail(self._get_active_component_index()) self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self): 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`` Every body with a feature list is rebuilt FROM SCRATCH: the base
and stored extrude parameters, re-solve the sketch and rebuild the feature (extrude / revolve / snapshot) is recomputed from its
body geometry. Cut / union operations are re-applied against the source sketch, then each cut / union re-applies in order against
current target body. 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: if not self._current_component:
return return
updated = 0 updated = 0
for body_id, body in list(self._current_component.bodies.items()): for body_id, body in list(self._current_component.bodies.items()):
if body.source_sketch is None or body.extrude_length is None: features = _ensure_feature_history(body)
continue # not a re-extrudable 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: try:
if body.extrude_through_all and target is not None: new_geom = _replay_body_features(
length = self._through_all_length(target, sketch) self._kernel, body, features, self._through_all_length_for_geometry
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}")
except Exception as exc: 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: if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch") 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. """Re-project underlay construction lines from updated 3D bodies.
For every sketch in the current component that carries a 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 external entities *in place* (preserving entity ids so existing
constraints survive). The solver is re-run so any user geometry constraints survive). The solver is re-run so any user geometry
anchored to the underlay follows the body. 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: if not self._current_component:
return return 0
from fluency.geometry_occ.kernel import OCGeometryKernel from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel() kernel = OCGeometryKernel()
@@ -1493,6 +1640,7 @@ class MainWindow(QMainWindow):
self._sketch_widget.update() self._sketch_widget.update()
if updated > 0: if updated > 0:
logger.info("Re-projected underlays for %d sketch(es)", updated) logger.info("Re-projected underlays for %d sketch(es)", updated)
return updated
def _propagate_to_assembly(self): def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component. """Refresh all assembly instances that reference the current component.
@@ -3777,6 +3925,45 @@ class MainWindow(QMainWindow):
# ─── Extrude / cut helpers (shared by live preview + apply) ──────── # ─── 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( def _resolve_extrude_target(
self, sketch: Sketch, exclude_body: Optional[Body] = None self, sketch: Sketch, exclude_body: Optional[Body] = None
) -> Optional[Body]: ) -> Optional[Body]:
@@ -3804,22 +3991,45 @@ class MainWindow(QMainWindow):
return body return body
return None 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: def _through_all_length(self, target: Body, sketch: Sketch) -> float:
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass """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 def _through_all_length_for_geometry(self, geometry: Any, sketch: Sketch) -> float:
workplane normal direction ("extent" = how far the body reaches on """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 either side of the face). With ``symmetric=True`` the kernel
extrudes ``± height/2``, so to clear the full ``extent`` on each extrudes ``± height/2``, so to clear the full ``extent`` on each
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer 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. boolean reliably removes the through volume.
""" """
import numpy as _np import numpy as _np
try: try:
p_min, p_max = self._kernel.get_bounding_box(target.geometry) p_min, p_max = self._kernel.get_bounding_box(geometry)
except Exception: except Exception:
logger.debug("through-all bbox failed", exc_info=True) logger.debug("through-all bbox failed", exc_info=True)
return 2000.0 # generous fallback if bbox fails for any reason return 2000.0 # generous fallback if bbox fails for any reason
@@ -4087,10 +4297,23 @@ class MainWindow(QMainWindow):
for body in all_targets: for body in all_targets:
try: try:
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom) new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
body.geometry = new_geom
except Exception as exc: except Exception as exc:
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc) logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
continue 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_length = length
body.extrude_symmetric = symmetric body.extrude_symmetric = symmetric
body.extrude_invert = invert body.extrude_invert = invert
@@ -4111,7 +4334,19 @@ class MainWindow(QMainWindow):
body_name = f"{updated_count} body(ies)" body_name = f"{updated_count} body(ies)"
elif target is not None: elif target is not None:
# Single-body cut / union: commit the result onto the *target* # 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"] target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later. # Store extrude params so the body can be rebuilt later.
target.extrude_length = length target.extrude_length = length
@@ -4133,30 +4368,81 @@ class MainWindow(QMainWindow):
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result") logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
body_name = target.name body_name = target.name
else: else:
# Plain extrude: create a new body for the extrusion. # Plain extrude. If this sketch already produced an
body = self._current_component.add_body( # existing extrude body, UPDATE that body in place rather
Body( # than stacking a fresh 44mm solid on top of the old one
name=f"Extrusion_{len(self._current_component.bodies) + 1}", # — which the user perceives as "44mm looks like 88mm"
geometry=result["result_geom"], # because the two coincident solids visually sum.
source_sketch=sketch, existing = self._find_extrude_body_for_sketch(sketch)
source_operation="extrude", if existing is not None:
extrude_length=length, body = existing
extrude_symmetric=symmetric, # Record the new feature BEFORE replacing geometry so
extrude_invert=invert, # the parametric history reflects the latest op.
extrude_cut=False, self._record_feature(
extrude_union=False, body,
extrude_through_all=bool(through_all), "extrude",
extrude_cut_all_bodies=False, sketch,
extrude_face_index=face_index, length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
face_index=face_index,
) )
) body.geometry = result["result_geom"]
self._mark_dirty() body.extrude_length = length
logger.info(f"Created body: {body.name}") body.extrude_symmetric = symmetric
logger.debug("Adding shape to OCC viewer") body.extrude_invert = invert
shape = self._kernel._get_shape(body.geometry) body.extrude_cut = False
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name) body.extrude_union = False
logger.info(f"Render object: {body.render_object}") body.extrude_through_all = bool(through_all)
body_name = body.name 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._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index()) self._update_component_thumbnail(self._get_active_component_index())
@@ -4210,6 +4496,14 @@ class MainWindow(QMainWindow):
geometry=body_geometry, geometry=body_geometry,
source_sketch=sketch, source_sketch=sketch,
source_operation="revolve", source_operation="revolve",
features=[
Feature(
operation="revolve",
sketch=sketch,
angle=angle,
face_index=self._sketch_widget.get_selected_face_index(),
)
],
) )
) )
self._mark_dirty() self._mark_dirty()
+328 -69
View File
@@ -97,6 +97,12 @@ class Sketch2DWidget(QWidget):
constrain_done = Signal() constrain_done = Signal()
sketch_updated = 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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -524,6 +530,24 @@ class Sketch2DWidget(QWidget):
return False return False
return entity.id in self._sketch._centerline_ids 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]: def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch return self._sketch
@@ -624,6 +648,45 @@ class Sketch2DWidget(QWidget):
self.setCursor(Qt.ArrowCursor) self.setCursor(Qt.ArrowCursor)
self.update() 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): def set_construct_mode(self, enabled: bool):
self._is_construct = enabled self._is_construct = enabled
@@ -683,9 +746,12 @@ class Sketch2DWidget(QWidget):
# cursor position while the entity lookup returned None and no # cursor position while the entity lookup returned None and no
# coincident constraint was created (silent disconnect). # coincident constraint was created (silent disconnect).
entity = self._find_nearest_point_entity(pos, max_distance) entity = self._find_nearest_point_entity(pos, max_distance)
if entity is None or entity.geometry is None: if entity is None:
return 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))) return QPoint(int(round(x)), int(round(y)))
def _find_nearest_point_entity( def _find_nearest_point_entity(
@@ -705,16 +771,16 @@ class Sketch2DWidget(QWidget):
for entity in self._points: for entity in self._points:
if self._is_external(entity) and not self._underlay_visible: if self._is_external(entity) and not self._underlay_visible:
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
point = QPoint(int(round(x)), int(round(y))) continue
screen_point = self._world_to_screen(point) x, y = xy
dist = math.sqrt( point = QPoint(int(round(x)), int(round(y)))
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2 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: if dist < min_dist:
min_dist = dist min_dist = dist
nearest = entity nearest = entity
return nearest return nearest
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]: def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
@@ -890,9 +956,12 @@ class Sketch2DWidget(QWidget):
nearest_entity = None nearest_entity = None
min_dist = self._snap_distance min_dist = self._snap_distance
for entity in self._points: for entity in self._points:
if entity.id in exclude_ids or not entity.geometry: if entity.id in exclude_ids:
continue 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)))) 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) d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
if d < min_dist: if d < min_dist:
@@ -990,11 +1059,13 @@ class Sketch2DWidget(QWidget):
for entity in self._points: for entity in self._points:
if self._is_external(entity) and not self._underlay_visible: if self._is_external(entity) and not self._underlay_visible:
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2) continue
if dist < self._pick_tolerance_world(10): x, y = xy
return entity dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
if dist < self._pick_tolerance_world(10):
return entity
return None return None
def _get_line_entity_at( def _get_line_entity_at(
@@ -1440,7 +1511,28 @@ class Sketch2DWidget(QWidget):
return None return None
def _sync_solved_positions(self): 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: if not self._sketch:
return return
for entity in self._points: for entity in self._points:
@@ -1454,12 +1546,66 @@ class Sketch2DWidget(QWidget):
for p1_ent, p2_ent in self._lines: for p1_ent, p2_ent in self._lines:
if p1_ent.geometry and p2_ent.geometry: if p1_ent.geometry and p2_ent.geometry:
pass # geometry already updated via point sync 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: 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: if not self._sketch:
return True return True
ok = self._sketch.solve() 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._sync_solved_positions()
self.update() self.update()
return ok return ok
@@ -1635,16 +1781,13 @@ class Sketch2DWidget(QWidget):
return return
if event.button() == Qt.RightButton: if event.button() == Qt.RightButton:
# Only clear drawing-state, preserve any point selection for the # Right-click cancels the active drawing/constraint tool (same
# context menu (contextMenuEvent is invoked by Qt after this). # behaviour as pressing Escape). Routed through the shared
if self._mode in ("line", "rectangle", "circle", "arc", "slot"): # helper so right-click, Escape, and any future cancel path
self._mode = None # all perform the exact same teardown. We only suppress the
self._draw_buffer = [] # tool itself — the context menu (if any) is still delivered
self._dynamic_line_end = None # by Qt afterwards, so we do NOT return here.
self._arc_accum_sweep = 0.0 self._cancel_active_tool()
self._arc_prev_angle = None
self.constrain_done.emit()
# Do NOT return — let Qt deliver the contextMenuEvent.
return return
if event.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
@@ -1957,6 +2100,32 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync() self._solve_and_sync()
self._snap_point_target = None self._snap_point_target = None
else: 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 # Auto-constrain: snap → coincident / point-on-line
target = None target = None
if self._snap_point_target is not None: if self._snap_point_target is not None:
@@ -2054,6 +2223,17 @@ class Sketch2DWidget(QWidget):
event.accept() event.accept()
return 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) super().keyPressEvent(event)
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
@@ -2247,6 +2427,54 @@ class Sketch2DWidget(QWidget):
self.sketch_updated.emit() self.sketch_updated.emit()
self.update() 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 ───────────────────────────────────────────────── # ─── Drawing handlers ─────────────────────────────────────────────────
def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None: def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None:
@@ -2987,15 +3215,34 @@ class Sketch2DWidget(QWidget):
if self._undo_manager: if self._undo_manager:
self._undo_manager.save_state() self._undo_manager.save_state()
new_radius = diameter / 2.0 new_radius = diameter / 2.0
# Update the circle's radius in the sketch # Update the circle's radius in the sketch. Note that
self._sketch._circles[c_ent.id] = (c_ent.id, new_radius) # ``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 # Update the local cache
for i, (ent, rad) in enumerate(self._circles): for i, (ent, rad) in enumerate(self._circles):
if ent.id == c_ent.id: if ent.id == c_ent.id:
self._circles[i] = (ent, new_radius) self._circles[i] = (ent, new_radius)
break break
# Record constraint for undo/redo
self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
self._solve_and_sync() self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm") logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = [] self._selected_entities = []
@@ -3285,8 +3532,9 @@ class Sketch2DWidget(QWidget):
# at the endpoints far out of view. # at the endpoints far out of view.
if self._is_centerline(entity): if self._is_centerline(entity):
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is not None:
x, y = xy
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
if entity.is_construction: if entity.is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1)) painter.setPen(QPen(QColor("#6c7086"), 1))
@@ -3316,12 +3564,14 @@ class Sketch2DWidget(QWidget):
for entity in self._points: for entity in self._points:
if not self._is_external(entity): if not self._is_external(entity):
continue continue
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) continue
painter.setPen(QPen(QColor("#fab387"), 1)) x, y = xy
painter.setBrush(QBrush(QColor("#fab387"))) screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
painter.drawEllipse(screen_pos, 4, 4) painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387")))
painter.drawEllipse(screen_pos, 4, 4)
# ── Lines ── # ── Lines ──
for p1_ent, p2_ent in self._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) ── # ── Constraint tags (log-driven; drawn upright in screen space) ──
# Tags are recomputed here so paint stays in sync with the latest solve. # Tags are recomputed here so paint stays in sync with the latest solve.
# While a drawing or constraint tool is active the badges are hidden
# 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() self._constraint_tags = self._compute_constraint_tags()
tag_font = QFont("Monospace", 9) if not self._is_drawing_tool_active():
painter.setFont(tag_font) tag_font = QFont("Monospace", 9)
for tag in self._constraint_tags: painter.setFont(tag_font)
rect: QRect = tag["rect"] for tag in self._constraint_tags:
hovered = tag["idx"] == self._hovered_constraint_idx rect: QRect = tag["rect"]
# Background pill so the label is readable over the sketch. hovered = tag["idx"] == self._hovered_constraint_idx
painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2)) # Background pill so the label is readable over the sketch.
painter.setBrush(QBrush(QColor(40, 40, 60, 200))) painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
painter.drawRoundedRect(rect, 6, 6) painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1)) painter.drawRoundedRect(rect, 6, 6)
painter.drawText(rect, Qt.AlignCenter, tag["label"]) painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
painter.drawText(rect, Qt.AlignCenter, tag["label"])
# ── Technical dimension lines for distance constraints ── # ── Technical dimension lines for distance constraints ──
# Draw proper measurement lines (extension lines + dimension line # Draw proper measurement lines (extension lines + dimension
# with arrowheads + centred text) for every distance constraint. # line with arrowheads + centred text) for every distance
for tag in self._constraint_tags: # constraint. Gated together with the badges above so the
if "p1_world" in tag and "p2_world" in tag: # whole constraint overlay hides as one unit while a tool is
try: # active.
self._draw_technical_dimension( for tag in self._constraint_tags:
painter, if "p1_world" in tag and "p2_world" in tag:
tag["p1_world"], try:
tag["p2_world"], self._draw_technical_dimension(
tag["distance"], painter,
) tag["p1_world"],
except Exception: tag["p2_world"],
import traceback tag["distance"],
)
except Exception:
import traceback
traceback.print_exc() traceback.print_exc()
# ── Circles ── # ── Circles ──
for center_ent, radius in self._circles: for center_ent, radius in self._circles: