Sketch drag: move only what the constraints need (#2060)

Thanks @jijinbei.
This commit is contained in:
JIJINBEI authored and GitHub committed 2026-10-18 02:23:24 -04:00
1 parent b8d7b42808
commit 932efbf341
4 files changed
+97 -12

No files matched your search

+61
View File
@@ -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<usize> = [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<usize> = (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<Vec2> = 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<usize> = [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<usize> = (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<Vec2> = 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:?}");
}
+2
View File
@@ -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;
+1 -1
View File
@@ -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<f64> {
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),
+33 -11
View File
@@ -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<bool>,
}
impl Layout {
@@ -145,7 +151,7 @@ pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec<bool> {
fn layout(sk: &Sketch, hold: &Hold) -> Layout {
let mut n = 0;
let pvar = held_points(sk, hold)
let pvar: Vec<Option<usize>> = 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<f64> {
/// 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<f64> {
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<f64> = 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<f64> = 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<Option<f64>> =
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();