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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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

+2 -1
View File
@@ -193,7 +193,8 @@ pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
// A profile feature without picks takes the profiles of the active (else the last) sketch:
// point at them.
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
let ss =
app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()).map(|s| &**s));
if let Some(ss) = ss {
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
}
+54 -4
View File
@@ -2243,15 +2243,30 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
let id = egui::Id::new(("pexpr", &p.name));
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
let refused_id = egui::Id::new(("prefused", &p.name));
if r.changed() {
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
// Typing again clears the reason a previous change was refused.
ui.data_mut(|dd| {
dd.insert_temp(id, e.clone());
dd.remove::<String>(refused_id);
});
}
if r.lost_focus() && e != p.expr {
let _ = app.run("parameters.change", json!({"name": p.name, "expression": e}));
ui.data_mut(|dd| dd.remove::<String>(id));
match change_param(app, &p.name, &e) {
Ok(()) => ui.data_mut(|dd| {
dd.remove::<String>(id);
dd.remove::<String>(refused_id);
}),
// Refused (it would leave a sketch unsolved, say): the typed expression
// stays in the field with the reason beside it, nothing silently reverts.
Err(why) => ui.data_mut(|dd| {
dd.insert_temp(refused_id, why);
}),
}
}
let refused = ui.data(|dd| dd.get_temp::<String>(refused_id));
let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
match (v, errs.get(&p.name)) {
match (v, errs.get(&p.name).or(refused.as_ref())) {
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
_ => ui.label(""),
@@ -2273,6 +2288,14 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
ui.end_row();
}
/// Set a parameter's expression from the parameters table: `Err` with the message to show
/// beside the field when the change is refused (the document keeps its values).
pub(crate) fn change_param(app: &mut SolveApp, name: &str, expression: &str) -> Result<(), String> {
app.run("parameters.change", json!({"name": name, "expression": expression}))
.map(|_| ())
.map_err(|e| crate::friendly_error("parameters.change", &e))
}
/// Start a sketch on a picked plane or planar face and turn the view to face it.
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
let plane = match sel {
@@ -3751,3 +3774,30 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.error = None;
Some(d)
}
#[cfg(test)]
mod param_refusal_tests {
use super::*;
use solvecraft_engine::Session;
#[test]
fn a_refused_parameter_change_shows_why() {
// A triangle whose sides are parameters: a side longer than the other two together
// has no solution, so the change is refused and the table shows the reason.
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY", "name": "Tri"})).unwrap();
s.execute("sketch.line", &json!({"points": [[0, 0], [40, 0], [20, 30]], "closed": true})).unwrap();
for l in ["l1", "l2", "l3"] {
s.execute("sketch.dimension", &json!({"entities": [l]})).unwrap();
}
s.execute("sketch.finish", &json!({})).unwrap();
let name = s.doc.params.iter().find(|p| p.model).map(|p| p.name.clone()).unwrap();
let before = s.doc.param(&name).map(|p| p.expr.clone());
let mut app = SolveApp::new(s, Default::default());
let why = change_param(&mut app, &name, "500 mm").unwrap_err();
assert!(why.contains("Tri") && why.contains("conflict"), "{why}");
assert_eq!(app.session.doc.param(&name).map(|p| p.expr.clone()), before);
assert!(app.status.as_ref().is_some_and(|(m, _, error)| *error && m.contains("Tri")), "{:?}", app.status);
assert!(change_param(&mut app, &name, "41 mm").is_ok());
}
}
+3 -2
View File
@@ -258,9 +258,10 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
}
/// Start dragging the text of the dimension under `press` (true: a dimension took the drag).
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool {
/// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`).
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool {
let Some(d) = DRAWN.with(|d| {
let id = hit(press)?;
let id = hit_text(press).or_else(|| if on_geometry { None } else { hit(press) })?;
d.borrow().iter().find(|x| x.id == id).cloned()
}) else {
return false;
+7 -7
View File
@@ -7,7 +7,7 @@
use std::cell::{Cell, RefCell};
use egui::{Align2, FontId, Shape, Stroke};
use serde_json::json;
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec2;
use solvecraft_engine::sketch::{CurveKind, Sketch};
@@ -195,18 +195,18 @@ pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec<usize>, Vec<usize>) {
(own, ex)
}
/// The drag let go: the constraint of the snap it ended on (none for lining up).
pub fn release(app: &mut SolveApp) {
let Some(s) = current() else { return };
/// The drag let go: the constraint of the snap it ended on (none for lining up), as
/// `sketch.drag_end` takes it.
pub fn release() -> Option<Value> {
let s = current()?;
clear();
let (cmd, params) = match &s.target {
Target::Point(t) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})),
Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})),
Target::On(c) => ("sketch.constraint.coincident", json!({"a": s.point, "b": c})),
Target::Align => return,
Target::Align => return None,
};
// A snap that would over-constrain the sketch is simply not kept.
let _ = app.session.execute(cmd, &params);
Some(json!({"command": cmd, "params": params}))
}
/// The snap of the drag in progress: its dashed guides, a ring on the target and its label.
+2
View File
@@ -93,6 +93,8 @@ pub(crate) mod sketch_display;
#[cfg(test)]
mod sketch_display_ui_tests;
#[cfg(test)]
mod sketch_drag_ui_tests;
#[cfg(test)]
mod sketch_edit_tests;
pub mod sketch_palette;
pub(crate) mod sketch_text;
+107
View File
@@ -0,0 +1,107 @@
//! Dragging sketch geometry in the viewport: live frames re-solve the sketch only, letting go
//! brings the model up to date in the drag's one undo step, and the grab stays on the geometry.
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec2;
use crate::scenario::Harness;
fn run(h: &mut Harness, id: &str, p: Value) {
if let Err(e) = h.app.run(id, p.clone()) {
panic!("{id} {p}: {e}");
}
}
fn screen(h: &mut Harness, x: f64, y: f64) -> egui::Pos2 {
let [sx, sy] = h.at(&json!({"sketch": [x, y]})).expect("on screen");
egui::pos2(sx as f32, sy as f32)
}
fn point(h: &Harness, id: &str) -> Vec2 {
let sid = h.app.session.active_sketch.expect("sketch mode");
let st = h.app.session.world_state();
let sk = &st.sketch(sid).expect("the sketch").sketch;
sk.resolve_point(id).and_then(|i| sk.point(i)).unwrap_or_else(|| panic!("no point {id}"))
}
/// Press at `path[0]` and move through the rest, one frame per event.
fn press_and_move(h: &mut Harness, path: &[egui::Pos2]) {
let Some(&a) = path.first() else { return };
h.app.synthetic.push(egui::Event::PointerMoved(a));
h.app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.app.synthetic.extend(path.iter().skip(1).map(|&p| egui::Event::PointerMoved(p)));
play(h);
}
fn release(h: &mut Harness, at: egui::Pos2) {
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
play(h);
}
fn play(h: &mut Harness) {
while !h.app.synthetic.is_empty() {
h.frame();
}
h.frames(2);
}
/// Points from `a` to `b` in `n` equal moves (without `a`).
fn steps(a: egui::Pos2, b: egui::Pos2, n: usize) -> Vec<egui::Pos2> {
(1..=n).map(|k| a + (b - a) * (k as f32 / n as f32)).collect()
}
#[test]
fn a_viewport_drag_solves_the_sketch_live_and_rebuilds_the_model_on_release() {
let mut h = Harness::new();
run(&mut h, "sketch.create", json!({"plane": "XY"}));
run(&mut h, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
run(&mut h, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
run(&mut h, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
run(&mut h, "sketch.finish", json!({}));
run(&mut h, "solid.extrude", json!({"distance": 5}));
let sketch = h.app.session.doc.features.first().map(|f| f.id).expect("the sketch");
h.app.edit_sketch(sketch);
h.settle();
let undo = h.app.session.undo.len();
let a = screen(&mut h, 5.0, 5.0);
let b = screen(&mut h, -3.0, -6.0);
let mut path = vec![a];
path.extend(steps(a, b, 8));
press_and_move(&mut h, &path);
assert!(h.app.viewport.point_drag.is_some(), "the drag is under way");
assert!(h.app.session.model.is_stale(), "the frames updated the sketch only");
assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1, "{:?}", point(&h, "l1.start"));
release(&mut h, b);
assert!(h.app.viewport.point_drag.is_none());
assert!(!h.app.session.model.is_stale(), "letting go evaluated the model");
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step for the drag");
assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1);
run(&mut h, "edit.undo", json!({}));
assert!(point(&h, "l1.start").dist(Vec2::new(5.0, 5.0)) < 1e-9);
}
#[test]
fn a_dragged_line_that_stops_short_stays_under_the_cursor_when_it_can_follow_again() {
let mut h = Harness::new();
run(&mut h, "sketch.create", json!({"plane": "XY"}));
// Two 10 mm links from the origin hold the start of a horizontal 10 mm line: the line can
// slide right until its start is 20 mm from the origin (3 mm).
let (x, y) = (15.0, 75f64.sqrt());
run(&mut h, "sketch.line", json!({"points": [[0, 0], [10, 0], [x, y], [x + 10.0, y]]}));
for l in ["l1", "l2", "l3"] {
run(&mut h, "sketch.dimension", json!({"entities": [l], "value": 10}));
}
run(&mut h, "sketch.constraint.horizontal_vertical", json!({"line": "l3"}));
let grab = screen(&mut h, x + 5.0, y);
let far = screen(&mut h, x + 11.0, y);
let back = screen(&mut h, x + 7.0, y);
let mut path = vec![grab];
path.extend(steps(grab, far, 8));
path.extend(steps(far, back, 4));
press_and_move(&mut h, &path);
release(&mut h, back);
// The cursor went 6 mm right, the line only 3; back at 2 mm right it is under it again.
let start = point(&h, "l3.start");
assert!(start.dist(Vec2::new(x + 2.0, y)) < 0.1, "{start:?}");
assert!(!h.app.session.model.is_stale());
}
+37 -15
View File
@@ -1887,13 +1887,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let (o, d) = proj.ray(p);
ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w))
};
// The 3D triad, then a dimension's text, follow a drag that starts on them.
if resp.drag_started_by(egui::PointerButton::Primary)
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
&& !crate::sketch3d::drag_start(app, &proj, a)
&& let Some(q) = on_plane(app, a)
// What a drag starts on: the 3D triad, then a dimension's text, then sketch geometry
// (before the dimension's lines, which end on the geometry they measure), as for clicks.
let grabbed = resp
.drag_started_by(egui::PointerButton::Primary)
.then(|| ui.input(|i| i.pointer.press_origin()))
.flatten()
.filter(|a| !crate::sketch3d::drag_start(app, &proj, *a))
.map(|a| (a, pick(app, &proj, a).into_iter().next()));
if let Some((a, hit)) = &grabbed
&& let Some(q) = on_plane(app, *a)
{
crate::dim_view::drag_start(app, a, q);
let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. }));
crate::dim_view::drag_start(app, *a, q, on_geometry);
}
if crate::sketch3d::dragging()
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
@@ -1908,13 +1914,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::dim_view::drag_to(app, q);
}
// A point or a curve of the sketch being edited follows a drag that starts on it.
if resp.drag_started_by(egui::PointerButton::Primary)
if let Some((a, hit)) = grabbed
&& !crate::dim_view::dragging()
&& !crate::sketch3d::dragging()
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
{
let active = app.session.active_sketch;
match pick(app, &proj, a).into_iter().next() {
match hit {
Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => {
app.viewport.point_drag = Some((id, app.session.undo.len(), None))
}
@@ -1950,17 +1955,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if let Some(s) = snap {
q += s.offset;
}
if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() {
app.session.undo.truncate(depth + 1);
}
// Each frame re-solves the sketch only (`live`); the model follows when the drag ends.
let at = match app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y], "live": true})) {
Ok(r) => {
app.session.undo.truncate(depth + 1);
r.get("at").and_then(solvecraft_engine::params::vec2).unwrap_or(q)
}
Err(_) => from,
};
// The grab stays on the geometry: what it could not follow this frame it catches up
// with on the next one instead of lagging the cursor for the rest of the drag.
if grab.is_some() {
app.viewport.point_drag = Some((id, depth, Some(q)));
app.viewport.point_drag = Some((id, depth, Some(at)));
}
}
if resp.drag_stopped() {
if let Some((_, depth, _)) = app.viewport.point_drag.clone() {
// Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step.
crate::drag_snap::release(app);
// Letting go on a point, midpoint or curve adds the matching constraint, then the
// whole model follows the drag, all in the drag's undo step.
let _ = app.session.execute("sketch.drag_end", &json!({ "constraint": crate::drag_snap::release() }));
app.session.undo.truncate(depth + 1);
}
crate::drag_snap::clear();
@@ -1969,6 +1982,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::sketch3d::drag_end();
}
}
// A sketch drag whose release never came (focus lost, a tool or dialog took over) ends here,
// so the model does not stay behind its sketch until the next command. Not while CAD input
// is held back (an issue report's capture): a release then belongs to the report, and the
// drag continues afterwards.
if app.viewport.point_drag.is_some() && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) {
app.viewport.point_drag = None;
crate::drag_snap::clear();
let _ = app.session.execute("sketch.drag_end", &json!({}));
}
// Box selection: a primary drag on the model when no navigation mode is on.
if app.tool.is_none()
&& primary_nav.is_none()