diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 997a4df..41052b3 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,18 +4,15 @@
-
+
-
-
-
@@ -52,47 +49,47 @@
- {
+ "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"
}
-}]]>
+}
1783976498520
-
+
+
+ 1785094789247
+
+
+
+ 1785094789248
+
+
@@ -467,7 +472,6 @@
-
@@ -492,6 +496,7 @@
-
+
+
\ No newline at end of file
diff --git a/gui.ui b/gui.ui
index 0c51197..c3e8c58 100644
--- a/gui.ui
+++ b/gui.ui
@@ -7,7 +7,7 @@
002551
- 1248
+ 1265
@@ -218,7 +218,7 @@
- 1
+ 0
@@ -372,6 +372,10 @@
Pt_Pt
+
+
+ icons/pt_pt.pngicons/pt_pt.png
+ true
@@ -1260,17 +1264,10 @@
Modify
-
-
+
+
- Rev
-
-
-
-
-
-
- Extrd
+ Comb
@@ -1281,6 +1278,34 @@
+
+
+
+ Mve
+
+
+
+
+
+
+ Rev
+
+
+
+
+
+
+ Fillet
+
+
+
+
+
+
+ Extrd
+
+
+
@@ -1288,17 +1313,17 @@
-
-
+
+
- Comb
+ Phase
-
-
+
+
- Mve
+ Thread
diff --git a/gui_ui.py b/gui_ui.py
index d0c550b..a01698d 100644
--- a/gui_ui.py
+++ b/gui_ui.py
@@ -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
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index f75530c..cadb9cd 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -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 start↔end 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
- ent.geometry = ((cx, cy), radius)
+ radius = params[0] / 2.0
+ 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:
- cx, cy = ent.geometry
+ 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}")
- return False
+ # 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 centre→start 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 start→end, 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,69 +2668,155 @@ 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.
- self._entity_counter = int(data.get("entity_counter", 0))
+ 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", []):
- eid = int(entry["id"])
+ # 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
- if etype == "point":
- x, y = float(geom[0]), float(geom[1])
- if is_external:
- ent = self.add_external_point(x, y)
+ 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)); 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
+ 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)
+ 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
+ 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)
+ 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))
+ 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)
+ return
+ ent = self.add_arc(
+ entities_by_id[c_id],
+ radius,
+ entities_by_id[s_id],
+ entities_by_id[e_id],
+ sweep=sweep,
+ )
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).
- (x1, y1), (x2, y2) = geom
- s_id = self._find_point_at(x1, y1)
- e_id = self._find_point_at(x2, y2)
- if s_id is None or e_id is None:
- logger.warning("Skipping line %s during load: endpoints not found", eid)
- continue
- 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":
- (cx, cy), radius = geom
- c_id = self._find_point_at(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))
- elif etype == "arc":
- 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)
- 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
- ent = self.add_arc(
- entities_by_id[c_id],
- radius,
- entities_by_id[s_id],
- entities_by_id[e_id],
- sweep=sweep,
- )
- else:
- logger.warning("Unknown sketch entity type %r; skipping", etype)
- continue
+ logger.warning("Unknown sketch entity type %r; skipping", etype)
+ 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", []):
- self._external_entity_ids.add(int(eid))
+ 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", []):
- self._centerline_ids.add(int(eid))
+ 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
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index 8557b1b..6e37d1e 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -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]] = []
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index c980462..4cb1cbc 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -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-
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 34e3b8f..b65815c 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -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 bodies→sketches
+ cycle repeats until nothing moves anymore (bounded), so changes
+ to sketches early in the design propagate all the way down the
+ dependent chain. Then the view, assembly instances and
+ connectors are refreshed.
"""
- self._update_bodies_from_sketch()
- self._update_sketches_from_bodies()
- self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
+ MAX_PASSES = 5
+ for _ in range(MAX_PASSES):
+ self._update_bodies_from_sketch()
+ if self._update_sketches_from_bodies() == 0:
+ break
+ else:
+ # Still converging after the cap — one last body pass so the
+ # final geometry is built from the freshest sketch state.
+ self._update_bodies_from_sketch()
self._redraw_bodies()
self._propagate_to_assembly()
self._recalculate_connectors()
@@ -1265,155 +1512,51 @@ class MainWindow(QMainWindow):
self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self):
- """Re-extrude bodies whose source sketch has been edited.
+ """Rebuild bodies by replaying their parametric feature history.
- For every body in the current component that has a ``source_sketch``
- and stored extrude parameters, re-solve the sketch and rebuild the
- body geometry. Cut / union operations are re-applied against the
- current target body.
+ Every body with a feature list is rebuilt FROM SCRATCH: the base
+ feature (extrude / revolve / snapshot) is recomputed from its
+ source sketch, then each cut / union re-applies in order against
+ the freshly built geometry. Because the base is rebuilt clean,
+ a moved sketch entity REPLACES its previous effect instead of
+ piling on top of it (e.g. a moved circle re-cuts one hole at the
+ new position — the old hole is gone).
+
+ Legacy bodies without a feature list are migrated lazily (see
+ :meth:`_ensure_feature_history`).
"""
if not self._current_component:
return
updated = 0
for body_id, body in list(self._current_component.bodies.items()):
- if body.source_sketch is None or body.extrude_length is None:
- continue # not a re-extrudable body
+ features = _ensure_feature_history(body)
+ if not features:
+ continue # imported / baked body — nothing parametric
- sketch = body.source_sketch
- if not sketch.occ_sketch:
- logger.debug(f"Body '{body.name}': source sketch has no OCC entity, skipping")
- continue
-
- # Re-solve the sketch so geometry reflects any edits.
- sketch.apply_workplane()
- sketch.solve()
-
- # Resolve the face geometry.
- face_geom = None
- if body.extrude_face_index is not None:
- faces = sketch.occ_sketch.detect_faces()
- if 0 <= body.extrude_face_index < len(faces):
- face_geom = sketch.occ_sketch.build_face_geometry(
- faces[body.extrude_face_index]
- )
- else:
- # Face index out of range (topology changed) — fallback
- # to first face if available.
- if faces:
- face_geom = sketch.occ_sketch.build_face_geometry(faces[0])
- logger.info(
- f"Body '{body.name}': face index {body.extrude_face_index} "
- f"invalid, fell back to face 0"
- )
- if face_geom is None:
- face_geom = sketch.occ_sketch.get_geometry()
- if face_geom is None:
- logger.warning(f"Body '{body.name}': no geometry from sketch, skipping")
- continue
-
- # Resolve the target body for cut / union.
- target = None
- if body.extrude_cut or body.extrude_union:
- if (
- body.extrude_target_body_id
- and body.extrude_target_body_id in self._current_component.bodies
- ):
- target = self._current_component.bodies[body.extrude_target_body_id]
- else:
- # Fallback: first body that isn't this one.
- for bid, b in self._current_component.bodies.items():
- if bid != body_id and b.geometry is not None:
- target = b
- break
-
- # Handle cut_all_bodies: apply the cut to every body in the
- # component, not just the target.
- if body.extrude_cut and body.extrude_cut_all_bodies:
- try:
- if body.extrude_through_all and target is not None:
- cut_length = self._through_all_length(target, sketch)
- cut_symmetric = True
- cut_invert = False
- else:
- cut_length = body.extrude_length or 10.0
- cut_symmetric = body.extrude_symmetric
- cut_invert = body.extrude_invert
- tool_geom = self._kernel.extrude(
- face_geom,
- -cut_length if cut_invert else cut_length,
- symmetric=cut_symmetric,
- )
- if tool_geom is None:
- logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
- continue
- cut_count = 0
- for other_id, other in list(self._current_component.bodies.items()):
- if other.geometry is None:
- continue
- try:
- other.geometry = self._kernel.boolean_difference(
- other.geometry, tool_geom
- )
- other.needs_update = False
- other.modified_at = datetime.now()
- cut_count += 1
- except Exception:
- pass # body doesn't intersect tool, skip
- body.needs_update = False
- body.modified_at = datetime.now()
- updated += 1
- logger.info(
- f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
- )
- except Exception as exc:
- logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
- continue # skip the single-target path below
-
- # Compute the new result.
try:
- if body.extrude_through_all and target is not None:
- length = self._through_all_length(target, sketch)
- result = self._compute_extrude_result(
- sketch,
- face_geom,
- length,
- symmetric=True,
- invert=False,
- cut=body.extrude_cut,
- union=body.extrude_union,
- through_all=True,
- )
- else:
- length = body.extrude_length
- result = self._compute_extrude_result(
- sketch,
- face_geom,
- length,
- symmetric=body.extrude_symmetric,
- invert=body.extrude_invert,
- cut=body.extrude_cut,
- union=body.extrude_union,
- through_all=False,
- )
-
- if result is None or result["result_geom"] is None:
- logger.warning(f"Body '{body.name}': re-extrude produced no geometry")
- continue
-
- body.geometry = result["result_geom"]
- body.needs_update = False
- body.modified_at = datetime.now()
- updated += 1
- logger.info(f"Re-extruded body: {body.name}")
-
+ new_geom = _replay_body_features(
+ self._kernel, body, features, self._through_all_length_for_geometry
+ )
except Exception as exc:
- logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
+ logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
+ continue
+
+ if new_geom is None:
+ # Replay aborted — keep the previous geometry and the
+ # needs_update flag so the ⚠ marker stays visible.
+ continue
+
+ body.geometry = new_geom
+ body.needs_update = False
+ body.modified_at = datetime.now()
+ updated += 1
+ logger.info(f"Rebuilt body from features: {body.name}")
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
- def _update_sketches_from_bodies(self) -> None:
+ def _update_sketches_from_bodies(self) -> int:
"""Re-project underlay construction lines from updated 3D bodies.
For every sketch in the current component that carries a
@@ -1423,9 +1566,13 @@ class MainWindow(QMainWindow):
external entities *in place* (preserving entity ids so existing
constraints survive). The solver is re-run so any user geometry
anchored to the underlay follows the body.
+
+ Returns the number of sketches whose underlay actually moved —
+ the caller (``_update_and_redraw``) uses this to decide whether
+ another body-rebuild pass is needed.
"""
if not self._current_component:
- return
+ return 0
from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel()
@@ -1493,6 +1640,7 @@ class MainWindow(QMainWindow):
self._sketch_widget.update()
if updated > 0:
logger.info("Re-projected underlays for %d sketch(es)", updated)
+ return updated
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
@@ -3777,6 +3925,45 @@ class MainWindow(QMainWindow):
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
+ def _record_feature(
+ self,
+ body: Body,
+ operation: str,
+ sketch: Optional[Sketch],
+ length: Optional[float] = None,
+ symmetric: bool = False,
+ invert: bool = False,
+ through_all: bool = False,
+ cut_all_bodies: bool = False,
+ face_index: Optional[int] = None,
+ angle: float = 360.0,
+ ) -> None:
+ """Append *operation* to *body*'s parametric feature history.
+
+ If the body has no features yet but already holds geometry (a
+ legacy body created before feature history existed), a frozen
+ "base" snapshot of the CURRENT geometry is inserted first so
+ replays start from a known state. Call this BEFORE assigning
+ the new geometry onto ``body.geometry`` — the snapshot must
+ capture the pre-operation state.
+ """
+ if not body.features and body.geometry is not None and operation in ("cut", "union"):
+ body.features.append(Feature(operation="base", geometry=body.geometry))
+ logger.info(f"Body '{body.name}': snapshotted legacy geometry as feature base")
+ body.features.append(
+ Feature(
+ operation=operation,
+ sketch=sketch,
+ length=length,
+ symmetric=symmetric,
+ invert=invert,
+ through_all=through_all,
+ cut_all_bodies=cut_all_bodies,
+ face_index=face_index,
+ angle=angle,
+ )
+ )
+
def _resolve_extrude_target(
self, sketch: Sketch, exclude_body: Optional[Body] = None
) -> Optional[Body]:
@@ -3804,22 +3991,45 @@ class MainWindow(QMainWindow):
return body
return None
+ def _find_extrude_body_for_sketch(self, sketch: Sketch) -> Optional[Body]:
+ """Return the existing body that was created from *sketch* via a
+ plain extrude, or *None* if no such body exists.
+
+ Used by the plain-extrude path of :meth:`_extrude_sketch` to decide
+ between "update the existing body in place" and "create a new
+ body". Without this, clicking Extrude a second time on the same
+ sketch stacks a fresh 44mm solid on top of the original 44mm
+ solid and the user perceives the apparent depth as 88mm.
+ """
+ if self._current_component is None or sketch is None:
+ return None
+ for body in self._current_component.bodies.values():
+ if body.source_sketch is sketch and body.source_operation == "extrude":
+ return body
+ return None
+
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
- *through* the target body.
+ *through* the target body. See :meth:`_through_all_length_for_geometry`.
+ """
+ return self._through_all_length_for_geometry(target.geometry, sketch)
- Computes the target body's bounding-box extent along the sketch's
- workplane normal direction ("extent" = how far the body reaches on
+ def _through_all_length_for_geometry(self, geometry: Any, sketch: Sketch) -> float:
+ """Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
+ *through* a body geometry.
+
+ Computes the geometry's bounding-box extent along the sketch's
+ workplane normal direction ("extent" = how far the solid reaches on
either side of the face). With ``symmetric=True`` the kernel
extrudes ``± height/2``, so to clear the full ``extent`` on each
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
- on each side guarantees the tool pokes out past the body so the
+ on each side guarantees the tool pokes out past the solid so the
boolean reliably removes the through volume.
"""
import numpy as _np
try:
- p_min, p_max = self._kernel.get_bounding_box(target.geometry)
+ p_min, p_max = self._kernel.get_bounding_box(geometry)
except Exception:
logger.debug("through-all bbox failed", exc_info=True)
return 2000.0 # generous fallback if bbox fails for any reason
@@ -4087,10 +4297,23 @@ class MainWindow(QMainWindow):
for body in all_targets:
try:
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
- body.geometry = new_geom
except Exception as exc:
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
continue
+ # Record the feature BEFORE committing the geometry so
+ # a legacy body snapshots its pre-cut state as base.
+ self._record_feature(
+ body,
+ "cut",
+ sketch,
+ length=length,
+ symmetric=symmetric,
+ invert=invert,
+ through_all=bool(through_all),
+ cut_all_bodies=True,
+ face_index=face_index,
+ )
+ body.geometry = new_geom
body.extrude_length = length
body.extrude_symmetric = symmetric
body.extrude_invert = invert
@@ -4111,7 +4334,19 @@ class MainWindow(QMainWindow):
body_name = f"{updated_count} body(ies)"
elif target is not None:
# Single-body cut / union: commit the result onto the *target*
- # body in place.
+ # body in place. Record the feature BEFORE committing so a
+ # legacy body snapshots its pre-op geometry as base.
+ self._record_feature(
+ target,
+ "cut" if cut else "union",
+ sketch,
+ length=length,
+ symmetric=symmetric,
+ invert=invert,
+ through_all=bool(through_all),
+ cut_all_bodies=False,
+ face_index=face_index,
+ )
target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
@@ -4133,30 +4368,81 @@ class MainWindow(QMainWindow):
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
body_name = target.name
else:
- # Plain extrude: create a new body for the extrusion.
- body = self._current_component.add_body(
- Body(
- name=f"Extrusion_{len(self._current_component.bodies) + 1}",
- geometry=result["result_geom"],
- source_sketch=sketch,
- source_operation="extrude",
- extrude_length=length,
- extrude_symmetric=symmetric,
- extrude_invert=invert,
- extrude_cut=False,
- extrude_union=False,
- extrude_through_all=bool(through_all),
- extrude_cut_all_bodies=False,
- extrude_face_index=face_index,
+ # Plain extrude. If this sketch already produced an
+ # existing extrude body, UPDATE that body in place rather
+ # than stacking a fresh 44mm solid on top of the old one
+ # — which the user perceives as "44mm looks like 88mm"
+ # because the two coincident solids visually sum.
+ existing = self._find_extrude_body_for_sketch(sketch)
+ if existing is not None:
+ body = existing
+ # Record the new feature BEFORE replacing geometry so
+ # the parametric history reflects the latest op.
+ self._record_feature(
+ body,
+ "extrude",
+ sketch,
+ length=length,
+ symmetric=symmetric,
+ invert=invert,
+ through_all=bool(through_all),
+ face_index=face_index,
)
- )
- self._mark_dirty()
- logger.info(f"Created body: {body.name}")
- logger.debug("Adding shape to OCC viewer")
- shape = self._kernel._get_shape(body.geometry)
- body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
- logger.info(f"Render object: {body.render_object}")
- body_name = body.name
+ body.geometry = result["result_geom"]
+ body.extrude_length = length
+ body.extrude_symmetric = symmetric
+ body.extrude_invert = invert
+ body.extrude_cut = False
+ body.extrude_union = False
+ body.extrude_through_all = bool(through_all)
+ body.extrude_cut_all_bodies = False
+ body.extrude_face_index = face_index
+ body.source_sketch = sketch
+ body.source_operation = "extrude"
+ body.extrude_target_body_id = None
+ self._mark_dirty()
+ logger.info(f"Updated existing body in place: {body.name}")
+ if body.render_object is not None:
+ self._viewer_3d.remove_mesh(body.render_object)
+ shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
+ body_name = body.name
+ else:
+ # Plain extrude: create a new body for the extrusion.
+ body = self._current_component.add_body(
+ Body(
+ name=f"Extrusion_{len(self._current_component.bodies) + 1}",
+ geometry=result["result_geom"],
+ source_sketch=sketch,
+ source_operation="extrude",
+ extrude_length=length,
+ extrude_symmetric=symmetric,
+ extrude_invert=invert,
+ extrude_cut=False,
+ extrude_union=False,
+ extrude_through_all=bool(through_all),
+ extrude_cut_all_bodies=False,
+ extrude_face_index=face_index,
+ features=[
+ Feature(
+ operation="extrude",
+ sketch=sketch,
+ length=length,
+ symmetric=symmetric,
+ invert=invert,
+ through_all=bool(through_all),
+ face_index=face_index,
+ )
+ ],
+ )
+ )
+ self._mark_dirty()
+ logger.info(f"Created body: {body.name}")
+ logger.debug("Adding shape to OCC viewer")
+ shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
+ logger.info(f"Render object: {body.render_object}")
+ body_name = body.name
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
@@ -4210,6 +4496,14 @@ class MainWindow(QMainWindow):
geometry=body_geometry,
source_sketch=sketch,
source_operation="revolve",
+ features=[
+ Feature(
+ operation="revolve",
+ sketch=sketch,
+ angle=angle,
+ face_index=self._sketch_widget.get_selected_face_index(),
+ )
+ ],
)
)
self._mark_dirty()
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index f881c2b..cc6fe87 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -97,6 +97,12 @@ class Sketch2DWidget(QWidget):
constrain_done = Signal()
sketch_updated = Signal()
+ # Emitted when the SolveSpace solver returns a non-OKAY result
+ # (INCONSISTENT, DIDNT_CONVERGE, or an exception). The payload is
+ # a short human-readable explanation; the main window hooks this up
+ # to the status bar so the user sees the failure instead of the
+ # geometry silently staying put.
+ solver_warning = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
@@ -524,6 +530,24 @@ class Sketch2DWidget(QWidget):
return False
return entity.id in self._sketch._centerline_ids
+ @staticmethod
+ def _flat_xy(geometry: Any) -> Optional[Tuple[float, float]]:
+ """Return ``(x, y)`` when *geometry* is a flat 2-tuple of numbers (a point).
+
+ Guards the point-iteration loops against malformed point geometry
+ (e.g. circle-shaped ``((cx, cy), r)`` left behind by a legacy
+ load or constraint replay) so a bad entity degrades to "not
+ snappable / not drawn" instead of crashing ``round()``.
+ """
+ if (
+ isinstance(geometry, (tuple, list))
+ and len(geometry) == 2
+ and isinstance(geometry[0], (int, float))
+ and isinstance(geometry[1], (int, float))
+ ):
+ return (float(geometry[0]), float(geometry[1]))
+ return None
+
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
@@ -624,6 +648,45 @@ class Sketch2DWidget(QWidget):
self.setCursor(Qt.ArrowCursor)
self.update()
+ def _is_drawing_tool_active(self) -> bool:
+ """Return True while a drawing or constraint tool is in progress.
+
+ Used to decide whether to render the constraint badges / dimension
+ lines in ``paintEvent``. They are visually loud and frequently
+ overlap the geometry the user is currently trying to place, so we
+ suppress them while an operation is mid-flight and bring them back
+ as soon as the operation is finished, cancelled, or never started
+ (``mode is None`` or ``mode == "select"``).
+ """
+ if self._mode is None or self._mode == "select":
+ return False
+ return True
+
+ def _cancel_active_tool(self) -> None:
+ """Abort the currently active drawing / constraint tool.
+
+ Single source of truth for "deselect the current tool" — called
+ from the Escape key handler, the right-click cancel branch, and
+ anywhere else that needs to drop the user back to a neutral state.
+ Resets the draw buffer, arc-sweep tracker, and any in-progress
+ constraint multi-select, then emits ``constrain_done`` so the
+ toolbar buttons uncheck themselves.
+ """
+ if not self._is_drawing_tool_active():
+ return
+ self._mode = None
+ self._draw_buffer = []
+ self._dynamic_line_end = None
+ self._selected_entities = []
+ self._hovered_constraint_idx = -1
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
+ # Invalidate any pending dimension-input prompt so the value
+ # dialog (if one is open elsewhere) doesn't latch onto stale state.
+ self._pending_distance_val = None
+ self.constrain_done.emit()
+ self.update()
+
def set_construct_mode(self, enabled: bool):
self._is_construct = enabled
@@ -683,9 +746,12 @@ class Sketch2DWidget(QWidget):
# cursor position while the entity lookup returned None and no
# coincident constraint was created (silent disconnect).
entity = self._find_nearest_point_entity(pos, max_distance)
- if entity is None or entity.geometry is None:
+ if entity is None:
return None
- x, y = entity.geometry
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ return None
+ x, y = xy
return QPoint(int(round(x)), int(round(y)))
def _find_nearest_point_entity(
@@ -705,16 +771,16 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if self._is_external(entity) and not self._underlay_visible:
continue
- if entity.geometry:
- x, y = entity.geometry
- point = QPoint(int(round(x)), int(round(y)))
- screen_point = self._world_to_screen(point)
- dist = math.sqrt(
- (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
- )
- if dist < min_dist:
- min_dist = dist
- nearest = entity
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ continue
+ x, y = xy
+ point = QPoint(int(round(x)), int(round(y)))
+ screen_point = self._world_to_screen(point)
+ dist = math.sqrt((pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2)
+ if dist < min_dist:
+ min_dist = dist
+ nearest = entity
return nearest
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
@@ -890,9 +956,12 @@ class Sketch2DWidget(QWidget):
nearest_entity = None
min_dist = self._snap_distance
for entity in self._points:
- if entity.id in exclude_ids or not entity.geometry:
+ if entity.id in exclude_ids:
continue
- x, y = entity.geometry
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ continue
+ x, y = xy
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
if d < min_dist:
@@ -990,11 +1059,13 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if self._is_external(entity) and not self._underlay_visible:
continue
- if entity.geometry:
- x, y = entity.geometry
- dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
- if dist < self._pick_tolerance_world(10):
- return entity
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ continue
+ x, y = xy
+ dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
+ if dist < self._pick_tolerance_world(10):
+ return entity
return None
def _get_line_entity_at(
@@ -1440,7 +1511,28 @@ class Sketch2DWidget(QWidget):
return None
def _sync_solved_positions(self):
- """Sync solver positions back to UI points and lines."""
+ """Sync solver positions back to UI points, lines, AND arcs.
+
+ The UI keeps its own ``self._arcs`` list (a snapshot of
+ ``(centre, radius, start, end, sweep)`` tuples built in
+ :meth:`_rebuild_from_sketch`) for fast access in
+ :meth:`paintEvent`. The radius in that list was a frozen copy
+ of the value at creation time — before the sketch solver was
+ wired up to the arc entity, it never changed after a solve, so
+ arcs visually stayed the same size even when the user resized
+ the rectangle they were attached to.
+
+ Now that the sketch solver owns the arc (and refreshes the
+ radius from the current centre→start distance after every
+ solve in :meth:`OCCSketch._sync_solved_positions`), we mirror
+ that refresh here: read each arc's current radius / sweep
+ straight from the authoritative ``self._sketch._arcs`` dict and
+ update the corresponding tuple in ``self._arcs`` in place.
+
+ Tuples are immutable so we replace each entry with a freshly-
+ built tuple rather than mutating an item — the ``paintEvent``
+ loop keeps working without re-running ``_rebuild_from_sketch``.
+ """
if not self._sketch:
return
for entity in self._points:
@@ -1454,12 +1546,66 @@ class Sketch2DWidget(QWidget):
for p1_ent, p2_ent in self._lines:
if p1_ent.geometry and p2_ent.geometry:
pass # geometry already updated via point sync
+ # Refresh arc radii / sweeps from the sketch so a coincident-
+ # constrained arc (e.g. attached to a rectangle's corner) resizes
+ # with the rectangle. See OCCSketch._sync_solved_positions.
+ if self._arcs:
+ import math as _m
+
+ new_arcs = []
+ for centre_ent, _radius, start_ent, end_ent, _sweep in self._arcs:
+ arc_id = None
+ for aid, adata in self._sketch._arcs.items():
+ if adata.get("center") == centre_ent.id:
+ arc_id = aid
+ break
+ if arc_id is None:
+ # The sketch no longer has this arc (deleted by
+ # ``delete_point`` / project load). Drop it from
+ # the UI list so paintEvent doesn't draw a ghost.
+ continue
+ adata = self._sketch._arcs[arc_id]
+ new_radius = float(adata.get("radius", _radius))
+ new_sweep = adata.get("sweep", _sweep)
+ if (
+ new_sweep is None
+ and start_ent.geometry
+ and end_ent.geometry
+ and centre_ent.geometry
+ ):
+ # Legacy arc without a stored sweep — fall back to the
+ # same shortest-path inference that paintEvent uses.
+ sx, sy = start_ent.geometry
+ ex, ey = end_ent.geometry
+ cx, cy = centre_ent.geometry
+ sa = _m.atan2(sy - cy, sx - cx)
+ ea = _m.atan2(ey - cy, ex - cx)
+ new_sweep = ea - sa
+ while new_sweep > _m.pi:
+ new_sweep -= 2 * _m.pi
+ while new_sweep < -_m.pi:
+ new_sweep += 2 * _m.pi
+ new_arcs.append((centre_ent, new_radius, start_ent, end_ent, new_sweep))
+ self._arcs = new_arcs
def _solve_and_sync(self) -> bool:
- """Solve constraints, sync positions, update UI. Returns True if solved OK."""
+ """Solve constraints, sync positions, update UI. Returns True if solved OK.
+
+ On a non-OKAY solve result (INCONSISTENT, DIDNT_CONVERGE, ...)
+ the geometry is left untouched and :attr:`solver_warning` is
+ emitted with a one-line human-readable explanation sourced from
+ :attr:`OCCSketch.last_solve_status`. The main window hooks this
+ to the status bar so the user sees the failure instead of the
+ geometry silently staying put.
+ """
if not self._sketch:
return True
ok = self._sketch.solve()
+ if not ok:
+ # Surface the failure to the user. Status text comes from
+ # OCCSketch.last_solve_status (e.g. "inconsistent: the new
+ # constraint conflicts with existing constraints").
+ self.solver_warning.emit(f"Solver failed — {self._sketch.last_solve_status}")
self._sync_solved_positions()
self.update()
return ok
@@ -1635,16 +1781,13 @@ class Sketch2DWidget(QWidget):
return
if event.button() == Qt.RightButton:
- # Only clear drawing-state, preserve any point selection for the
- # context menu (contextMenuEvent is invoked by Qt after this).
- if self._mode in ("line", "rectangle", "circle", "arc", "slot"):
- self._mode = None
- self._draw_buffer = []
- self._dynamic_line_end = None
- self._arc_accum_sweep = 0.0
- self._arc_prev_angle = None
- self.constrain_done.emit()
- # Do NOT return — let Qt deliver the contextMenuEvent.
+ # Right-click cancels the active drawing/constraint tool (same
+ # behaviour as pressing Escape). Routed through the shared
+ # helper so right-click, Escape, and any future cancel path
+ # all perform the exact same teardown. We only suppress the
+ # tool itself — the context menu (if any) is still delivered
+ # by Qt afterwards, so we do NOT return here.
+ self._cancel_active_tool()
return
if event.button() == Qt.LeftButton:
@@ -1957,6 +2100,32 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync()
self._snap_point_target = None
else:
+ # Lock the moved anchor in its new location with a
+ # ``dragged`` constraint. ``set_positions`` alone only
+ # seeds the solver's initial state; with the many DOFs
+ # a free sketch typically has, the solver reverts the
+ # position to minimise the L2 parameter change. Adding
+ # ``dragged`` makes the move a hard constraint so the
+ # position sticks — and lets the arc constraint (now
+ # live in the solver, see OCCSketch._sync_solved_positions)
+ # propagate the change into a new centre/radius.
+ #
+ # Why only the anchor? ``_collect_connected_points``
+ # already moves every connected point by the same delta
+ # in mouseMoveEvent; locking all of them would freeze
+ # the entire connected component (e.g. a 4-corner
+ # rectangle becomes a rigid body and individual corners
+ # can no longer be dragged to resize it). Locking just
+ # the anchor lets the other points adjust under the
+ # line / arc / coincident constraints, which is what
+ # the user expects from dragging a single corner.
+ if self._move_anchor is not None and self._move_anchor.geometry is not None:
+ ax, ay = self._move_anchor.geometry
+ if not self._sketch.is_entity_dragged(self._move_anchor.id):
+ self._sketch.constrain_fixed(self._move_anchor)
+ # constrain_fixed reads the current params via
+ # the dragged() call, so re-sync to be safe.
+ self._solve_and_sync()
# Auto-constrain: snap → coincident / point-on-line
target = None
if self._snap_point_target is not None:
@@ -2054,6 +2223,17 @@ class Sketch2DWidget(QWidget):
event.accept()
return
+ # Escape cancels the active drawing/constraint tool. This is
+ # the user-facing counterpart to right-click: drop the in-progress
+ # operation (without losing the entities already placed) and
+ # uncheck the toolbar button so the constraint badges / dimension
+ # overlay reappears.
+ if event.key() == Qt.Key_Escape and not event.modifiers():
+ if self._is_drawing_tool_active():
+ self._cancel_active_tool()
+ event.accept()
+ return
+
super().keyPressEvent(event)
def contextMenuEvent(self, event):
@@ -2247,6 +2427,54 @@ class Sketch2DWidget(QWidget):
self.sketch_updated.emit()
self.update()
+ def convert_hovered_line_to_construction(self) -> bool:
+ """Convert the hovered line to a construction line.
+
+ Public counterpart of ``_toggle_hovered_line_construction`` that
+ always sets the line to construction (not toggle). Wired to the
+ toolbar "Cstrct" button so that selecting an existing line and
+ pressing the button promotes it to a construction line — the
+ same behaviour the C key provides via toggle, but with
+ button-as-action semantics.
+
+ Only the line entity's ``is_construction`` flag is changed; its
+ endpoint points are left alone so that other lines sharing the
+ same endpoint are unaffected. External (underlay) lines and
+ centerlines are rejected (matching the toggle behaviour).
+
+ Returns ``True`` if a line was converted, ``False`` otherwise
+ (no sketch, no hover, external/centerline, or already
+ construction). Callers use the return value to decide whether
+ the button should be left in the "on" state.
+ """
+ line_ent = self._hovered_line_entity
+ if line_ent is None or self._sketch is None:
+ return False
+ # External (underlay) lines are reference geometry from the source
+ # face — they can't be promoted individually.
+ if getattr(line_ent, "is_external", False):
+ logger.debug("Refusing to convert external (underlay) line to construction")
+ return False
+ # Centerlines are permanent reference axes — refuse promotion.
+ if self._is_centerline(line_ent):
+ logger.debug("Refusing to convert centerline to construction")
+ return False
+ # Already construction — nothing to do, but still report success
+ # so the caller keeps the button in a consistent "on" state.
+ if getattr(line_ent, "is_construction", False):
+ return True
+ # Save state before converting
+ if self._undo_manager:
+ self._undo_manager.save_state()
+ line_ent.is_construction = True
+ logger.info(f"Converted line {line_ent.id} to construction via toolbar")
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+ return True
+
# ─── Drawing handlers ─────────────────────────────────────────────────
def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None:
@@ -2987,15 +3215,34 @@ class Sketch2DWidget(QWidget):
if self._undo_manager:
self._undo_manager.save_state()
new_radius = diameter / 2.0
- # Update the circle's radius in the sketch
- self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
+ # Update the circle's radius in the sketch. Note that
+ # ``self._circles`` holds (center_point_entity, radius)
+ # — ``c_ent`` is the CENTER point, not the circle. The
+ # sketch's ``_circles`` dict is keyed by circle id with
+ # values (center_id, radius), so we must find the circle
+ # entity whose center is the clicked point and route
+ # through ``constrain_diameter``. The old code wrote a
+ # bogus entry keyed by the center point id and left the
+ # real circle entry untouched with the OLD radius — that
+ # ghost circle kept forming a face and made hole cuts
+ # use the stale diameter.
+ circle_found = False
+ for cid, (center_id, _old_r) in self._sketch._circles.items():
+ if center_id == c_ent.id:
+ circle_ent = self._sketch._entities.get(cid)
+ if circle_ent is not None:
+ self._sketch.constrain_diameter(circle_ent, diameter)
+ circle_found = True
+ break
+ if not circle_found:
+ # Defensive fallback: keep the direct update rather
+ # than silently dropping the user's input.
+ self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
# Update the local cache
for i, (ent, rad) in enumerate(self._circles):
if ent.id == c_ent.id:
self._circles[i] = (ent, new_radius)
break
- # Record constraint for undo/redo
- self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = []
@@ -3285,8 +3532,9 @@ class Sketch2DWidget(QWidget):
# at the endpoints far out of view.
if self._is_centerline(entity):
continue
- if entity.geometry:
- x, y = entity.geometry
+ xy = self._flat_xy(entity.geometry)
+ if xy is not None:
+ x, y = xy
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
if entity.is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1))
@@ -3316,12 +3564,14 @@ class Sketch2DWidget(QWidget):
for entity in self._points:
if not self._is_external(entity):
continue
- if entity.geometry:
- x, y = entity.geometry
- screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- painter.setPen(QPen(QColor("#fab387"), 1))
- painter.setBrush(QBrush(QColor("#fab387")))
- painter.drawEllipse(screen_pos, 4, 4)
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ continue
+ x, y = xy
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.setPen(QPen(QColor("#fab387"), 1))
+ painter.setBrush(QBrush(QColor("#fab387")))
+ painter.drawEllipse(screen_pos, 4, 4)
# ── Lines ──
for p1_ent, p2_ent in self._lines:
@@ -3352,35 +3602,44 @@ class Sketch2DWidget(QWidget):
# ── Constraint tags (log-driven; drawn upright in screen space) ──
# Tags are recomputed here so paint stays in sync with the latest solve.
+ # While a drawing or constraint tool is active the badges are hidden
+ # because their pills/extension lines routinely cover the very
+ # geometry the user is trying to place — they reappear as soon as
+ # the operation finishes, Escape is pressed, or the tool is
+ # otherwise deselected (see ``_is_drawing_tool_active``).
self._constraint_tags = self._compute_constraint_tags()
- tag_font = QFont("Monospace", 9)
- painter.setFont(tag_font)
- for tag in self._constraint_tags:
- rect: QRect = tag["rect"]
- hovered = tag["idx"] == self._hovered_constraint_idx
- # Background pill so the label is readable over the sketch.
- painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
- painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
- painter.drawRoundedRect(rect, 6, 6)
- painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
- painter.drawText(rect, Qt.AlignCenter, tag["label"])
+ if not self._is_drawing_tool_active():
+ tag_font = QFont("Monospace", 9)
+ painter.setFont(tag_font)
+ for tag in self._constraint_tags:
+ rect: QRect = tag["rect"]
+ hovered = tag["idx"] == self._hovered_constraint_idx
+ # Background pill so the label is readable over the sketch.
+ painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
+ painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
+ painter.drawRoundedRect(rect, 6, 6)
+ painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
+ painter.drawText(rect, Qt.AlignCenter, tag["label"])
- # ── Technical dimension lines for distance constraints ──
- # Draw proper measurement lines (extension lines + dimension line
- # with arrowheads + centred text) for every distance constraint.
- for tag in self._constraint_tags:
- if "p1_world" in tag and "p2_world" in tag:
- try:
- self._draw_technical_dimension(
- painter,
- tag["p1_world"],
- tag["p2_world"],
- tag["distance"],
- )
- except Exception:
- import traceback
+ # ── Technical dimension lines for distance constraints ──
+ # Draw proper measurement lines (extension lines + dimension
+ # line with arrowheads + centred text) for every distance
+ # constraint. Gated together with the badges above so the
+ # whole constraint overlay hides as one unit while a tool is
+ # active.
+ for tag in self._constraint_tags:
+ if "p1_world" in tag and "p2_world" in tag:
+ try:
+ self._draw_technical_dimension(
+ painter,
+ tag["p1_world"],
+ tag["p2_world"],
+ tag["distance"],
+ )
+ except Exception:
+ import traceback
- traceback.print_exc()
+ traceback.print_exc()
# ── Circles ──
for center_ent, radius in self._circles: