Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 9b6dde7d6e constraints: don't refuse solving a component that already holds (regression from #2677)
#2677 added an evaluation-work budget to Comp::run that refuses before
the solver starts. lm::solve returns after a single evaluation when the
residuals already hold, so before #2677 a satisfied component cost one
evaluation however large its splines were. Now `solve` returns TooLarge
for the whole drawing as soon as any component has a Tangent/Smooth on a
fit spline of roughly 150+ points, even when that component is already
satisfied and nothing about it needs to move. after_command re-solves
every component after an edit, so editing unrelated constrained geometry
in such a drawing fails with "constraint system too large to solve".

The estimate also charged a spline none of whose points is free (Fixed
or held) with a rebuild on every evaluation; with #2677's rebuild cache
its points never change, so it is rebuilt once. Two Fixed 450-point fit
splines with a line tangent to both solved before and were refused.

- Comp::run applies the budget only when the system does not already
  hold at its start point (one evaluation, reusing the rebuilds made
  while interpreting).
- eval_work counts one rebuild for a spline with no free points.
- add's general redundancy check builds a Jacobian even for a system
  that holds, so it now applies the same budget itself; add behaves as
  it did on main.
2026-11-02 22:31:07 -05:00
3 changed files with 82 additions and 6 deletions
+3 -2
View File
@@ -234,8 +234,9 @@ mod budget_tests {
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
// Solving it is refused for the same reason, before any Jacobian is built.
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
// Its residuals already hold (the other object is missing), so solving does not iterate
// and is not refused; an unsatisfied system of this size is (see below).
assert!(comp.run(None).is_ok_and(|o| o.converged));
}
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
+20 -4
View File
@@ -274,7 +274,8 @@ impl Comp {
}
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
/// are shared by residuals at the same points, see [`Model`]).
/// are shared by residuals at the same points, see [`Model`]). A spline none of whose points
/// is free never moves, so its one rebuild is reused by every evaluation.
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
let rows = self.rows(skip) as f64;
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
@@ -291,11 +292,18 @@ impl Comp {
.filter_map(|h| self.model.slot(*h))
.map(|s| {
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
let rebuilds = if own == 0.0 { 1.0 } else { 2.0 * own + TRIAL_EVALS + 1.0 };
rebuilds * self.model.spline_reconstruct_cost(s.handle)
})
.sum();
evals * rows + rebuilds
}
/// Do the residuals hold at `x`, as [`lm::solve`] decides before its first iteration?
fn holds(&self, x: &[f64], skip: Option<usize>) -> bool {
let mut out = Vec::new();
self.eval(x, &mut out, skip);
out.is_empty() || lm::max_abs(&out) <= self.tol
}
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
let free = self.free(skip);
let rows = self.rows(skip);
@@ -303,8 +311,11 @@ impl Comp {
return Err(SolveError::TooLarge);
}
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
// its own fit points: refuse before iterating when that alone is too much (CWE-407). A
// system that already holds is not iterated (`lm::solve` stops after one evaluation), so
// it is not refused: a large component left as it is must not stop the rest of the
// drawing from being solved.
if self.eval_work(&free, skip) > MAX_EVAL_WORK && !self.holds(&self.model.x0, skip) {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
@@ -790,6 +801,11 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
&& nb.rows > 0
{
let free = comp.free(None);
// `run` lets a system that already holds through without its work budget; this Jacobian
// is built regardless, so it keeps that budget (CWE-407).
if comp.eval_work(&free, None) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let mut ordered: Vec<&Built> = comp.built.iter().filter(|b| b.id != id).collect();
ordered.push(nb);
let f = |x: &[f64], out: &mut Vec<f64>| {
+59
View File
@@ -516,3 +516,62 @@ fn next_name_skips_names_in_use() {
});
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
}
/// A straight open fit spline through `n` points from `from` in steps of `step`.
fn straight_fit_spline(d: &mut Drawing, n: usize, from: (f64, f64), step: (f64, f64)) -> Handle {
let pts: Vec<Vec2> = (0..n).map(|i| Vec2::new(from.0 + step.0 * i as f64, from.1 + step.1 * i as f64)).collect();
add_e(d, EntityKind::Spline(cadcraft_geom::Spline::fit_with(&pts, false, Default::default())))
}
fn push_con(d: &mut Drawing, kind: ConstraintKind, refs: &[GeomRef]) {
let id = d.constraints.iter().map(|c| c.id).max().unwrap_or(0) + 1;
d.constraints.push(Constraint { id, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() });
}
/// A component that already holds is not iterated, so its size does not keep the rest of the
/// drawing from being solved: a Tangent on a 200-point fit spline that is already satisfied
/// must not make re-solving an unrelated Horizontal fail as "too large".
#[test]
fn satisfied_large_spline_component_does_not_block_solve() {
let mut d = Drawing::default();
let sp = straight_fit_spline(&mut d, 200, (0.0, 0.0), (1.0, 0.0));
let touching = line(&mut d, (199.0, 0.0), (220.0, 0.0));
push_con(&mut d, ConstraintKind::Tangent, &[w(sp), w(touching)]);
let other = line(&mut d, (0.0, 50.0), (10.0, 51.0));
push_con(&mut d, ConstraintKind::Horizontal, &[w(other)]);
let r = solve(&mut d, &SolveOptions::default());
assert!(r.is_ok(), "{r:?}");
let (a, b) = ln(&d, other);
assert!((a.y - b.y).abs() < 1e-6);
assert_eq!(ln(&d, touching), (Vec2::new(199.0, 0.0), Vec2::new(220.0, 0.0)));
}
/// Fixed splines are not rebuilt while solving (their points never move, so the one rebuild is
/// reused): two large fixed fit splines with a free line tangent to both still solve.
#[test]
fn fixed_large_splines_do_not_count_as_rebuilt_every_evaluation() {
let mut d = Drawing::default();
let a = straight_fit_spline(&mut d, 450, (0.0, 0.0), (1.0, 0.0));
let b = straight_fit_spline(&mut d, 450, (1449.0, 0.0), (-1.0, 0.0));
let l = line(&mut d, (449.0, 0.0), (1000.0, 0.5));
push_con(&mut d, ConstraintKind::Fix, &[w(a)]);
push_con(&mut d, ConstraintKind::Fix, &[w(b)]);
push_con(&mut d, ConstraintKind::Tangent, &[w(a), w(l)]);
push_con(&mut d, ConstraintKind::Tangent, &[w(b), w(l)]);
let r = solve(&mut d, &SolveOptions::default());
assert!(r.is_ok(), "{r:?}");
let (p, q) = ln(&d, l);
assert!(p.y.abs() < 1e-6 && q.y.abs() < 1e-6, "{p:?} {q:?}");
}
/// Adding a constraint still checks redundancy within the work budget when the system already
/// holds: an already tangent line on a free 400-point fit spline is refused, not differentiated.
#[test]
fn add_on_satisfied_large_spline_keeps_redundancy_budget() {
let mut d = Drawing::default();
let sp = straight_fit_spline(&mut d, 400, (0.0, 0.0), (1.0, 0.0));
let l = line(&mut d, (399.0, 0.0), (420.0, 0.0));
let r = add(&mut d, con(ConstraintKind::Tangent, &[w(sp), w(l)]), &SolveOptions::default());
assert!(matches!(r, Err(SolveError::TooLarge)), "{r:?}");
assert!(d.constraints.is_empty());
}