From 932efbf34159b88b692e18a88a57a5b155c8ceb4 Mon Sep 17 00:00:00 2001 From: JIJINBEI <87472238+jijinbei@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:23:24 +0900 Subject: [PATCH] Sketch drag: move only what the constraints need (#2060) Thanks @jijinbei. --- crates/sketch/src/least_motion_tests.rs | 61 +++++++++++++++++++++++++ crates/sketch/src/lib.rs | 2 + crates/sketch/src/redundancy.rs | 2 +- crates/sketch/src/solver.rs | 44 +++++++++++++----- 4 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 crates/sketch/src/least_motion_tests.rs diff --git a/crates/sketch/src/least_motion_tests.rs b/crates/sketch/src/least_motion_tests.rs new file mode 100644 index 00000000..f009300a --- /dev/null +++ b/crates/sketch/src/least_motion_tests.rs @@ -0,0 +1,61 @@ +//! A solve with geometry held moves the rest as little as it can. + +use solidcraft_geom::Vec2; + +use crate::{Analysis, ConstraintKind, Hold, Sketch, solve_with}; + +fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +/// A rectangle with a divider whose ends are held on the bottom and top sides (point on curve). +/// Dragging the top-left corner tilts the top side: the divider's top end slides along with it, +/// the far corner stays. +#[test] +fn a_point_held_on_a_line_slides_instead_of_swinging_the_line() { + let mut sk = Sketch::new(); + let c: Vec = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + let sides: Vec = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect(); + let bottom = sk.add_point(v(20.0, 0.0), None).unwrap(); + let top = sk.add_point(v(20.0, 30.0), None).unwrap(); + sk.add_line_pts(bottom, top, None).unwrap(); + sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap(); + sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap(); + let before: Vec = sk.points.iter().map(|p| p.pos).collect(); + sk.points[c[3]].pos = v(1.0, 31.0); + let rep = solve_with(&mut sk, &Hold { points: vec![c[3]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None); + assert!(rep.ok(), "{rep:?}"); + let moved = |i: usize| sk.points[i].pos.dist(before[i]); + // The top side through (1, 31) and (40, 30) passes x = 20 at y ≈ 30.51: the divider's end + // needs about half a millimetre. The far corner needs nothing. + assert!(moved(top) < 0.6, "divider end moved {}", moved(top)); + assert!(moved(c[2]) < 0.3, "far corner moved {}", moved(c[2])); + assert!(moved(c[1]) < 1e-6 && moved(bottom) < 1e-6, "the bottom has no reason to move"); +} + +/// The same rectangle, dragging the top-right corner down: the top side turns about its other +/// corner, which stays, and the divider's end slides down with it. +#[test] +fn the_far_corner_stays_when_the_side_a_point_is_held_on_turns() { + let mut sk = Sketch::new(); + let c: Vec = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + let sides: Vec = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect(); + let bottom = sk.add_point(v(20.0, 0.0), None).unwrap(); + let top = sk.add_point(v(20.0, 30.0), None).unwrap(); + sk.add_line_pts(bottom, top, None).unwrap(); + sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap(); + sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap(); + // Down in steps, as a live drag goes: each solve starts from the last. + for k in 1..=10 { + let before: Vec = sk.points.iter().map(|p| p.pos).collect(); + sk.points[c[2]].pos = v(40.0, 30.0 - k as f64); + let rep = solve_with(&mut sk, &Hold { points: vec![c[2]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None); + assert!(rep.ok(), "{rep:?}"); + } + let corner = sk.points[c[3]].pos; + assert!(corner.dist(v(0.0, 30.0)) < 0.1, "the far corner went to {corner:?}"); + // On the turned side, near where it was. + let (end, a, b) = (sk.points[top].pos, corner, sk.points[c[2]].pos); + let off_side = ((b - a).cross(end - a) / a.dist(b)).abs(); + assert!(off_side < 1e-6 && (end.x - 20.0).abs() < 1.0, "the divider's end is at {end:?}"); +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 44ed6ec2..aa941345 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -80,6 +80,8 @@ mod ends_tests; #[cfg(test)] mod font_tests; #[cfg(test)] +mod least_motion_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod thin_tests; diff --git a/crates/sketch/src/redundancy.rs b/crates/sketch/src/redundancy.rs index efe6196e..9af06771 100644 --- a/crates/sketch/src/redundancy.rs +++ b/crates/sketch/src/redundancy.rs @@ -123,7 +123,7 @@ pub(super) fn dependent_blocks( /// dimension (it constrains nothing) or an unknown index. pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option { let c = sk.constraints.get(ci).filter(|c| !c.driven)?; - let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() }; let on_curve = points_on_curves(sk); let b = match super::touch_point(sk, &c.kind, &on_curve) { Some(p) => Block::TangentAt(ci, &c.kind, p), diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 360b4c33..bd897495 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -36,6 +36,9 @@ const MAX_COMPONENT_VARS: usize = 4_000; const TOL: f64 = 1e-9; /// A solution is accepted when every residual is below this (mm). const ACCEPT: f64 = 1e-6; +/// How much less it costs a solve to move a point held on a curve than any other point: such a +/// point is placed relative to the curve, so it follows the curve rather than the curve it. +const SLIDE_WEIGHT: f64 = 0.01; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -107,6 +110,9 @@ struct Layout { n: usize, /// The point coordinates' variables: `0..points`. points: usize, + /// Per variable: a coordinate of a point held on a curve (point on curve, midpoint), which + /// a solve slides along it before moving the curve (see [`SLIDE_WEIGHT`]). + slide: Vec, } impl Layout { @@ -145,7 +151,7 @@ pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec { fn layout(sk: &Sketch, hold: &Hold) -> Layout { let mut n = 0; - let pvar = held_points(sk, hold) + let pvar: Vec> = held_points(sk, hold) .iter() .map(|f| { if *f { @@ -169,7 +175,19 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout { _ => None, }) .collect(); - Layout { pvar, rvar, n, points } + let mut slide = vec![false; n]; + for c in &sk.constraints { + if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind + && let Some(Some(v)) = pvar.get(p) + { + for k in [*v, *v + 1] { + if let Some(s) = slide.get_mut(k) { + *s = true; + } + } + } + } + Layout { pvar, rvar, n, points, slide } } /// Read-only view of the sketch with variable values substituted. @@ -618,7 +636,7 @@ fn measure(s: &State, k: &ConstraintKind) -> Option { /// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a /// constraint that is not a dimension. pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option { - let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() }; measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite()) } @@ -954,13 +972,17 @@ fn lm_capped( } } let mut improved = false; - // Damping floor: variables the residuals barely see must not take huge steps. - let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4; + // Damping by distance moved, not by JᵀJ's diagonal (Marquardt): of the steps that fit the + // residuals equally well it takes the shortest, so what the constraints leave free moves + // as little as it can. (Scaled by the diagonal, a variable the residuals see weakly is + // cheap to move: a point held on a line made the line's far end swing round instead of + // the point sliding along it.) A point held on a curve is cheaper still, so it slides + // rather than the curve's ends moving. + let weight: Vec = vars.iter().map(|&v| if lay.slide.get(v).copied().unwrap_or(false) { SLIDE_WEIGHT } else { 1.0 }).collect(); for _ in 0..30 { let mut m = a.clone(); - for i in 0..n { - let d = a.get(i, i); - m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12); + for (i, w) in weight.iter().enumerate() { + m.add(i, i, lambda * w + 1e-12); } let rhs: Vec = g.iter().map(|v| -v).collect(); let Some(step) = lin_solve(m, rhs) else { @@ -2201,7 +2223,7 @@ fn solve_inner( None => true, }; // Driven dimensions measure the solved geometry. - let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() }; let measured: Vec> = sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect(); for (c, m) in sk.constraints.iter_mut().zip(measured) { @@ -2265,7 +2287,7 @@ mod continuation_unit_tests { let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap(); let c = sk.add_point(Vec2::new(20.0, 1e-6), None).unwrap(); - let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() }; let turn = Orient::Turn([a, b, c]); let state = |sk: &Sketch| (orientation(&State { sk, lay: &lay, x: &[] }, turn), reference_orientation(&State { sk, lay: &lay, x: &[] }, turn)); @@ -2285,7 +2307,7 @@ mod continuation_unit_tests { let q = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap(); let r = sk.add_point(Vec2::new(0.0, 10.0), None).unwrap(); let l = sk.add_line(Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), None, None, None).unwrap(); - let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() }; let sign = |sk: &Sketch, o: Orient| orientation(&State { sk, lay: &lay, x: &[] }, o); let checks = [Orient::SideX(p, q, 10.0), Orient::SideY(p, q, 5.0), Orient::OfLine(q, l, 10.0), Orient::Turn([p, q, r])]; let before: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect();