Files
fluencyCAD/src/fluency/sketch_undo.py
T
bklronin 9f1387fe68 - added renderer
- Added undo
2026-07-12 22:21:43 +02:00

373 lines
14 KiB
Python

"""
Undo/Redo manager for OCCSketch using snapshot-based approach.
python_solvespace has no per-entity delete API — the solver is rebuilt from
scratch after every modification. This makes snapshot-based undo natural:
we capture the complete sketch state (entities, geometry, constraints) and
restore it by rebuilding the solver from the snapshot data.
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
stack at a configurable depth (default 50).
"""
from __future__ import annotations
import copy
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
logger = logging.getLogger(__name__)
@dataclass
class SketchSnapshot:
"""Immutable snapshot of sketch state for undo/redo.
Captures everything needed to fully reconstruct an OCCSketch:
entities, points, lines, circles, arcs, counter, constraint log,
and the special entity id sets (centerlines, external/underlay).
"""
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
# Geometry sub-indices
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
# Counters and flags
entity_counter: int = 0
constraint_count: int = 0
first_point_id: Optional[int] = None
# Constraint replay log
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
# Special entity sets
centerline_ids: Set[int] = field(default_factory=set)
external_entity_ids: Set[int] = field(default_factory=set)
# Workplane (so undo doesn't lose the placement plane)
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
class SketchUndoManager:
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
Usage::
undo_mgr = SketchUndoManager(sketch)
# Before any modification:
undo_mgr.save_state()
# ... perform modification ...
# Ctrl+Z:
undo_mgr.undo()
# Ctrl+Y / Ctrl+Shift+Z:
undo_mgr.redo()
"""
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
from fluency.geometry_occ.sketch import OCCSketch
self._sketch: OCCSketch = sketch
self._max_stack_size = max_stack_size
self._undo_stack: List[SketchSnapshot] = []
self._redo_stack: List[SketchSnapshot] = []
# ─── Public API ────────────────────────────────────────────────────────
@property
def can_undo(self) -> bool:
"""True if there is a state to undo to."""
return len(self._undo_stack) > 0
@property
def can_redo(self) -> bool:
"""True if there is a state to redo to."""
return len(self._redo_stack) > 0
@property
def undo_depth(self) -> int:
"""Number of undo levels available."""
return len(self._undo_stack)
@property
def redo_depth(self) -> int:
"""Number of redo levels available."""
return len(self._redo_stack)
def save_state(self) -> None:
"""Capture the current sketch state and push it onto the undo stack.
Call this **before** any modifying operation (draw, delete, move,
constraint add/remove, construction toggle, etc.).
The redo stack is cleared whenever a new state is saved (i.e. when
the user makes a new change after undoing).
"""
snapshot = self._capture()
self._undo_stack.append(snapshot)
# Cap the stack size.
if len(self._undo_stack) > self._max_stack_size:
self._undo_stack.pop(0)
# New mutation invalidates the redo history.
self._redo_stack.clear()
logger.debug(
f"save_state: undo_depth={len(self._undo_stack)} "
f"entities={len(snapshot.entities)}"
)
def undo(self) -> bool:
"""Restore the previous sketch state.
Returns True if a state was restored, False if the undo stack is empty.
"""
if not self._undo_stack:
logger.debug("undo: stack empty")
return False
# Save current state to redo stack before restoring.
current_snapshot = self._capture()
self._redo_stack.append(current_snapshot)
# Pop and restore.
snapshot = self._undo_stack.pop()
self._restore(snapshot)
logger.debug(
f"undo: restored state with {len(snapshot.entities)} entities, "
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
)
return True
def redo(self) -> bool:
"""Re-apply the most recently undone state.
Returns True if a state was restored, False if the redo stack is empty.
"""
if not self._redo_stack:
logger.debug("redo: stack empty")
return False
# Save current state to undo stack before restoring.
current_snapshot = self._capture()
self._undo_stack.append(current_snapshot)
# Pop and restore.
snapshot = self._redo_stack.pop()
self._restore(snapshot)
logger.debug(
f"redo: restored state with {len(snapshot.entities)} entities, "
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
)
return True
def clear(self) -> None:
"""Clear both stacks (e.g. when loading a new sketch)."""
self._undo_stack.clear()
self._redo_stack.clear()
logger.debug("undo stacks cleared")
# ─── Snapshot Capture ──────────────────────────────────────────────────
def _capture(self) -> SketchSnapshot:
"""Capture the current sketch state into a snapshot."""
sketch = self._sketch
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
for eid, ent in sketch._entities.items():
entities[eid] = (
ent.entity_type,
ent.geometry,
ent.is_construction,
ent.is_external,
list(ent.constraints), # copy the constraints list
)
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
# but arcs contain dicts so we deep-copy those).
points = dict(sketch._points)
lines = dict(sketch._lines)
circles = dict(sketch._circles)
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
# Constraint log entries contain tuples and sets — need careful copy.
constraint_log = []
for entry in sketch._constraint_log:
copied = {
"type": entry["type"],
"ids": tuple(entry["ids"]),
"params": tuple(entry["params"]) if entry.get("params") else (),
"labels": set(entry["labels"]) if entry.get("labels") else set(),
}
constraint_log.append(copied)
return SketchSnapshot(
entities=entities,
points=points,
lines=lines,
circles=circles,
arcs=arcs,
entity_counter=sketch._entity_counter,
constraint_count=sketch._constraint_count,
first_point_id=sketch._first_point_id,
constraint_log=constraint_log,
centerline_ids=set(sketch._centerline_ids),
external_entity_ids=set(sketch._external_entity_ids),
wp_origin=sketch._wp_origin,
wp_normal=sketch._wp_normal,
wp_x_dir=sketch._wp_x_dir,
wp_y_dir=sketch._wp_y_dir,
)
# ─── Snapshot Restore ──────────────────────────────────────────────────
def _restore(self, snapshot: SketchSnapshot) -> None:
"""Restore the sketch to a previously captured snapshot state."""
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
sketch = self._sketch
# Clear the current solver and rebuild from scratch.
sketch._solver = sketch._solver.__class__() # SolverSystem()
sketch._wp = sketch._solver.create_2d_base()
sketch._first_point_id = None
# Restore counters and flags.
sketch._entity_counter = snapshot.entity_counter
sketch._constraint_count = snapshot.constraint_count
sketch._centerline_ids = set(snapshot.centerline_ids)
sketch._external_entity_ids = set(snapshot.external_entity_ids)
# Restore workplane.
sketch._wp_origin = snapshot.wp_origin
sketch._wp_normal = snapshot.wp_normal
sketch._wp_x_dir = snapshot.wp_x_dir
sketch._wp_y_dir = snapshot.wp_y_dir
# Rebuild entity objects from the snapshot.
sketch._entities.clear()
sketch._points.clear()
sketch._lines.clear()
sketch._circles.clear()
sketch._arcs.clear()
# First pass: re-add all points to the solver.
for eid in sorted(snapshot.entities.keys()):
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
if etype == "point" and eid in snapshot.points:
x, y = snapshot.points[eid]
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
ent = OCCSketchEntity(
entity_id=eid,
entity_type="point",
geometry=(x, y),
handle=solver_handle,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[eid] = ent
sketch._points[eid] = (x, y)
# Anchor the first point (or first external point) for solver stability.
if sketch._first_point_id is None and not is_ext:
sketch._first_point_id = eid
sketch._solver.dragged(solver_handle, sketch._wp)
elif sketch._first_point_id is None and is_ext:
sketch._first_point_id = eid
sketch._solver.dragged(solver_handle, sketch._wp)
# Second pass: re-add all lines.
for lid in sorted(snapshot.lines.keys()):
sid, eid2 = snapshot.lines[lid]
s_ent = sketch._entities.get(sid)
e_ent = sketch._entities.get(eid2)
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
continue
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
ent = OCCSketchEntity(
entity_id=lid,
entity_type="line",
geometry=geometry,
handle=solver_handle,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[lid] = ent
sketch._lines[lid] = (sid, eid2)
# Restore circles (not in solver, just tracked).
for cid, (center_id, radius) in snapshot.circles.items():
if cid in snapshot.entities:
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
center_ent = sketch._entities.get(center_id)
ent = OCCSketchEntity(
entity_id=cid,
entity_type="circle",
geometry=geometry,
handle=None,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[cid] = ent
sketch._circles[cid] = (center_id, radius)
# Restore arcs (not in solver, just tracked).
for aid, arc_data in snapshot.arcs.items():
if aid in snapshot.entities:
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
ent = OCCSketchEntity(
entity_id=aid,
entity_type="arc",
geometry=geometry,
handle=None,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[aid] = ent
sketch._arcs[aid] = copy.deepcopy(arc_data)
# Rebuild the constraint log (entries were deep-copied on capture).
sketch._constraint_log = []
for entry in snapshot.constraint_log:
sketch._constraint_log.append({
"type": entry["type"],
"ids": tuple(entry["ids"]),
"params": tuple(entry["params"]) if entry.get("params") else (),
"labels": set(entry["labels"]) if entry.get("labels") else set(),
})
# Re-apply all constraints to the solver.
for entry in sketch._constraint_log:
sketch._apply_constraint_log(entry)
# Solve to update geometry positions.
sketch.solve()
logger.debug(
f"Restored snapshot: {len(sketch._entities)} entities, "
f"{len(sketch._constraint_log)} constraints"
)