feat(sketch): live drag that keeps its solution, ended by sketch.drag_end (#341)

* feat(sketch): constructive solving ahead of the numeric solver

Part 1 of splitting #251: the sketch crate only. A graph-constructive front
end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass
loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the
rest, and the rank analysis stays authoritative for degrees of freedom. Solves
can follow a reference (the previous solution), so edits and drags keep their
branch instead of flipping.

New API: solve_with (hold, reference, analysis, budget) and
solve_after_value_edit; solve and solve_holding keep their signatures as
wrappers, so no caller changes here.

When the seed moves an arc's centre, the arc's unconstructed ends start carried
along (the arc translates) instead of left behind for LM to turn; found by the
reference test 20-slot-constrained.

Refs #203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat(doc): keep a sketch's solved state across value edits

Part 2 of splitting #251 (builds on part 1, the sketch crate):
- Sketch edits solve for the document's values continuing from the stored
  solved state (Document::solve_sketch), so a dimension edit keeps the
  sketch's branch instead of re-solving from scratch.
- A value edit (parameters, a configuration, the units) that would make a
  sketch that solved before fail or turn over is refused with the
  conflicting constraints named; otherwise the solved sketches are written
  back into the document (Model::adopt_solved_sketches), which changes what
  saved designs store.
- Later features are keyed by a sketch's solved geometry (to a 1e-6 mm
  quantum), so setting a value back reuses their earlier results.
- Solved sketches in a model state are shared (Arc) between states.

Refs #203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat(sketch): live drag that keeps its solution, ended by sketch.drag_end

Part 3 of splitting #251 (builds on part 2):
- sketch.drag follows a long move in short held steps, so the geometry keeps
  its branch, and reports how far it got (reached, at).
- live: true runs one frame of an interactive drag: the sketch is solved
  once and shown, later features keep their last result (marked stale)
  until sketch.drag_end or any command that reads the model evaluates it
  (CommandSpec::live, reads_model, doc_only).
- sketch.drag_end adds the constraint the drag let go on and evaluates the
  model once.
- Undo and redo evaluate a new document once (execute does) instead of
  twice.
- The viewport drives drags through live frames and drag_end; dimension
  text drags start only off sketch geometry.

Refs #203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 13:45:48 -04:00
1 parent 75a5609b23
commit fe4d171e22
47 files changed
+10385 -523

No files matched your search

+48
View File
@@ -285,3 +285,51 @@ Pi STEP (27 bodies) into the case in 279 ms; undo restored the original two bodi
full 77 MB board STEP (2689 solid definitions, 45,689 faces) still had not completed after
300 s. Its window continued handling inspection and Cancel, and cancelling preserved the
case. Complex STEP construction remains an outstanding kernel performance problem.
# Sketch solver and drags (2026-10-11)
Unknowns the constraints pin down one after another from the constants (each one's own rows
of full, well-conditioned rank, reading nothing but constants and unknowns pinned before it)
are eliminated exactly: values the constructive seed placed and whose constraints hold there
stay constants while Levenberg–Marquardt solves the rest, and the rank analysis (degrees of
freedom, determined geometry) runs on the remainder only. A component tied to no constant takes
one point as a constant first (its translation). All the held solves of one live drag frame
share a 1M decomposition work budget (about 16 ms; a solve's own, 20M, is about 0.3 s), and a
drag's steps are a quarter of the smallest held size instead of an eighth, except once a seed
ran out of budget: the rest of that drag goes in eighths, as before, and continuation keeps
the branch the seed no longer places.
`cargo run --release -p solvecraft-sketch --example solve_bench`, macOS arm64, load average
45–75 (best of 5):
| Grid (origin corner fixed, rows horizontal, columns vertical, every edge a distance) | Start | Before | After |
|---|---|---:|---:|
| 10 × 10 | at solution | 0.36 ms | 0.21 ms |
| 10 × 10 | ±1 mm | 0.34 ms | 0.21 ms |
| 15 × 15 | at solution | 1.41 ms | 0.41 ms |
| 15 × 15 | ±1 mm | 1.39 ms | 0.40 ms |
| 20 × 20 | at solution | 4.06 ms | 0.72 ms |
| 20 × 20 | ±1 mm | 4.44 ms | 0.73 ms |
The seed constructs every grid point, so LM gets no variable (a test counts them). Before, the
time was the dense rank analysis of the whole grid. A 100-rectangle strip with nothing fixed,
solved whole from a moved corner, still costs 20–27 ms: nothing is constructed there and LM
iterates on every point with dense normal equations (a sparse solve would be the next step).
Drag frames, `sketch.drag` + `world_state()` per frame (60 frames of 0.5 mm, best of 5; the
"before" column is one run of the same bench on the previous code):
| Sketch | Points | Scripted ms/frame before → after | Live ms/frame before → after | `drag_end` ms before → after |
|---|---:|---:|---:|---:|
| 100 rectangles | 401 | 2.77 → 2.37 | 1.13 → 1.02 | 1.44 → 1.37 |
| 100 rectangles, rolled back under an extrude | 401 | 2.49 → 2.33 | 1.13 → 0.97 | 1.42 → 1.28 |
| 100-rectangle connected strip | 401 | 100.9 → 2.32 | 1.32 → 1.20 | 53.6 → 1.41 |
| 100-rectangle strip, rolled back under an extrude | 401 | 203.8 → 2.23 | 1.31 → 1.18 | 54.0 → 1.42 |
| rigid truss of 40 triangles, pinned at one end, dragged at the other | 42 | — → 6.8 | — → 6.3 | — → 0.59 |
The strip floats (nothing fixed), so its full solves (scripted frames, `drag_end`) ran the
dense rank analysis on 800 variables; with one corner taken as a constant all of it is
eliminated. A live frame's held solve was already cheap: the seed places the strip and LM
returns at once. Of a live strip frame, the seed reads the constraints in about 0.15 ms, so a
per-gesture cache of that reading is not worth its invalidation (its signs depend on the
reference positions, which change every step).