Skip to main content

geop_core_sketch/
solve.rs

1//! Solving a sketch: every constraint contributes residuals that are zero
2//! exactly when it holds, and [`crate::bfgs::minimize`] drives the sum of
3//! their squares to zero.
4//!
5//! **Variables.** Each class of coincident points (see
6//! [`Sketch::point_classes`]) is one `(x, y)` pair — coincidence is not a
7//! residual at all, it removes two degrees of freedom by construction. Each
8//! arc adds its half sweep, each circle its radius.
9//!
10//! **Units.** Every residual is a length: dimensionless ones (angles,
11//! parallelism) are multiplied by the sketch's characteristic size, so no
12//! constraint kind dominates the objective just by its choice of units, and
13//! the convergence test is a single relative length.
14//!
15//! **Floating point variables, interval-scalar residuals.** A sketch is
16//! design intent, not a geometric claim: the solved positions are the
17//! designer's free choice, entering the kernel as exact inputs through
18//! [`crate::profile`]. So [`bfgs::minimize`] itself still walks plain `f64`
19//! variables and its tolerances only decide when to stop iterating — but
20//! each residual along the way is computed as a [`Dual<ScalInF64>`], so the
21//! `sin`/`sqrt`/`PI` a geometric formula can't help but need are honestly
22//! enclosed rather than quietly rounded away. A residual that becomes
23//! genuinely undecidable (a division or square root at the edge of its
24//! domain) is treated as "this point is infeasible" — the same outcome a
25//! plain `f64` computing `inf`/`nan` there would already have produced.
26use crate::{
27    bfgs::{BfgsOptions, minimize},
28    dual::{Dual, MAX_LOCAL_VARS},
29    geometry::{Arc, V, line_distance},
30    sketch::{Constraint, ConstraintId, CurveId, CurveKind, PointId, Sketch},
31};
32use geop_core_math::{
33    geop_error::{GeopError, GeopResult},
34    scalars::{Scalar, scal_in_f64::ScalInF64},
35};
36
37/// The scalar every residual is computed in: an honest enclosure, not a bare
38/// `f64` — see the module docs.
39type S = ScalInF64;
40
41use serde::Serialize;
42use std::collections::BTreeMap;
43
44/// Maps sketch entities to solver variables.
45struct Layout {
46    /// Index of the `x` variable of each point (its `y` follows).
47    point_var: BTreeMap<PointId, usize>,
48    /// The arc's half sweep or the circle's radius, for every arc and circle.
49    curve_var: BTreeMap<CurveId, usize>,
50    n: usize,
51}
52
53impl Layout {
54    fn new(sketch: &Sketch) -> Self {
55        let class = sketch.point_classes();
56        let mut n = 0;
57        let mut class_var = BTreeMap::new();
58        for rep in class.values() {
59            class_var.entry(*rep).or_insert_with(|| {
60                n += 2;
61                n - 2
62            });
63        }
64        let point_var = class.iter().map(|(&p, rep)| (p, class_var[rep])).collect();
65        let curve_var = sketch
66            .curves
67            .iter()
68            .filter(|(_, c)| matches!(c.kind, CurveKind::Arc { .. } | CurveKind::Circle { .. }))
69            .map(|(&id, _)| {
70                n += 1;
71                (id, n - 1)
72            })
73            .collect();
74        Layout {
75            point_var,
76            curve_var,
77            n,
78        }
79    }
80
81    /// The current variable values, read from the sketch.
82    fn read(&self, sketch: &Sketch) -> Vec<f64> {
83        let mut x = vec![0.0; self.n];
84        // Iterate in reverse so a class takes its representative's (lowest
85        // id) position.
86        for (id, p) in sketch.points.iter().rev() {
87            x[self.point_var[id]] = p.x;
88            x[self.point_var[id] + 1] = p.y;
89        }
90        for (id, c) in &sketch.curves {
91            match c.kind {
92                CurveKind::Arc { sweep, .. } => x[self.curve_var[id]] = sweep / 2.0,
93                CurveKind::Circle { radius, .. } => x[self.curve_var[id]] = radius,
94                _ => {}
95            }
96        }
97        x
98    }
99
100    /// Write variable values back into the sketch.
101    fn write(&self, sketch: &mut Sketch, x: &[f64]) {
102        for (id, p) in sketch.points.iter_mut() {
103            p.x = x[self.point_var[id]];
104            p.y = x[self.point_var[id] + 1];
105        }
106        for (id, c) in sketch.curves.iter_mut() {
107            match &mut c.kind {
108                CurveKind::Arc { sweep, .. } => *sweep = 2.0 * x[self.curve_var[id]],
109                CurveKind::Circle { radius, .. } => *radius = x[self.curve_var[id]].abs(),
110                _ => {}
111            }
112        }
113    }
114
115    /// The variables a curve depends on.
116    fn curve_vars(&self, sketch: &Sketch, c: CurveId, out: &mut Vec<usize>) {
117        for p in sketch.curves[&c].points() {
118            out.extend([self.point_var[&p], self.point_var[&p] + 1]);
119        }
120        out.extend(self.curve_var.get(&c));
121    }
122}
123
124/// Read-only view of the variables for evaluating one constraint, with that
125/// constraint's own variables `seeded` as [`Scalar`] values.
126struct Geo<'a, T> {
127    sketch: &'a Sketch,
128    layout: &'a Layout,
129    x: &'a [f64],
130    seeded: &'a [(usize, T)],
131}
132
133impl<T: Scalar> Geo<'_, T> {
134    fn var(&self, i: usize) -> T {
135        self.seeded
136            .iter()
137            .find(|(j, _)| *j == i)
138            .map(|(_, t)| *t)
139            .unwrap_or_else(|| T::from_f64(self.x[i]))
140    }
141    fn point(&self, p: PointId) -> V<T> {
142        let i = self.layout.point_var[&p];
143        V::new(self.var(i), self.var(i + 1))
144    }
145    fn line(&self, c: CurveId) -> (V<T>, V<T>) {
146        match &self.sketch.curves[&c].kind {
147            CurveKind::Line { start, end } => (self.point(*start), self.point(*end)),
148            k => unreachable!("validated to be a line: {k:?}"),
149        }
150    }
151    fn arc(&self, c: CurveId) -> Option<Arc<T>> {
152        match &self.sketch.curves[&c].kind {
153            CurveKind::Arc { start, end, .. } => Some(Arc {
154                s: self.point(*start),
155                e: self.point(*end),
156                half: self.var(self.layout.curve_var[&c]),
157            }),
158            _ => None,
159        }
160    }
161    /// `(center, radius)` of a circle or arc.
162    fn round(&self, c: CurveId) -> GeopResult<(V<T>, T)> {
163        Ok(match &self.sketch.curves[&c].kind {
164            CurveKind::Circle { center, .. } => (
165                self.point(*center),
166                self.var(self.layout.curve_var[&c]).abs(),
167            ),
168            CurveKind::Arc { .. } => {
169                let a = self.arc(c).unwrap();
170                (a.center()?, a.radius()?)
171            }
172            k => unreachable!("validated to be a circle or arc: {k:?}"),
173        })
174    }
175    /// Unit tangent of an open curve at its start (`at_end = false`) or end,
176    /// in the direction of travel.
177    fn end_tangent(&self, c: CurveId, at_end: bool) -> GeopResult<V<T>> {
178        Ok(match &self.sketch.curves[&c].kind {
179            CurveKind::Line { .. } => {
180                let (s, e) = self.line(c);
181                e.sub(s).unit()?
182            }
183            CurveKind::Arc { .. } => {
184                let a = self.arc(c).unwrap();
185                if at_end {
186                    a.tangent_end()?
187                } else {
188                    a.tangent_start()?
189                }
190            }
191            CurveKind::Spline { control_points } => {
192                let n = control_points.len();
193                let (p, q) = if at_end {
194                    (control_points[n - 2], control_points[n - 1])
195                } else {
196                    (control_points[0], control_points[1])
197                };
198                self.point(q).sub(self.point(p)).unit()?
199            }
200            k => unreachable!("validated to be an open curve: {k:?}"),
201        })
202    }
203}
204
205/// How a [`Constraint::Tangent`] is enforced, decided once per solve from
206/// the operands' connectivity and their starting configuration.
207#[derive(Clone, Copy, Debug)]
208enum TangentMode {
209    /// The curves share an endpoint: their tangents there are parallel.
210    Endpoint { a_end: bool, b_end: bool },
211    /// Line `line` touches circle/arc `round`.
212    LineRound { line: CurveId, round: CurveId },
213    /// Two circles/arcs touch, from inside or outside — whichever is closer
214    /// to holding when the solve starts, so a solve never flips the
215    /// configuration the designer drew.
216    RoundRound { internal: bool },
217}
218
219/// One constraint, ready to evaluate.
220struct Prepared<'a> {
221    constraint: &'a Constraint,
222    vars: Vec<usize>,
223    tangent: Option<TangentMode>,
224}
225
226/// Everything needed to evaluate the objective, fixed for one solve.
227struct Problem<'a> {
228    sketch: &'a Sketch,
229    layout: Layout,
230    constraints: Vec<Prepared<'a>>,
231    /// Characteristic length of the sketch; see the module docs.
232    scale: f64,
233    /// Extra residuals `weight * (point - target)` pulling points towards a
234    /// cursor while dragging.
235    drags: Vec<(PointId, [f64; 2], f64)>,
236}
237
238impl<'a> Problem<'a> {
239    fn new(sketch: &'a Sketch) -> GeopResult<Self> {
240        sketch.validate()?;
241        let layout = Layout::new(sketch);
242        let x0 = layout.read(sketch);
243
244        let mut lo = [f64::INFINITY; 2];
245        let mut hi = [f64::NEG_INFINITY; 2];
246        for p in sketch.points.values() {
247            lo = [lo[0].min(p.x), lo[1].min(p.y)];
248            hi = [hi[0].max(p.x), hi[1].max(p.y)];
249        }
250        let diagonal = (hi[0] - lo[0]).hypot(hi[1] - lo[1]);
251        let radii = sketch.curves.values().filter_map(|c| match c.kind {
252            CurveKind::Circle { radius, .. } => Some(2.0 * radius.abs()),
253            _ => None,
254        });
255        let scale = radii.fold(diagonal, f64::max);
256        let scale = if scale.is_finite() && scale > 0.0 {
257            scale
258        } else {
259            1.0
260        };
261
262        let mut constraints = Vec::new();
263        for c in sketch.constraints.values() {
264            use Constraint::*;
265            let mut vars = Vec::new();
266            let pt = |p: &PointId, vars: &mut Vec<usize>| {
267                vars.extend([layout.point_var[p], layout.point_var[p] + 1])
268            };
269            match c {
270                // Coincidence is built into the layout.
271                Coincident { .. } => continue,
272                Fix { point, .. } => pt(point, &mut vars),
273                Distance { a, b, .. } | DistanceX { a, b, .. } | DistanceY { a, b, .. } => {
274                    pt(a, &mut vars);
275                    pt(b, &mut vars);
276                }
277                PointOnCurve { point, curve }
278                | Midpoint { point, curve }
279                | PointLineDistance {
280                    point, line: curve, ..
281                } => {
282                    pt(point, &mut vars);
283                    layout.curve_vars(sketch, *curve, &mut vars);
284                }
285                Symmetric { a, b, line } => {
286                    pt(a, &mut vars);
287                    pt(b, &mut vars);
288                    layout.curve_vars(sketch, *line, &mut vars);
289                }
290                Horizontal { line: curve }
291                | Vertical { line: curve }
292                | Length { curve, .. }
293                | Radius { curve, .. } => layout.curve_vars(sketch, *curve, &mut vars),
294                Parallel { a, b }
295                | Perpendicular { a, b }
296                | Collinear { a, b }
297                | Tangent { a, b }
298                | Equal { a, b }
299                | Concentric { a, b }
300                | Angle { a, b, .. } => {
301                    layout.curve_vars(sketch, *a, &mut vars);
302                    layout.curve_vars(sketch, *b, &mut vars);
303                }
304            }
305            vars.sort_unstable();
306            vars.dedup();
307            if vars.len() > MAX_LOCAL_VARS {
308                return Err(GeopError::new(format!(
309                    "constraint {c:?} depends on {} variables, more than the supported {MAX_LOCAL_VARS}",
310                    vars.len()
311                )));
312            }
313            let tangent = match c {
314                Tangent { a, b } => Some(Self::tangent_mode(sketch, &layout, &x0, *a, *b)?),
315                _ => None,
316            };
317            constraints.push(Prepared {
318                constraint: c,
319                vars,
320                tangent,
321            });
322        }
323
324        Ok(Problem {
325            sketch,
326            layout,
327            constraints,
328            scale,
329            drags: Vec::new(),
330        })
331    }
332
333    fn tangent_mode(
334        sketch: &Sketch,
335        layout: &Layout,
336        x: &[f64],
337        a: CurveId,
338        b: CurveId,
339    ) -> GeopResult<TangentMode> {
340        if let Some((a_end, b_end)) = sketch.shared_endpoint(a, b)? {
341            return Ok(TangentMode::Endpoint { a_end, b_end });
342        }
343        let is_line = |c: CurveId| matches!(sketch.curves[&c].kind, CurveKind::Line { .. });
344        if is_line(a) {
345            return Ok(TangentMode::LineRound { line: a, round: b });
346        }
347        if is_line(b) {
348            return Ok(TangentMode::LineRound { line: b, round: a });
349        }
350        let geo = Geo::<S> {
351            sketch,
352            layout,
353            x,
354            seeded: &[],
355        };
356        let ((ca, ra), (cb, rb)) = (geo.round(a)?, geo.round(b)?);
357        let d = ca.sub(cb).norm()?.to_f64();
358        let (ra, rb) = (ra.to_f64(), rb.to_f64());
359        Ok(TangentMode::RoundRound {
360            internal: (d - (ra - rb).abs()).abs() < (d - (ra + rb)).abs(),
361        })
362    }
363
364    fn residuals<T: Scalar>(&self, p: &Prepared, geo: &Geo<T>, out: &mut Vec<T>) -> GeopResult<()> {
365        use Constraint::*;
366        let scale = T::from_f64(self.scale);
367        match *p.constraint {
368            Coincident { .. } => {}
369            Fix { point, x, y } => {
370                let q = geo.point(point);
371                out.extend([q.x.sub(T::from_f64(x)), q.y.sub(T::from_f64(y))]);
372            }
373            Distance { a, b, value } => out.push(
374                geo.point(b)
375                    .sub(geo.point(a))
376                    .norm()?
377                    .sub(T::from_f64(value)),
378            ),
379            DistanceX { a, b, value } => {
380                out.push(geo.point(b).x.sub(geo.point(a).x).sub(T::from_f64(value)))
381            }
382            DistanceY { a, b, value } => {
383                out.push(geo.point(b).y.sub(geo.point(a).y).sub(T::from_f64(value)))
384            }
385            PointOnCurve { point, curve } => {
386                let q = geo.point(point);
387                out.push(match &self.sketch.curves[&curve].kind {
388                    CurveKind::Line { .. } => {
389                        let (s, e) = geo.line(curve);
390                        line_distance(s, e, q)?
391                    }
392                    CurveKind::Arc { .. } => geo.arc(curve).unwrap().circle_residual(q)?,
393                    _ => {
394                        let (c, r) = geo.round(curve)?;
395                        q.sub(c).norm()?.sub(r)
396                    }
397                });
398            }
399            Midpoint { point, curve } => {
400                let q = geo.point(point);
401                let m = match geo.arc(curve) {
402                    Some(a) => a.arc_mid()?,
403                    None => {
404                        let (s, e) = geo.line(curve);
405                        s.add(e).scale(T::from_f64(0.5))
406                    }
407                };
408                out.extend([q.x.sub(m.x), q.y.sub(m.y)]);
409            }
410            Symmetric { a, b, line } => {
411                let (pa, pb) = (geo.point(a), geo.point(b));
412                let (s, e) = geo.line(line);
413                let mid = pa.add(pb).scale(T::from_f64(0.5));
414                out.push(line_distance(s, e, mid)?);
415                out.push(pb.sub(pa).dot(e.sub(s).unit()?));
416            }
417            PointLineDistance { point, line, value } => {
418                let (s, e) = geo.line(line);
419                out.push(
420                    line_distance(s, e, geo.point(point))?
421                        .abs()
422                        .sub(T::from_f64(value)),
423                );
424            }
425            Horizontal { line } => {
426                let (s, e) = geo.line(line);
427                out.push(e.y.sub(s.y));
428            }
429            Vertical { line } => {
430                let (s, e) = geo.line(line);
431                out.push(e.x.sub(s.x));
432            }
433            Parallel { a, b } | Perpendicular { a, b } | Angle { a, b, .. } => {
434                let (sa, ea) = geo.line(a);
435                let (sb, eb) = geo.line(b);
436                let (ua, ub) = (ea.sub(sa).unit()?, eb.sub(sb).unit()?);
437                let (cross, dot) = (ua.cross(ub), ua.dot(ub));
438                // sin(angle(a, b) - target), in units of length.
439                let r = match *p.constraint {
440                    Parallel { .. } => cross,
441                    Perpendicular { .. } => dot,
442                    Angle { value, .. } => cross
443                        .mul(T::from_f64(value.cos()))
444                        .sub(dot.mul(T::from_f64(value.sin()))),
445                    _ => unreachable!(),
446                };
447                out.push(r.mul(scale));
448            }
449            Collinear { a, b } => {
450                let (sa, ea) = geo.line(a);
451                let (sb, eb) = geo.line(b);
452                out.push(line_distance(sa, ea, sb)?);
453                out.push(line_distance(sa, ea, eb)?);
454            }
455            Tangent { a, b } => match p.tangent.unwrap() {
456                TangentMode::Endpoint { a_end, b_end } => {
457                    let (ta, tb) = (geo.end_tangent(a, a_end)?, geo.end_tangent(b, b_end)?);
458                    out.push(ta.cross(tb).mul(scale));
459                }
460                TangentMode::LineRound { line, round } => {
461                    let (s, e) = geo.line(line);
462                    let (c, r) = geo.round(round)?;
463                    out.push(line_distance(s, e, c)?.abs().sub(r));
464                }
465                TangentMode::RoundRound { internal } => {
466                    let ((ca, ra), (cb, rb)) = (geo.round(a)?, geo.round(b)?);
467                    let d = ca.sub(cb).norm()?;
468                    out.push(if internal {
469                        d.sub(ra.sub(rb).abs())
470                    } else {
471                        d.sub(ra.add(rb))
472                    });
473                }
474            },
475            Equal { a, b } => {
476                let size = |c: CurveId| -> GeopResult<T> {
477                    Ok(match &self.sketch.curves[&c].kind {
478                        CurveKind::Line { .. } => {
479                            let (s, e) = geo.line(c);
480                            e.sub(s).norm()?
481                        }
482                        _ => geo.round(c)?.1,
483                    })
484                };
485                out.push(size(a)?.sub(size(b)?));
486            }
487            Concentric { a, b } => {
488                let (ca, cb) = (geo.round(a)?.0, geo.round(b)?.0);
489                out.extend([ca.x.sub(cb.x), ca.y.sub(cb.y)]);
490            }
491            Length { curve, value } => {
492                let length = match geo.arc(curve) {
493                    Some(a) => a.length()?,
494                    None => {
495                        let (s, e) = geo.line(curve);
496                        e.sub(s).norm()?
497                    }
498                };
499                out.push(length.sub(T::from_f64(value)));
500            }
501            Radius { curve, value } => out.push(match geo.arc(curve) {
502                // `2 R |sin θ| - L` rather than `L / (2 |sin θ|) - R`: the
503                // same zero set, but finite for a nearly straight arc.
504                Some(a) => T::from_f64(2.0 * value)
505                    .mul(a.half.sin().abs())
506                    .sub(a.chord_length()?),
507                None => geo.round(curve)?.1.sub(T::from_f64(value)),
508            }),
509        }
510        Ok(())
511    }
512
513    /// Objective `Σ r²` and its gradient. A residual that hits a degenerate
514    /// division/sqrt (see the module docs) makes the whole point infeasible
515    /// — `f = ∞` — exactly as a plain `f64` computing `inf`/`nan` there would
516    /// already have made the line search back off.
517    fn objective(&self, x: &[f64]) -> (f64, Vec<f64>) {
518        let mut f = 0.0;
519        let mut g = vec![0.0; x.len()];
520        let mut rs = Vec::new();
521        for p in &self.constraints {
522            let seeded: Vec<(usize, Dual<S>)> = p
523                .vars
524                .iter()
525                .enumerate()
526                .map(|(slot, &i)| (i, Dual::var(S::from_f64(x[i]), slot)))
527                .collect();
528            let geo = Geo {
529                sketch: self.sketch,
530                layout: &self.layout,
531                x,
532                seeded: &seeded,
533            };
534            rs.clear();
535            if self.residuals(p, &geo, &mut rs).is_err() {
536                return (f64::INFINITY, vec![0.0; x.len()]);
537            }
538            for r in &rs {
539                let rv = r.v.to_f64();
540                f += rv * rv;
541                for (slot, &i) in p.vars.iter().enumerate() {
542                    g[i] += 2.0 * rv * r.d[slot].to_f64();
543                }
544            }
545        }
546        for &(point, target, weight) in &self.drags {
547            let i = self.layout.point_var[&point];
548            for k in 0..2 {
549                let r = weight * (x[i + k] - target[k]);
550                f += r * r;
551                g[i + k] += 2.0 * weight * r;
552            }
553        }
554        (f, g)
555    }
556
557    /// Every constraint residual (no drags), and its Jacobian row by row. A
558    /// constraint whose residual hits a degenerate division/sqrt at `x` (see
559    /// the module docs) contributes no rows — [`Problem::report`] catches
560    /// the same failure independently and lists it as unsatisfied.
561    fn jacobian(&self, x: &[f64]) -> (Vec<f64>, Vec<Vec<f64>>) {
562        let mut values = Vec::new();
563        let mut rows = Vec::new();
564        let mut rs = Vec::new();
565        for p in &self.constraints {
566            let seeded: Vec<(usize, Dual<S>)> = p
567                .vars
568                .iter()
569                .enumerate()
570                .map(|(slot, &i)| (i, Dual::var(S::from_f64(x[i]), slot)))
571                .collect();
572            let geo = Geo {
573                sketch: self.sketch,
574                layout: &self.layout,
575                x,
576                seeded: &seeded,
577            };
578            rs.clear();
579            if self.residuals(p, &geo, &mut rs).is_err() {
580                continue;
581            }
582            for r in &rs {
583                values.push(r.v.to_f64());
584                let mut row = vec![0.0; x.len()];
585                for (slot, &i) in p.vars.iter().enumerate() {
586                    row[i] += r.d[slot].to_f64();
587                }
588                rows.push(row);
589            }
590        }
591        (values, rows)
592    }
593
594    fn minimize(&self, x: Vec<f64>, max_iterations: usize) -> (Vec<f64>, usize) {
595        let tol = RELATIVE_TOLERANCE * self.scale;
596        let r = minimize(
597            |x| self.objective(x),
598            x,
599            BfgsOptions {
600                max_iterations,
601                f_tolerance: (0.01 * tol).powi(2),
602                g_tolerance: 0.0,
603            },
604        );
605        (r.x, r.iterations)
606    }
607}
608
609/// A constraint counts as satisfied once its residual is within this
610/// fraction of the sketch's size.
611const RELATIVE_TOLERANCE: f64 = 1e-9;
612
613/// The outcome of a solve.
614#[derive(Clone, Debug, PartialEq, Serialize)]
615pub struct SolveReport {
616    /// Every constraint holds (to [`RELATIVE_TOLERANCE`] of the sketch size).
617    pub converged: bool,
618    /// Largest remaining constraint residual, in sketch units.
619    pub max_residual: f64,
620    pub iterations: usize,
621    /// Remaining degrees of freedom: variables minus independent constraints.
622    pub dof: usize,
623    /// Per point: whether it can still move without violating a constraint.
624    pub free_points: BTreeMap<PointId, bool>,
625    /// Per curve: whether any of its points or its own sweep/radius can still
626    /// change.
627    pub free_curves: BTreeMap<CurveId, bool>,
628    /// The constraints left unsatisfied (conflicting or unreachable), empty
629    /// when `converged`.
630    pub failed_constraints: Vec<ConstraintId>,
631}
632
633impl Sketch {
634    /// Move every point (and arc sweep, circle radius) so all constraints
635    /// hold, changing the sketch as little as the constraints allow.
636    ///
637    /// The sketch is updated even if the solve does not converge, to the
638    /// closest configuration found — the report says which constraints could
639    /// not be met.
640    pub fn solve(&mut self) -> GeopResult<SolveReport> {
641        self.solve_with_drag(&[])
642    }
643
644    /// Like [`Sketch::solve`], while pulling each `(point, target)` towards
645    /// its target as far as the constraints allow — interactive dragging.
646    pub fn solve_with_drag(&mut self, drags: &[(PointId, [f64; 2])]) -> GeopResult<SolveReport> {
647        let mut problem = Problem::new(self)?;
648        let x0 = problem.layout.read(self);
649        let mut iterations = 0;
650        let mut x = x0;
651        if !drags.is_empty() {
652            // Pull softly first, then solve the constraints alone from there:
653            // a dragged point follows the cursor exactly when it is free to,
654            // and the constraints win wherever they disagree with it.
655            problem.drags = drags.iter().map(|&(p, t)| (p, t, 0.1)).collect();
656            let (x1, it) = problem.minimize(x, 200);
657            problem.drags.clear();
658            x = x1;
659            iterations += it;
660        }
661        let (x, it) = problem.minimize(x, 2000);
662        iterations += it;
663
664        let report = problem.report(&x, iterations);
665        let layout = problem.layout;
666        layout.write(self, &x);
667        Ok(report)
668    }
669}
670
671impl Problem<'_> {
672    fn report(&self, x: &[f64], iterations: usize) -> SolveReport {
673        let tol = RELATIVE_TOLERANCE * self.scale;
674        let (values, rows) = self.jacobian(x);
675        let max_residual = values.iter().fold(0.0f64, |m, r| m.max(r.abs()));
676
677        let mut failed_constraints = Vec::new();
678        let mut rs = Vec::new();
679        let mut prepared = self.constraints.iter();
680        for (&i, c) in &self.sketch.constraints {
681            if matches!(c, Constraint::Coincident { .. }) {
682                continue;
683            }
684            let p = prepared.next().unwrap();
685            let geo = Geo::<S> {
686                sketch: self.sketch,
687                layout: &self.layout,
688                x,
689                seeded: &[],
690            };
691            rs.clear();
692            if self.residuals(p, &geo, &mut rs).is_err() {
693                failed_constraints.push(i);
694                continue;
695            }
696            if rs.iter().any(|r| !r.is_finite() || r.to_f64().abs() > tol) {
697                failed_constraints.push(i);
698            }
699        }
700
701        let free_vars = free_variables(rows, self.layout.n);
702        let dof = free_vars.1;
703        let free_vars = free_vars.0;
704        let free_points = self
705            .layout
706            .point_var
707            .iter()
708            .map(|(&p, &i)| (p, free_vars[i] || free_vars[i + 1]))
709            .collect();
710        let free_curves = self
711            .sketch
712            .curves
713            .keys()
714            .map(|&c| {
715                let mut vars = Vec::new();
716                self.layout.curve_vars(self.sketch, c, &mut vars);
717                (c, vars.iter().any(|&i| free_vars[i]))
718            })
719            .collect();
720
721        SolveReport {
722            converged: failed_constraints.is_empty(),
723            max_residual,
724            iterations,
725            dof,
726            free_points,
727            free_curves,
728            failed_constraints,
729        }
730    }
731}
732
733/// Which of `n` variables can move to first order without changing any
734/// residual (those with a nonzero component in the Jacobian's null space),
735/// and the null space's dimension.
736///
737/// Gauss-Jordan elimination with partial pivoting; a pivot counts as zero
738/// below a fixed fraction of the largest entry. This only classifies
739/// entities for display — the solve itself does not depend on it.
740fn free_variables(mut rows: Vec<Vec<f64>>, n: usize) -> (Vec<bool>, usize) {
741    let largest = rows.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
742    let zero = 1e-9 * largest.max(1e-300);
743    let mut pivot_cols = Vec::new();
744    let mut r = 0;
745    for col in 0..n {
746        let Some(best) =
747            (r..rows.len()).max_by(|&a, &b| rows[a][col].abs().total_cmp(&rows[b][col].abs()))
748        else {
749            break;
750        };
751        if rows[best][col].abs() <= zero {
752            continue;
753        }
754        rows.swap(r, best);
755        let pivot = rows[r][col];
756        for v in &mut rows[r] {
757            *v /= pivot;
758        }
759        for i in 0..rows.len() {
760            if i != r && rows[i][col] != 0.0 {
761                let factor = rows[i][col];
762                let (pivot_row, row) = if i < r {
763                    let (lo, hi) = rows.split_at_mut(r);
764                    (&hi[0], &mut lo[i])
765                } else {
766                    let (lo, hi) = rows.split_at_mut(i);
767                    (&lo[r], &mut hi[0])
768                };
769                for (v, p) in row.iter_mut().zip(pivot_row) {
770                    *v -= factor * p;
771                }
772            }
773        }
774        pivot_cols.push(col);
775        r += 1;
776    }
777
778    // Null space basis: one vector per non-pivot column `f`, with `v_f = 1`
779    // and `v_{pivot_cols[i]} = -rows[i][f]`.
780    let mut free = vec![false; n];
781    let is_pivot: Vec<bool> = (0..n).map(|c| pivot_cols.contains(&c)).collect();
782    for f in (0..n).filter(|&c| !is_pivot[c]) {
783        free[f] = true;
784        for (i, &pc) in pivot_cols.iter().enumerate() {
785            if rows[i][f].abs() > 1e-7 {
786                free[pc] = true;
787            }
788        }
789    }
790    (free, n - pivot_cols.len())
791}