9f1387fe68
- Added undo
373 lines
14 KiB
Python
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"
|
|
)
|