- 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
+744 -37
View File
@@ -24,6 +24,13 @@ from fluency.geometry_occ.kernel import OCCGeometryObject
logger = logging.getLogger(__name__)
# World-unit tolerance used when matching a saved line/circle/arc position to
# an existing point entity during load. Old files can carry derived geometry
# that is stale relative to the point entities (saved after a drag that was
# never re-solved); the fallback must be generous enough to reach the real
# point while staying far below typical feature sizes.
_LOAD_POINT_TOL = 0.5
class OCCSketchEntity(SketchEntity):
"""Sketch entity for OpenCASCADE-based sketch with solver integration."""
@@ -85,6 +92,16 @@ class OCCSketch(SketchInterface):
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
# Cached 2D normal for the workplane. SolveSpace's add_arc() needs
# a normal_2d handle, and creating one per arc pollutes the solver
# with redundant entities. We create it lazily on first arc and
# reset it whenever the workplane or solver is reset.
self._wp_normal_handle: Optional[Any] = None
# Set of arc ids whose diameter is locked by a ``diameter``
# constraint — for those we MUST NOT overwrite the stored radius
# from the geometry, because the user explicitly fixed it.
self._arc_diameter_fixed: set = set()
# ── Workplane ───────────────────────────────────────────────────
# The sketch lives in a 2D UV frame on this plane. UV coordinates
# map to world via: P = origin + u*x_dir + v*y_dir
@@ -262,6 +279,47 @@ class OCCSketch(SketchInterface):
return entity
def _make_arc_normal_3d(self) -> Any:
"""Build a SolveSpace 3D normal (quaternion) that matches this sketch's workplane orientation.
SolveSpace's ``add_arc`` requires a 3D normal (quaternion) for the
arc plane, NOT a 2D one passing a 2D normal raises
``TypeError: ... is not a 3d normal``. The 3D normal is a
unit quaternion that rotates the canonical Z-axis onto the
workplane's stored normal.
The default XY workplane (normal = +Z) maps to the identity
quaternion ``(1, 0, 0, 0)``. For arbitrary workplanes we derive
the shortest-arc quaternion that takes +Z onto the workplane
normal; this is the standard axis-angle quaternion conversion
via the cross product as rotation axis.
"""
import math as _math
nx, ny, nz = self._wp_normal
# Identity rotation when the workplane normal is already +Z.
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz - 1.0) < 1e-12:
return self._solver.add_normal_3d(1.0, 0.0, 0.0, 0.0)
# Antiparallel case (workplane normal = -Z) — 180° about X axis.
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz + 1.0) < 1e-12:
return self._solver.add_normal_3d(0.0, 1.0, 0.0, 0.0)
# Axis = +Z × n = (-ny, nx, 0); angle = arccos(nz).
axis_len = _math.sqrt(nx * nx + ny * ny)
ax = -ny / axis_len
ay = nx / axis_len
az = 0.0
angle = _math.acos(max(-1.0, min(1.0, nz)))
half = angle * 0.5
s = _math.sin(half)
qw = _math.cos(half)
qx = ax * s
qy = ay * s
qz = az * s
return self._solver.add_normal_3d(qw, qx, qy, qz)
def add_arc(
self,
center: SketchEntity,
@@ -270,10 +328,29 @@ class OCCSketch(SketchInterface):
end_point: SketchEntity,
sweep: Optional[float] = None,
) -> OCCSketchEntity:
"""Add an arc (tracked only).
"""Add an arc (added to solver + tracked).
*sweep* is the signed angular span in radians (positive = CCW, negative = CW).
When *None* the rendering will infer the shortest path between start and end.
The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
so the radius is **derived** from the current geometry rather than
stored as a fixed scalar.
Consequences:
* If the user constrains the start or end (e.g. coincident to a
rectangle corner) and then resizes the rectangle, the centre
slides on the perpendicular bisector of startend to keep the
arc consistent the arc shape follows the rectangle, which is
the behaviour users expect from a fillet.
* If the centre is dragged instead, the radius adjusts so the
endpoints stay on the new circle.
* If the user wants the **diameter pinned** to a specific value
(e.g. a quarter-circle of exactly 10 mm), they can call
:meth:`constrain_arc_diameter` afterwards.
*sweep* is the signed angular span in radians (positive = CCW,
negative = CW). When *None* the rendering will infer the shortest
path between start and end.
"""
import math
@@ -285,6 +362,8 @@ class OCCSketch(SketchInterface):
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry
sx, sy = start_entity.geometry
@@ -300,6 +379,25 @@ class OCCSketch(SketchInterface):
while sweep < -math.pi:
sweep += 2 * math.pi
# ── Add the arc to the SolveSpace solver ───────────────────────
# We need a 3D normal (quaternion) for the work plane. Cache one
# per (sketch, workplane) so we don't accumulate unused normals
# across many arc creations, and so the cache is invalidated
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="arc",
@@ -310,6 +408,7 @@ class OCCSketch(SketchInterface):
"end": (ex, ey),
"sweep": sweep,
},
handle=arc_handle,
)
self._entities[entity_id] = entity
@@ -319,6 +418,15 @@ class OCCSketch(SketchInterface):
"end": end_point.id,
"radius": radius,
"sweep": sweep,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
# uses this to re-derive the centre position so the arc
# scales with the rectangle while keeping the same shape.
# The user can override it later with
# :meth:`constrain_arc_diameter` if they want a fixed-size
# arc regardless of the host geometry.
"original_sweep": sweep,
}
return entity
@@ -881,16 +989,59 @@ class OCCSketch(SketchInterface):
# tracked only (no solver entity)
pass
elif ctype == "diameter":
# Update circle radius in sketch data
# Update circle radius in sketch data. Legacy files (pre-ghost-
# circle-fix) sometimes recorded the diameter against the CENTER
# point id instead of the circle entity id; resolve such entries
# to the real circle via the _circles center mapping. Never
# touch the geometry of a non-circle entity — doing so used to
# turn a point into circle-shaped geometry and crashed the UI's
# ``round()`` calls.
circle_id = ids[0]
if circle_id in self._circles:
center_id, _ = self._circles[circle_id]
radius = params[0] / 2.0
self._circles[circle_id] = (center_id, radius)
ent = self._entities.get(circle_id)
if ent is not None and ent.geometry is not None:
cx, cy = ent.geometry[0] if isinstance(ent.geometry[0], tuple) else ent.geometry
resolved: Optional[int] = circle_id
if resolved not in self._circles:
for cid, (center_id, _r) in self._circles.items():
if center_id == circle_id:
resolved = cid
break
else:
resolved = None
if resolved is not None:
if resolved in self._circles:
center_id, _ = self._circles[resolved]
self._circles[resolved] = (center_id, radius)
ent = self._entities.get(resolved)
if ent is not None and ent.entity_type == "circle" and ent.geometry is not None:
cx, cy = (
ent.geometry[0]
if isinstance(ent.geometry[0], (tuple, list))
else ent.geometry
)
ent.geometry = ((cx, cy), radius)
elif ctype == "arc_diameter":
# Re-apply the solver-side diameter constraint and refresh the
# stored radius so the renderer matches. Marks the arc as
# diameter-pinned so subsequent solves don't overwrite the
# radius from the implicit geometry.
arc_id = ids[0]
ent = self._entities.get(arc_id)
if ent is None or ent.handle is None or arc_id not in self._arcs:
return False
try:
diameter_value = float(params[0])
except (TypeError, ValueError) as e:
logger.debug("arc_diameter log had non-numeric param: %s", e)
return False
try:
self._solver.diameter(ent.handle, diameter_value)
except Exception as e:
logger.debug("Re-applying arc_diameter failed: %s", e)
return False
radius = diameter_value / 2.0
self._arcs[arc_id]["radius"] = radius
if isinstance(ent.geometry, dict):
ent.geometry["radius"] = radius
self._arc_diameter_fixed.add(arc_id)
else:
return False
return True
@@ -901,8 +1052,9 @@ class OCCSketch(SketchInterface):
python_solvespace cannot remove individual entities/constraints, so
after deleting an entity we rebuild the whole system: re-add every
surviving point at its current position (first point re-fixed for
stability), re-add every surviving line, then re-apply the pruned
constraint log. Entity ids are preserved; only solver handles change.
stability), re-add every surviving line, re-add every surviving
arc, then re-apply the pruned constraint log. Entity ids are
preserved; only solver handles change.
"""
# Snapshot current point positions before resetting the solver.
saved_pos: Dict[int, Tuple[float, float]] = {}
@@ -913,6 +1065,9 @@ class OCCSketch(SketchInterface):
self._solver = SolverSystem()
self._wp = self._solver.create_2d_base()
self._first_point_id = None
# New solver = new work plane = new normal entity. Drop the cache
# so ``add_arc`` recreates it on demand.
self._wp_normal_handle = None
# Re-add point entities in id order (preserves first-point-fixed).
for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"):
@@ -945,6 +1100,43 @@ class OCCSketch(SketchInterface):
if line_ent is not None:
line_ent.handle = new_handle
# Re-add arc entities in id order. Without this, every arc loses
# its solver-side constraint that ties start/end/centre together,
# and the renderer would happily draw the old radius over the new
# geometry — the exact "arc doesn't follow the rectangle" bug
# that motivated the add-arc-to-solver change.
if self._arcs:
if self._wp_normal_handle is None:
# Use the 3D-normal helper, not add_normal_2d — the latter
# produces a 2D entity that add_arc rejects with
# ``TypeError: ... is not a 3d normal``.
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
c_ent = self._entities.get(c_id) if c_id is not None else None
s_ent = self._entities.get(s_id) if s_id is not None else None
e_ent = self._entities.get(e_id) if e_id is not None else None
if (
c_ent is None
or s_ent is None
or e_ent is None
or c_ent.handle is None
or s_ent.handle is None
or e_ent.handle is None
):
continue
new_handle = self._solver.add_arc(
nm, c_ent.handle, s_ent.handle, e_ent.handle, self._wp
)
arc_ent = self._entities.get(aid)
if arc_ent is not None:
arc_ent.handle = new_handle
# Re-apply every surviving logged constraint.
for entry in self._constraint_log:
self._apply_constraint_log(entry)
@@ -1069,14 +1261,55 @@ class OCCSketch(SketchInterface):
if circle.id in self._circles:
center_id, _ = self._circles[circle.id]
self._circles[circle.id] = (center_id, radius)
# Update the entity geometry
# Update the entity geometry. Circle geometry is
# ``((cx, cy), old_radius)`` — keep the center, swap the radius.
ent = self._entities.get(circle.id)
if ent is not None:
if ent is not None and ent.geometry is not None:
if isinstance(ent.geometry[0], (tuple, list)):
(cx, cy), _old_radius = ent.geometry
else:
cx, cy = ent.geometry
ent.geometry = ((cx, cy), radius)
self._record_constraint("diameter", (circle.id,), (diameter,))
return True
def constrain_arc_diameter(self, arc: SketchEntity, diameter: float) -> bool:
"""Pin the diameter of an arc to a specific value.
Without this constraint an arc is implicit-radius: its diameter
is whatever the geometry needs it to be to keep
``distance(start, centre) = distance(end, centre)`` perfect for
fillets that grow with the rectangle they're attached to. When
the user wants a quarter-circle of *exactly* N mm they pin it
with this method; afterwards the solver enforces the diameter
and our ``_arc_diameter_fixed`` set tells ``_sync_solved_positions``
to stop overwriting the stored radius.
"""
ent = self._entities.get(arc.id)
if ent is None or ent.handle is None or arc.id not in self._arcs:
return False
try:
d = float(diameter)
except (TypeError, ValueError) as e:
logger.error("Arc diameter must be numeric: %s", e)
return False
try:
self._solver.diameter(ent.handle, d)
except Exception as e:
logger.error("Arc diameter constraint failed: %s", e)
return False
radius = d / 2.0
self._arcs[arc.id]["radius"] = radius
if isinstance(ent.geometry, dict):
ent.geometry["radius"] = radius
self._arc_diameter_fixed.add(arc.id)
try:
self._record_constraint("arc_diameter", (arc.id,), (d,))
except Exception as e:
logger.error("Recording arc diameter constraint failed: %s", e)
return False
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place via dragged constraint."""
ent = self._entities.get(entity.id)
@@ -1086,6 +1319,22 @@ class OCCSketch(SketchInterface):
self._record_constraint("fixed", (entity.id,))
return True
def is_entity_dragged(self, entity_id: int) -> bool:
"""True if the entity already has a ``dragged`` (fixed) constraint.
Used by the UI to avoid stacking duplicate ``dragged`` constraints
on the same point every time the user moves it SolveSpace can
take several dragged constraints on the same point, but each one
bloats the constraint log without changing the locked position.
The user can still move the point later: a fresh
``set_entity_position`` updates the params and the existing
``dragged`` keeps the point at the new location on the next solve.
"""
for entry in self._constraint_log:
if entry["type"] == "fixed" and entity_id in entry["ids"]:
return True
return False
def constrain_symmetric(
self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity
) -> bool:
@@ -1144,22 +1393,89 @@ class OCCSketch(SketchInterface):
# ─── Solving ───────────────────────────────────────────────────────────
def solve(self) -> bool:
"""Solve all constraints via SolveSpace solver."""
"""Solve all constraints via SolveSpace solver.
Returns True on success, False if the solver returns a non-OKAY
result (INCONSISTENT, DIDNT_CONVERGE, TOO_MANY_UNKNOWNS). When
False, :attr:`last_solve_status` is set to a human-readable string
describing the failure so the UI can surface it to the user.
Callers that need to know *which* failure happened should use
:meth:`last_solve_result` (returns the raw :class:`ResultFlag`).
"""
try:
result = self._solver.solve()
self._last_solve_result = int(result)
if result == ResultFlag.OKAY:
# Sync solved positions back to entity geometries
self._sync_solved_positions()
self._last_solve_status = "ok"
return True
else:
logger.warning(f"Solver returned: {result}")
# Map SolveSpace's result enum to a one-line user-facing
# hint. INCONSISTENT is the most common and the most useful
# to call out: a new constraint conflicts with existing
# ones, so the geometry can't satisfy all of them.
status_map = {
int(ResultFlag.INCONSISTENT): (
"inconsistent: the new constraint conflicts with existing constraints"
),
int(ResultFlag.DIDNT_CONVERGE): (
"didn't converge: try simplifying the constraints or removing one"
),
int(ResultFlag.TOO_MANY_UNKNOWNS): (
"too many unknowns: the sketch is under-constrained"
),
}
self._last_solve_status = status_map.get(
int(result), f"failed (result code {int(result)})"
)
logger.warning(f"Solver returned: {result}{self._last_solve_status}")
return False
except Exception as e:
logger.error(f"Solver error: {e}")
self._last_solve_status = f"error: {e}"
self._last_solve_result = -1
return False
def last_solve_result(self) -> int:
"""Raw SolveSpace result code from the most recent :meth:`solve` call.
``0`` = OKAY, ``1`` = INCONSISTENT, ``2`` = DIDNT_CONVERGE,
``3`` = TOO_MANY_UNKNOWNS, ``-1`` if solve raised an exception.
Use :attr:`last_solve_status` for a human-readable version.
"""
return getattr(self, "_last_solve_result", 0)
@property
def last_solve_status(self) -> str:
"""One-line human-readable description of the most recent solve outcome.
``"ok"`` on success, or a short explanation of the failure
(e.g. ``"inconsistent: the new constraint conflicts with existing
constraints"``). Useful for status-bar messages when the solver
can't satisfy the current set of constraints.
"""
return getattr(self, "_last_solve_status", "ok")
def _sync_solved_positions(self) -> None:
"""Read solved point positions from solver and update entity geometries."""
"""Read solved point positions from solver and update entity geometries.
After syncing points and lines, also refreshes every arc's stored
radius from the current centrestart distance. This is what
makes a coincident-constrained arc follow the rectangle it's
attached to: as the start/end points move with the rectangle's
corners, the solver shifts the centre onto the perpendicular
bisector and the radius becomes the new centre-to-endpoint
distance. Without this refresh the renderer would still draw
the arc with the original radius and the visual would desync
from the constraints.
Arcs whose diameter has been explicitly pinned via
:meth:`constrain_arc_diameter` are skipped the user wants
the diameter fixed and we must not overwrite it.
"""
import math as _math
for entity_id, entity in list(self._entities.items()):
if entity.entity_type == "point" and entity.handle is not None:
try:
@@ -1177,6 +1493,207 @@ class OCCSketch(SketchInterface):
if start_entity and end_entity and start_entity.geometry and end_entity.geometry:
entity.geometry = (start_entity.geometry, end_entity.geometry)
elif entity.entity_type == "arc" and entity_id in self._arcs:
if entity_id in self._arc_diameter_fixed:
# User has pinned the diameter; don't touch it.
continue
arc_data = self._arcs[entity_id]
center_id = arc_data.get("center")
start_id = arc_data.get("start")
end_id = arc_data.get("end")
center_ent = self._entities.get(center_id) if center_id is not None else None
start_ent = self._entities.get(start_id) if start_id is not None else None
end_ent = self._entities.get(end_id) if end_id is not None else None
if (
center_ent is not None
and start_ent is not None
and end_ent is not None
and center_ent.geometry is not None
and start_ent.geometry is not None
and end_ent.geometry is not None
):
cx, cy = center_ent.geometry
sx, sy = start_ent.geometry
ex, ey = end_ent.geometry
# ── Side lock: keep the arc on the side the user drew it on.
# The L2-norm minimisation SolveSpace uses to pick
# the new centre position can land on the *opposite*
# side of the chord from where the user originally
# drew the arc — for example, when a corner is
# dragged upward past the original centre, the chord
# ends up above the centre and the arc now bulges
# INTO the rectangle. The sign of the stored
# ``sweep`` encodes which side the centre is on
# (positive = CCW from start→end, negative = CW),
# so we use that to detect a side flip and mirror
# the centre across the chord midpoint to put it
# back on the correct side.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
prev_sweep = arc_data.get("sweep")
if (
prev_sweep is not None
and prev_sweep != 0.0
and new_sweep != 0.0
and (prev_sweep * new_sweep) < 0.0
):
# Sign flipped — mirror the centre across the
# chord so the arc stays on the original side.
mid_x = (sx + ex) * 0.5
mid_y = (sy + ey) * 0.5
cx = 2.0 * mid_x - cx
cy = 2.0 * mid_y - cy
center_ent.geometry = (cx, cy)
# Recompute sweep with the mirrored centre.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
new_radius = _math.dist((cx, cy), (sx, sy))
arc_data["radius"] = new_radius
arc_data["sweep"] = new_sweep
if isinstance(entity.geometry, dict):
entity.geometry["radius"] = new_radius
entity.geometry["center"] = (cx, cy)
entity.geometry["start"] = (sx, sy)
entity.geometry["end"] = (ex, ey)
entity.geometry["sweep"] = new_sweep
# ── Auto-scale: preserve the original sweep ──
# The user-drawn sweep (captured in
# ``original_sweep`` at add_arc time) describes the
# arc's *shape* — its angular span and which side
# of the chord the centre is on. When the host
# geometry (e.g. a rectangle the arc is attached
# to) resizes, the L2-minimising solver leaves the
# centre close to its previous position, which
# gives the *wrong* shape (the sweep drifts). If
# the centre is free, we override it with the
# position that exactly preserves the original
# sweep on the new chord — geometrically, this is
# the only well-defined choice for an arc whose
# start and end are constrained but whose radius
# should scale with the host.
original_sweep = arc_data.get("original_sweep")
if (
original_sweep is not None
and abs(original_sweep) > 1e-9
and not self._is_centre_constrained(center_id)
):
new_cx, new_cy = self._centre_for_sweep((sx, sy), (ex, ey), original_sweep)
if new_cx is not None:
# Push the new centre into the solver
# AND the entity geometry. The solver
# accepts set_params even after solve()
# because the centre is a free point and
# the arc constraint
# (|s-c| = |e-c|) is satisfied
# automatically when the centre sits on
# the perpendicular bisector.
assert new_cy is not None # both-or-neither from _centre_for_sweep
try:
self._solver.set_params(
center_ent.handle.params,
(new_cx, new_cy),
)
except Exception as e:
logger.debug("set_params for arc centre failed: %s", e)
center_ent.geometry = (new_cx, new_cy)
cx, cy = new_cx, new_cy
new_radius = _math.dist((cx, cy), (sx, sy))
arc_data["radius"] = new_radius
# Recompute the sweep from the new
# geometry — should equal original_sweep
# up to floating point.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
arc_data["sweep"] = new_sweep
if isinstance(entity.geometry, dict):
entity.geometry["radius"] = new_radius
entity.geometry["center"] = (cx, cy)
entity.geometry["sweep"] = new_sweep
def _is_centre_constrained(self, centre_id: Optional[int]) -> bool:
"""True if *centre_id* is referenced by any constraint in the log.
Used by the arc auto-scale path in :meth:`_sync_solved_positions`
to avoid moving a centre that's locked by a coincident, fixed,
distance, or symmetric constraint in those cases the user
has expressed an intent about where the centre should be, and
overriding it would silently break the constraint.
"""
if centre_id is None:
return True # Conservative: don't move a centre we can't identify.
for entry in self._constraint_log:
if centre_id in entry.get("ids", ()):
return True
return False
def _centre_for_sweep(
self,
start: Tuple[float, float],
end: Tuple[float, float],
sweep: float,
) -> Tuple[Optional[float], Optional[float]]:
"""Return the centre position that gives an arc a specific sweep on a chord.
Given two endpoints *start* and *end* and a signed sweep
*sweep* (positive = CCW from startend, negative = CW), the
unique centre on the perpendicular bisector at the right
distance is::
d = |start end| / (2 * tan(|sweep| / 2))
C = midpoint ± d * normal
where *normal* is the unit vector 90° CCW from the chord
direction and the sign of *sweep* picks which side the centre
is on. Returns ``(None, None)`` for degenerate inputs (zero
chord, sweep π so d 0).
"""
import math as _math
sx, sy = start
ex, ey = end
dx = ex - sx
dy = ey - sy
chord_len = _math.hypot(dx, dy)
if chord_len < 1e-12:
return None, None
half_sweep = abs(sweep) * 0.5
# tan(π/2) is infinite — the arc is a semicircle and the centre
# sits on the chord. Skip rather than divide by zero.
if half_sweep >= _math.pi * 0.5 - 1e-9:
return None, None
d = chord_len / (2.0 * _math.tan(half_sweep))
# Left normal: rotate the chord direction 90° CCW. For a chord
# direction (dx, dy) the CCW perpendicular is (-dy, dx). With
# this convention, a POSITIVE sweep (CCW from start→end) places
# the centre on the +n side — i.e. the bulge is on the "left"
# of the chord, matching what the user sees when they draw the
# arc. Get the sign wrong and the centre lands on the wrong side
# and the stored ``original_sweep`` flips sign on the next solve.
nx = -dy / chord_len
ny = dx / chord_len
side = 1.0 if sweep > 0 else -1.0
mid_x = (sx + ex) * 0.5
mid_y = (sy + ey) * 0.5
return mid_x + d * side * nx, mid_y + d * side * ny
def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]:
"""Get the solved position of a point entity."""
entity = self._entities.get(entity_id)
@@ -1789,6 +2306,9 @@ class OCCSketch(SketchInterface):
self._external_entity_ids.clear()
self._centerline_ids.clear()
self._first_point_id = None
# New solver = new work plane; cached normal_2d is now stale.
self._wp_normal_handle = None
self._arc_diameter_fixed.clear()
def _prune_log_for(self, removed_ids: set) -> None:
"""Drop constraint-log entries that reference any id in ``removed_ids``."""
@@ -1902,6 +2422,9 @@ class OCCSketch(SketchInterface):
del self._arcs[aid]
if aid in self._entities:
del self._entities[aid]
# If a diameter-pinned arc is being torn down, clear its flag
# so the set doesn't grow stale.
self._arc_diameter_fixed.discard(aid)
self._prune_log_for(removed_ids)
self._rebuild_solver()
@@ -2005,13 +2528,71 @@ class OCCSketch(SketchInterface):
entities_payload: List[Dict[str, Any]] = []
for eid in sorted(self._entities.keys()):
ent = self._entities[eid]
# Serialize line / circle / arc geometry DERIVED from the point
# entities they reference, not from ``ent.geometry``. A drag
# (set_entity_position) updates the point but not the line/circle
# stored geometry, so the raw attribute can be stale — writing it
# produces files whose lines/circles no longer match their
# endpoints, and the next load drops those entities.
geometry_payload = ent.geometry
if ent.entity_type == "line":
line_ref = self._lines.get(eid)
if line_ref is not None:
s_ent = self._entities.get(line_ref[0])
e_ent = self._entities.get(line_ref[1])
if (
s_ent is not None
and e_ent is not None
and s_ent.geometry is not None
and e_ent.geometry is not None
):
geometry_payload = (tuple(s_ent.geometry), tuple(e_ent.geometry))
elif ent.entity_type == "circle":
circle_ref = self._circles.get(eid)
if circle_ref is not None:
c_ent = self._entities.get(circle_ref[0])
if c_ent is not None and c_ent.geometry is not None:
try:
geometry_payload = (tuple(c_ent.geometry), float(circle_ref[1]))
except (TypeError, ValueError):
# Corrupt in-memory radius must not abort the save.
geometry_payload = ent.geometry
elif ent.entity_type == "arc" and isinstance(ent.geometry, dict):
arc_data = self._arcs.get(eid)
if arc_data is not None:
c_ent = self._entities.get(arc_data.get("center"))
s_ent = self._entities.get(arc_data.get("start"))
e_ent = self._entities.get(arc_data.get("end"))
if (
c_ent is not None
and s_ent is not None
and e_ent is not None
and c_ent.geometry is not None
and s_ent.geometry is not None
and e_ent.geometry is not None
):
try:
radius_val = float(
arc_data.get("radius", ent.geometry.get("radius", 0.0))
)
sweep_val = float(arc_data.get("sweep", ent.geometry.get("sweep", 0.0)))
except (TypeError, ValueError):
radius_val = ent.geometry.get("radius", 0.0)
sweep_val = ent.geometry.get("sweep", 0.0)
geometry_payload = {
"center": tuple(c_ent.geometry),
"start": tuple(s_ent.geometry),
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
}
entities_payload.append(
{
"id": eid,
"type": ent.entity_type,
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
# circle→((cx,cy),r), arc→dict. All JSON-friendly.
"geometry": ent.geometry,
"geometry": geometry_payload,
"is_construction": bool(ent.is_construction),
"is_external": bool(ent.is_external),
"constraints": list(ent.constraints),
@@ -2065,6 +2646,12 @@ class OCCSketch(SketchInterface):
order so :attr:`_first_point_id` is anchored correctly. Existing
callers (notably the solver-rebuild path on entity delete) don't use
this; only the project load path does.
Numeric coercions (``int``/``float``) and entity-replay calls are
wrapped in try/except: a single corrupt entry in a project file
(hand-edited, partially-written, from a different app version) must
not abort the whole load. The bad entry is logged and skipped so
the surviving geometry can still be loaded and used.
"""
# Wipe solver + trackers (don't lose the workplane yet — we set it
# explicitly below).
@@ -2081,59 +2668,139 @@ class OCCSketch(SketchInterface):
# 2. Force the entity counter so the replay assigns the same ids as
# the saved sketch — the constraint log references those ids.
try:
self._entity_counter = int(data.get("entity_counter", 0))
except (TypeError, ValueError) as e:
logger.warning("entity_counter invalid (%s); starting at 0", e)
self._entity_counter = 0
# 3. Replay entities in id order. We need the OCCSketchEntity
# objects back (for arc center/start/end lookups), so we
# reconstruct by id and let ``_next_id`` advance the counter.
# 3. Replay entities. Points are loaded in a first pass (in file
# order) and lines / circles / arcs in a second pass, so an
# endpoint reference can resolve by position even when the
# referenced point has a HIGHER id than the line (re-saved
# recovery points, hand-edited files). We need the
# OCCSketchEntity objects back (for arc center/start/end
# lookups), so we reconstruct by id and let ``_next_id`` advance
# the counter.
entities_by_id: Dict[int, OCCSketchEntity] = {}
for entry in data.get("entities", []):
# Pre-compute the centerline id set (step 5 below restores it after
# the loops) and the highest saved id. A legacy file can save a
# centerline whose axis point has stale coordinates, leaving the
# line's other endpoint unmatched; in that case we reconstruct the
# missing point with an id ABOVE every saved id so it can't collide
# with the entities still to load.
try:
centerline_ids_set = {int(x) for x in data.get("centerline_ids", [])}
except (TypeError, ValueError):
centerline_ids_set = set()
max_saved_id = 0
try:
max_saved_id = max(int(e["id"]) for e in data.get("entities", []))
except (TypeError, ValueError):
pass
def _replay_entry(entry: Dict[str, Any]) -> None:
"""Recreate one saved entity, preserving its id and flags."""
nonlocal entities_by_id
try:
eid = int(entry["id"])
except (TypeError, ValueError) as e:
logger.warning("Skipping entity with invalid id (%s): %r", e, entry)
return
# Ensure the next _next_id() call returns eid.
self._entity_counter = eid - 1
etype = entry["type"]
geom = entry.get("geometry")
is_external = bool(entry.get("is_external", False))
if geom is None:
logger.warning("Skipping entity %s during load: missing geometry", eid)
return
try:
if etype == "point":
try:
x, y = float(geom[0]), float(geom[1])
except (TypeError, ValueError, IndexError) as e:
logger.warning("Skipping point %s during load: bad geometry (%s)", eid, e)
return
if is_external:
ent = self.add_external_point(x, y)
else:
ent = self.add_point(x, y)
elif etype == "line":
# line geometry is ((x1,y1),(x2,y2)); we already know the
# endpoints exist as point entities. Look them up by saved
# position via _points (which was just populated above).
# line geometry is ((x1,y1),(x2,y2)); the endpoints are
# point entities already loaded in pass 1. Look them up
# by saved position via _points. Older files can carry
# line geometry that is stale relative to the endpoint
# points (a drag without a subsequent solve), so fall
# back to the nearest point within a small world
# tolerance before giving up.
try:
(x1, y1), (x2, y2) = geom
s_id = self._find_point_at(x1, y1)
e_id = self._find_point_at(x2, y2)
except (TypeError, ValueError) as e:
logger.warning("Skipping line %s during load: bad geometry (%s)", eid, e)
return
s_id = self._find_point_at(x1, y1) or self._find_point_near(x1, y1)
e_id = self._find_point_at(x2, y2) or self._find_point_near(x2, y2)
if (s_id is None or e_id is None) and eid in centerline_ids_set:
# Centerline endpoint missing — older files saved the
# axis point with stale coordinates. Reconstruct it
# from the line's own geometry so the reference axis
# survives; allocate above every saved id to avoid
# colliding with entities that load afterwards.
self._entity_counter = max(max_saved_id, self._entity_counter)
if s_id is None:
s_ent = self.add_point(x1, y1)
s_ent.is_construction = True
s_id = s_ent.id
entities_by_id[s_id] = s_ent
self._centerline_ids.add(s_id)
if e_id is None:
e_ent = self.add_point(x2, y2)
e_ent.is_construction = True
e_id = e_ent.id
entities_by_id[e_id] = e_ent
self._centerline_ids.add(e_id)
# Recovery points were allocated above every saved
# id; re-point the counter at the line's own id so
# the line below keeps its saved id (and the next
# file entity re-points the counter anyway).
self._entity_counter = eid - 1
if s_id is None or e_id is None:
logger.warning("Skipping line %s during load: endpoints not found", eid)
continue
return
if is_external:
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
else:
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
elif etype == "circle":
try:
(cx, cy), radius = geom
c_id = self._find_point_at(cx, cy)
radius = float(radius)
except (TypeError, ValueError) as e:
logger.warning("Skipping circle %s during load: bad geometry (%s)", eid, e)
return
c_id = self._find_point_at(cx, cy) or self._find_point_near(cx, cy)
if c_id is None:
logger.warning("Skipping circle %s during load: center not found", eid)
continue
ent = self.add_circle(entities_by_id[c_id], float(radius))
return
ent = self.add_circle(entities_by_id[c_id], radius)
elif etype == "arc":
try:
center_pos = tuple(geom["center"])
start_pos = tuple(geom["start"])
end_pos = tuple(geom["end"])
radius = float(geom["radius"])
sweep = float(geom.get("sweep", 0.0))
c_id = self._find_point_at(*center_pos)
s_id = self._find_point_at(*start_pos)
e_id = self._find_point_at(*end_pos)
except (TypeError, ValueError, KeyError) as e:
logger.warning("Skipping arc %s during load: bad geometry (%s)", eid, e)
return
c_id = self._find_point_at(*center_pos) or self._find_point_near(*center_pos)
s_id = self._find_point_at(*start_pos) or self._find_point_near(*start_pos)
e_id = self._find_point_at(*end_pos) or self._find_point_near(*end_pos)
if c_id is None or s_id is None or e_id is None:
logger.warning("Skipping arc %s during load: endpoints not found", eid)
continue
return
ent = self.add_arc(
entities_by_id[c_id],
radius,
@@ -2143,7 +2810,13 @@ class OCCSketch(SketchInterface):
)
else:
logger.warning("Unknown sketch entity type %r; skipping", etype)
continue
return
except Exception as e:
# Last-ditch guard: a single bad entity must not abort the
# whole load. Log and move on so the rest of the sketch
# can still be reconstructed.
logger.warning("Skipping entity %s during load: %s", eid, e)
return
# Restore the per-entity UI flags / labels that aren't carried
# by the add_* methods themselves.
@@ -2151,6 +2824,17 @@ class OCCSketch(SketchInterface):
ent.constraints = list(entry.get("constraints", []))
entities_by_id[eid] = ent
all_entries = data.get("entities", [])
# Pass 1: every point, so pass 2 can resolve endpoint references by
# position regardless of the id order in the file.
for entry in all_entries:
if entry.get("type") == "point":
_replay_entry(entry)
# Pass 2: lines, circles, arcs (id order preserved per entry).
for entry in all_entries:
if entry.get("type") != "point":
_replay_entry(entry)
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
# solver call and pushes back into the entity tracker via
# ``entity.constraints``. We don't double-record into the log
@@ -2174,9 +2858,15 @@ class OCCSketch(SketchInterface):
# for some reason (legacy / hand-edited file), fold it in too so
# the saved flag is authoritative.
for eid in data.get("external_entity_ids", []):
try:
self._external_entity_ids.add(int(eid))
except (TypeError, ValueError) as exc:
logger.warning("Skipping invalid external_entity_ids entry: %s", exc)
for eid in data.get("centerline_ids", []):
try:
self._centerline_ids.add(int(eid))
except (TypeError, ValueError) as exc:
logger.warning("Skipping invalid centerline_ids entry: %s", exc)
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
@@ -2184,3 +2874,20 @@ class OCCSketch(SketchInterface):
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
return pid
return None
def _find_point_near(self, x: float, y: float, tol: float = _LOAD_POINT_TOL) -> Optional[int]:
"""Return the id of the point *closest* to ``(x, y)`` within ``tol``.
Fallback for ``_find_point_at`` when loading older project files
whose line/circle/arc geometry is stale relative to the point
entities (saved after a drag that was never re-solved). Points are
authoritative the derived geometry just has to reach them.
"""
best_id: Optional[int] = None
best_d = tol
for pid, pos in self._points.items():
d = math.hypot(pos[0] - x, pos[1] - y)
if d < best_d:
best_d = d
best_id = pid
return best_id
+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-
+442 -148
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.
"""
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._update_sketches_from_bodies()
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
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,
new_geom = _replay_body_features(
self._kernel, body, features, self._through_all_length_for_geometry
)
if tool_geom is None:
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
except Exception as exc:
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
continue
cut_count = 0
for other_id, other in list(self._current_component.bodies.items()):
if other.geometry is None:
if new_geom is None:
# Replay aborted — keep the previous geometry and the
# needs_update flag so the ⚠ marker stays visible.
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.geometry = new_geom
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}")
except Exception as exc:
logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
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
@@ -4132,6 +4367,46 @@ class MainWindow(QMainWindow):
op = "cut" if cut else "union"
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
body_name = target.name
else:
# 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,
)
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(
@@ -4148,6 +4423,17 @@ class MainWindow(QMainWindow):
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()
@@ -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()
+291 -32
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,13 +771,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
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
)
dist = math.sqrt((pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2)
if dist < min_dist:
min_dist = dist
nearest = entity
@@ -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,8 +1059,10 @@ 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
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
@@ -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
# 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,8 +3564,10 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if not self._is_external(entity):
continue
if entity.geometry:
x, y = entity.geometry
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")))
@@ -3352,7 +3602,13 @@ 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()
if not self._is_drawing_tool_active():
tag_font = QFont("Monospace", 9)
painter.setFont(tag_font)
for tag in self._constraint_tags:
@@ -3366,8 +3622,11 @@ class Sketch2DWidget(QWidget):
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.
# 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: