//! The constraint solver. //! //! Unknowns are the coordinates of every non-fixed point and the radius of every circle. Each //! constraint contributes residuals (zero when satisfied, in millimetres so that all rows have a //! comparable scale). The system is split into connected components and each is solved with a //! damped Gauss–Newton (Levenberg–Marquardt) iteration on a dense Jacobian built from local //! central differences. Small steps from the current positions are preferred, so //! under-constrained sketches move as little as possible. Afterwards the rank of the Jacobian //! gives the remaining degrees of freedom and which points are fully determined. use serde::{Deserialize, Serialize}; use solvecraft_geom::{Seg2, Vec2}; use crate::linalg::{Mat, determined_vars, solve as lin_solve}; use crate::model::{ConstraintKind, CurveKind, Sketch}; const MAX_ITERS: usize = 500; /// Converged when every residual is below this (mm). const TOL: f64 = 1e-9; /// A solution is accepted when every residual is below this (mm). const ACCEPT: f64 = 1e-6; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SolveStatus { /// All constraints satisfied. Solved, /// The constraints conflict (or the solver could not satisfy them); geometry was left as it /// was before solving. Failed, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SolveReport { pub status: SolveStatus, /// Remaining degrees of freedom (0 = fully constrained). pub dof: usize, pub iterations: usize, pub max_residual: f64, /// Constraints still violated after a failed solve. pub failing: Vec, /// Per point: fully determined by constraints (fixed points count as determined). pub point_determined: Vec, /// Per curve: all of its defining values determined. pub curve_determined: Vec, } impl SolveReport { pub fn ok(&self) -> bool { self.status == SolveStatus::Solved } pub fn fully_constrained(&self) -> bool { self.dof == 0 } } /// Variable layout: point → (x var, y var), circle → radius var. struct Layout { pvar: Vec>, rvar: Vec>, n: usize, } /// Geometry held in place for one solve: point indices and curves whose radius is held. #[derive(Clone, Debug, Default, PartialEq)] pub struct Hold { pub points: Vec, pub radii: Vec, } fn layout(sk: &Sketch, hold: &Hold) -> Layout { let mut fixed: Vec = sk.points.iter().map(|p| p.fixed).collect(); for p in &hold.points { if let Some(f) = fixed.get_mut(*p) { *f = true; } } for c in &sk.constraints { if let ConstraintKind::Fix { p } = c.kind && let Some(f) = fixed.get_mut(p) { *f = true; } } for c in sk.curves.iter().filter(|c| c.fixed) { for p in c.kind.point_ids() { if let Some(f) = fixed.get_mut(p) { *f = true; } } } let mut n = 0; let pvar = fixed .iter() .map(|f| { if *f { None } else { n += 2; Some(n - 2) } }) .collect(); let rvar = sk .curves .iter() .enumerate() .map(|(i, c)| match c.kind { CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() && !c.fixed && !hold.radii.contains(&i) => { n += 1; Some(n - 1) } _ => None, }) .collect(); Layout { pvar, rvar, n } } /// Read-only view of the sketch with variable values substituted. struct State<'a> { sk: &'a Sketch, lay: &'a Layout, x: &'a [f64], } impl State<'_> { fn p(&self, i: usize) -> Vec2 { match self.lay.pvar.get(i).copied().flatten() { Some(v) => Vec2::new(self.x.get(v).copied().unwrap_or(0.0), self.x.get(v + 1).copied().unwrap_or(0.0)), None => self.sk.points.get(i).map(|p| p.pos).unwrap_or_default(), } } fn line(&self, l: usize) -> (Vec2, Vec2) { match self.sk.curves.get(l).map(|c| &c.kind) { Some(CurveKind::Line { a, b }) => (self.p(*a), self.p(*b)), _ => (Vec2::ZERO, Vec2::X), } } /// Centre and radius of a circle or arc. fn round(&self, c: usize) -> (Vec2, f64) { match self.sk.curves.get(c).map(|c| &c.kind) { Some(CurveKind::Circle { c: ci, r }) => { let r = match self.lay.rvar.get(c).copied().flatten() { Some(v) => self.x.get(v).copied().unwrap_or(*r), None => *r, }; (self.p(*ci), r) } Some(CurveKind::Arc { c: ci, a, .. }) => { let cc = self.p(*ci); (cc, cc.dist(self.p(*a))) } _ => (Vec2::ZERO, 0.0), } } fn is_line(&self, c: usize) -> bool { matches!(self.sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) } fn is_freeform(&self, c: usize) -> bool { self.sk.curves.get(c).is_some_and(|c| c.kind.is_freeform()) } /// Polyline of a free-form curve with trial values. fn poly(&self, c: usize) -> Vec { match self.sk.curves.get(c).map(|c| &c.kind) { Some(CurveKind::Ellipse { c: ci, m, r }) => { let r = match self.lay.rvar.get(c).copied().flatten() { Some(v) => self.x.get(v).copied().unwrap_or(*r), None => *r, }; crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r) } Some(CurveKind::Spline { pts, control, degree, periodic }) => { let p = self.spline_points(pts, *periodic); crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic) } Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho), _ => Vec::new(), } } /// Point-on-curve constraints use the exact shape. A polyline residual would move an /// exact crossing onto its chord, changing geometry merely by splitting it. fn spline_points(&self, pts: &[usize], periodic: bool) -> Vec { let mut points: Vec = pts.iter().map(|i| self.p(*i)).collect(); if periodic && let Some(first) = points.first().copied() && let Some(last) = points.last_mut() { *last = first; } points } fn freeform_dist(&self, c: usize, q: Vec2) -> f64 { let segs: Vec = match self.sk.curves.get(c).map(|c| &c.kind) { Some(CurveKind::Ellipse { c: ci, m, .. }) => { let center = self.p(*ci); let u = self.p(*m) - center; let a = u.len(); let r = self.radius_of(c); if a <= 1e-12 || r <= 1e-12 { return q.dist(center); } let delta = q - center; let x = delta.dot(u) / (a * a); let y = delta.cross(u) / (a * r); return (x.hypot(y) - 1.0) * a.min(r); } Some(CurveKind::Spline { pts, control, degree, periodic }) => { let p = self.spline_points(pts, *periodic); if p.len() < 2 { return q.dist(p.first().copied().unwrap_or_default()); } if *control { let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1); crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect() } else { crate::curves::fit_beziers_mode(&p, *periodic) .unwrap_or_default() .into_iter() .map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }) .collect() } } Some(CurveKind::Conic { a, b, apex, rho }) => { vec![Seg2::Conic { a: self.p(*a), b: self.p(*b), apex: self.p(*apex), w: rho / (1.0 - rho).max(1e-9) }] } _ => Vec::new(), }; segs.iter() .map(|seg| { let t = seg.closest_param(q); let delta = q - seg.point_at(t); let d = delta.len(); (d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d }) }) .min_by(|a, b| a.0.total_cmp(&b.0)) .map(|(_, signed)| signed) .unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q)) } /// Radius of a circle or arc; an ellipse's minor radius. fn radius_of(&self, c: usize) -> f64 { match self.sk.curves.get(c).map(|x| &x.kind) { Some(CurveKind::Ellipse { r, .. }) => match self.lay.rvar.get(c).copied().flatten() { Some(v) => self.x.get(v).copied().unwrap_or(*r), None => *r, }, _ => self.round(c).1, } } fn ends(&self, c: usize) -> Option<(usize, usize)> { self.sk.curves.get(c)?.kind.ends() } /// Length along an arc (counter-clockwise from its start). fn arc_length(&self, c: usize) -> f64 { match self.sk.curves.get(c).map(|c| &c.kind) { Some(CurveKind::Arc { c: ci, a, b }) => { let (cc, pa, pb) = (self.p(*ci), self.p(*a), self.p(*b)); let mut sw = (pb - cc).angle() - (pa - cc).angle(); while sw <= 1e-12 { sw += std::f64::consts::TAU; } cc.dist(pa) * sw } _ => 0.0, } } fn arc_sweep(&self, c: usize) -> f64 { let Some(curve) = self.sk.curves.get(c) else { return 0.0 }; let CurveKind::Arc { c: cp, a, b } = curve.kind else { return 0.0 }; let sweep = ((self.p(b) - self.p(cp)).angle() - (self.p(a) - self.p(cp)).angle()).rem_euclid(std::f64::consts::TAU); if curve.reversed { -sweep } else { sweep } } fn slot_length(&self, a: usize, b: usize) -> f64 { let (ca, ra) = self.round(a); let (cb, rb) = self.round(b); ca.dist(cb) + ra + rb } /// Curvature vector of a curve at its point `p` (towards the centre of curvature). fn curvature_at(&self, c: usize, p: usize) -> Vec2 { let Some(kind) = self.sk.curves.get(c).map(|c| &c.kind) else { return Vec2::ZERO }; match kind { CurveKind::Line { .. } | CurveKind::Ellipse { .. } => Vec2::ZERO, CurveKind::Circle { .. } | CurveKind::Arc { .. } => { let (cc, r) = self.round(c); (cc - self.p(p)) / (r * r).max(1e-24) } CurveKind::Spline { pts, control, degree, periodic } => { let q = self.spline_points(pts, *periodic); let at_end = pts.last() == Some(&p) && pts.first() != Some(&p); crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, t), at_end) } CurveKind::Conic { a, b, apex, rho } => { let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex)); crate::curves::end_curvature(&|t| crate::curves::conic_point(pa, px, pb, *rho, t), p == *b) } } } /// Unit tangent of any curve at its point `p` (an end point for open free-form curves). fn tangent_at(&self, c: usize, p: usize) -> Option { match self.sk.curves.get(c).map(|c| &c.kind)? { CurveKind::Line { a, b } => (self.p(*b) - self.p(*a)).normalized(), CurveKind::Circle { .. } | CurveKind::Arc { .. } => (self.p(p) - self.round(c).0).perp().normalized(), CurveKind::Spline { pts, control, periodic, .. } => { let q = self.spline_points(pts, *periodic); crate::curves::spline_end_tangent_mode(&q, *control, *periodic, pts.last() == Some(&p) && pts.first() != Some(&p)) } CurveKind::Conic { a, b, apex, .. } => { if p == *a { (self.p(*apex) - self.p(*a)).normalized() } else if p == *b { (self.p(*b) - self.p(*apex)).normalized() } else { None } } CurveKind::Ellipse { .. } => { let poly = self.poly(c); let q = self.p(p); let k = (0..poly.len().saturating_sub(1)).min_by(|i, j| poly[*i].dist(q).total_cmp(&poly[*j].dist(q)))?; (*poly.get(k + 1)? - *poly.get(k)?).normalized() } } } } fn wrap_angle(a: f64) -> f64 { let t = std::f64::consts::TAU; let mut a = a % t; if a > std::f64::consts::PI { a -= t; } else if a <= -std::f64::consts::PI { a += t; } a } /// Distance from `p` to the infinite line through `a`, `b` (signed by side). fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 { let d = b - a; let l = d.len().max(1e-12); d.cross(p - a) / l } /// Residual rows of one constraint. fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { use ConstraintKind::*; match *k { Coincident { p, q } => { let d = s.p(p) - s.p(q); out.extend([d.x, d.y]); } PointOnCurve { p, c } => { let pp = s.p(p); if s.is_freeform(c) { out.push(s.freeform_dist(c, pp)); } else if s.is_line(c) { let (a, b) = s.line(c); out.push(line_dist(pp, a, b)); } else { let (cc, r) = s.round(c); out.push(pp.dist(cc) - r); } } Horizontal { l } => { let (a, b) = s.line(l); out.push(b.y - a.y); } Vertical { l } => { let (a, b) = s.line(l); out.push(b.x - a.x); } HorizontalPoints { p, q } => out.push(s.p(q).y - s.p(p).y), VerticalPoints { p, q } => out.push(s.p(q).x - s.p(p).x), Parallel { a, b } | Perpendicular { a, b } => { let (a0, a1) = s.line(a); let (b0, b1) = s.line(b); let (da, db) = (a1 - a0, b1 - b0); // The sine (parallel) or cosine (perpendicular) of the angle between the lines: // independent of their lengths, so the solver can't satisfy it by shrinking a line // to nothing (scaling by the summed lengths did, collapsing dragged rectangles). let scale = (da.len() * db.len()).max(1e-12); let v = if matches!(k, Parallel { .. }) { da.cross(db) } else { da.dot(db) }; out.push(v / scale); } Collinear { a, b } => { let (a0, a1) = s.line(a); let (b0, b1) = s.line(b); out.push(line_dist(b0, a0, a1)); out.push(line_dist(b1, a0, a1)); } Tangent { a, b } => { let (la, lb) = (s.is_line(a), s.is_line(b)); if s.is_freeform(a) || s.is_freeform(b) { // Without a shared end point: the curves touch (closest approach zero). let (f, o) = if s.is_freeform(a) { (a, b) } else { (b, a) }; let poly = s.poly(f); // Signed gap at the closest approach (smooth through zero, unlike its size). let closest = |v: &mut dyn Iterator| v.fold(f64::INFINITY, |m: f64, x| if x.abs() < m.abs() { x } else { m }); let d = if s.is_line(o) { let (p0, p1) = s.line(o); closest(&mut poly.iter().map(|q| line_dist(*q, p0, p1))) } else if s.is_freeform(o) { let other = s.poly(o); closest(&mut poly.iter().map(|q| crate::curves::polyline_signed_dist(&other, *q))) } else { let (cc, r) = s.round(o); closest(&mut poly.iter().map(|q| q.dist(cc) - r)) }; out.push(if d.is_finite() { d } else { 0.0 }); } else if la || lb { let (l, c) = if la { (a, b) } else { (b, a) }; let (p0, p1) = s.line(l); let (cc, r) = s.round(c); out.push(line_dist(cc, p0, p1).abs() - r); } else { let (c1, r1) = s.round(a); let (c2, r2) = s.round(b); let d = c1.dist(c2); let ext = d - (r1 + r2); let int = d - (r1 - r2).abs(); out.push(if ext.abs() <= int.abs() { ext } else { int }); } } Equal { a, b } => { if s.is_line(a) { let (a0, a1) = s.line(a); let (b0, b1) = s.line(b); out.push(a0.dist(a1) - b0.dist(b1)); } else { out.push(s.round(a).1 - s.round(b).1); } } Concentric { a, b } => { let d = s.round(a).0 - s.round(b).0; out.extend([d.x, d.y]); } Midpoint { p, l } => { let (a, b) = s.line(l); let d = s.p(p) - (a + b) * 0.5; out.extend([d.x, d.y]); } Symmetric { p, q, l } => { let (a, b) = s.line(l); let (pp, qq) = (s.p(p), s.p(q)); let dir = b - a; let len = dir.len().max(1e-12); out.push(line_dist((pp + qq) * 0.5, a, b)); out.push((qq - pp).dot(dir) / len); } Fix { .. } => {} Distance { p, q, value } => out.push(s.p(p).dist(s.p(q)) - value), DistanceX { p, q, value } => out.push((s.p(q).x - s.p(p).x).abs() - value), DistanceY { p, q, value } => out.push((s.p(q).y - s.p(p).y).abs() - value), PointLineDistance { p, l, value } => { let (a, b) = s.line(l); out.push(line_dist(s.p(p), a, b).abs() - value); } Length { l, value } => { let (a, b) = s.line(l); out.push(a.dist(b) - value); } LineAngle { l, value } => { let (a, b) = s.line(l); // A dimension with no typed length must not satisfy its angle by collapsing // the line. Scale with the original geometry, independently of the unknowns. let scale = match s.sk.curves.get(l).map(|c| &c.kind) { Some(CurveKind::Line { a, b }) => s.sk.point(*a).zip(s.sk.point(*b)).map(|(a, b)| a.dist(b)).unwrap_or(1.0), _ => 1.0, }; out.push(wrap_angle((b - a).angle() - value) * scale.max(1.0)); } ArcSweep { c, value } => out.push((s.arc_sweep(c) - value) * s.sk.radius(c).unwrap_or(1.0).max(1.0)), SlotOverallLength { a, b, value } => out.push(s.slot_length(a, b) - value), Radius { c, value } => out.push(s.radius_of(c) - value), Diameter { c, value } => out.push(2.0 * s.round(c).1 - value), Angle { a, b, value, flip } => { let (a0, a1) = s.line(a); let (b0, b1) = s.line(b); let (da, db) = (a1 - a0, b1 - b0); let ang = da.cross(db).atan2(da.dot(db)); let scale = 0.5 * (da.len() + db.len()); let target = if flip { value + std::f64::consts::PI } else { value }; out.push(wrap_angle(ang - target) * scale.max(1e-6)); } Smooth { a, b } => { let shared = match (s.ends(a), s.ends(b)) { (Some((a0, a1)), Some((b0, b1))) => [a0, a1].into_iter().find(|x| *x == b0 || *x == b1), _ => None, }; let Some(p) = shared else { out.extend([0.0, 0.0, 0.0]); return; }; match (s.tangent_at(a, p), s.tangent_at(b, p)) { (Some(u), Some(v)) => out.push(u.cross(v) * 10.0), _ => out.push(0.0), } let k = s.curvature_at(a, p) - s.curvature_at(b, p); out.extend([k.x * 100.0, k.y * 100.0]); } ArcLength { c, value } => out.push(s.arc_length(c) - value), LinearDiameter { p, l, value } => { let (a, b) = s.line(l); out.push(2.0 * line_dist(s.p(p), a, b).abs() - value); } } } /// Measured value of a dimension (for driven dimensions). fn measure(s: &State, k: &ConstraintKind) -> Option { use ConstraintKind::*; Some(match *k { Distance { p, q, .. } => s.p(p).dist(s.p(q)), DistanceX { p, q, .. } => (s.p(q).x - s.p(p).x).abs(), DistanceY { p, q, .. } => (s.p(q).y - s.p(p).y).abs(), PointLineDistance { p, l, .. } => { let (a, b) = s.line(l); line_dist(s.p(p), a, b).abs() } Length { l, .. } => { let (a, b) = s.line(l); a.dist(b) } LineAngle { l, value } => { let (a, b) = s.line(l); value + wrap_angle((b - a).angle() - value) } ArcSweep { c, .. } => s.arc_sweep(c), SlotOverallLength { a, b, .. } => s.slot_length(a, b), Radius { c, .. } => s.radius_of(c), Diameter { c, .. } => 2.0 * s.round(c).1, Angle { a, b, flip, .. } => { let (a0, a1) = s.line(a); let (b0, b1) = s.line(b); let (da, db) = (a1 - a0, b1 - b0); let ang = da.cross(db).atan2(da.dot(db)) - if flip { std::f64::consts::PI } else { 0.0 }; ang.rem_euclid(std::f64::consts::TAU) } ArcLength { c, .. } => s.arc_length(c), LinearDiameter { p, l, .. } => { let (a, b) = s.line(l); 2.0 * line_dist(s.p(p), a, b).abs() } _ => return None, }) } /// 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 }; measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite()) } /// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known /// touch point (better conditioned than the distance form when an end point is on the curve). enum Block<'a> { Closure { a: usize, b: usize }, Arc { c: usize, a: usize, b: usize }, User(usize, &'a ConstraintKind), TangentAt(usize, &'a ConstraintKind, usize), } fn copy_block<'a>(b: &Block<'a>) -> Block<'a> { match b { Block::Closure { a, b } => Block::Closure { a: *a, b: *b }, Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b }, Block::User(i, k) => Block::User(*i, k), Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p), } } fn block_residuals(s: &State, b: &Block, out: &mut Vec) { match b { Block::Closure { a, b } => { let d = s.p(*a) - s.p(*b); out.extend([d.x, d.y]); } Block::Arc { c, a, b } => { let cc = s.p(*c); out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a))); } Block::User(_, k) => residuals(s, k, out), Block::TangentAt(_, k, p) => { let ConstraintKind::Tangent { a, b } = **k else { return }; let pt = s.p(*p); if s.is_freeform(a) || s.is_freeform(b) { // Directions at the shared end parallel. match (s.tangent_at(a, *p), s.tangent_at(b, *p)) { (Some(u), Some(v)) => out.push(u.cross(v) * 10.0), _ => out.push(0.0), } } else if s.is_line(a) || s.is_line(b) { let (l, c) = if s.is_line(a) { (a, b) } else { (b, a) }; let (p0, p1) = s.line(l); let (cc, _) = s.round(c); let d = p1 - p0; // The radius to the touch point is perpendicular to the line. out.push((pt - cc).dot(d) / d.len().max(1e-12)); } else { let (c1, _) = s.round(a); let (c2, _) = s.round(b); let (u, v) = (pt - c1, pt - c2); // Both centres on one line through the touch point. out.push(u.cross(v) / (u.len() + v.len()).max(1e-12)); } } } } /// A point where a tangency must happen: an end point shared by the two curves, or a line end /// point constrained onto the round curve. fn touch_point(sk: &Sketch, k: &ConstraintKind) -> Option { let ConstraintKind::Tangent { a, b } = *k else { return None }; let ends = |c: usize| -> Vec { match sk.curves.get(c).map(|c| &c.kind) { Some(k) => k.ends().map(|(a, b)| vec![a, b]).unwrap_or_default(), None => Vec::new(), } }; let (ea, eb) = (ends(a), ends(b)); if let Some(p) = ea.iter().find(|p| eb.contains(p)) { return Some(*p); } let on = |p: usize, c: usize| sk.constraints.iter().any(|x| x.kind == ConstraintKind::PointOnCurve { p, c }); ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))).copied() } /// Variables a block depends on. fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { let mut v = Vec::new(); let pt = |v: &mut Vec, p: usize| { if let Some(x) = lay.pvar.get(p).copied().flatten() { v.extend([x, x + 1]); } }; let curve = |v: &mut Vec, c: usize| { if let Some(k) = sk.curves.get(c).map(|c| &c.kind) { for q in k.point_ids() { pt(v, q); } if let Some(r) = lay.rvar.get(c).copied().flatten() { v.push(r); } } }; use ConstraintKind::*; match b { Block::Closure { a, b } => { pt(&mut v, *a); pt(&mut v, *b); } Block::Arc { c, a, b } => { pt(&mut v, *c); pt(&mut v, *a); pt(&mut v, *b); } Block::User(_, k) => match **k { Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => { pt(&mut v, p); pt(&mut v, q); } Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => { pt(&mut v, p); pt(&mut v, q); } PointOnCurve { p, c } | Midpoint { p, l: c } | PointLineDistance { p, l: c, .. } => { pt(&mut v, p); curve(&mut v, c); } Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } | ArcSweep { c: l, .. } | Radius { c: l, .. } | Diameter { c: l, .. } => curve(&mut v, l), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } | Angle { a, b, .. } | SlotOverallLength { a, b, .. } => { curve(&mut v, a); curve(&mut v, b); } Symmetric { p, q, l } => { pt(&mut v, p); pt(&mut v, q); curve(&mut v, l); } Fix { .. } => {} Smooth { a, b } => { curve(&mut v, a); curve(&mut v, b); } ArcLength { c, .. } => curve(&mut v, c), LinearDiameter { p, l, .. } => { pt(&mut v, p); curve(&mut v, l); } }, Block::TangentAt(_, k, p) => { pt(&mut v, *p); if let ConstraintKind::Tangent { a, b } = **k { curve(&mut v, a); curve(&mut v, b); } } } v.sort_unstable(); v.dedup(); v } fn find(parent: &mut [usize], mut i: usize) -> usize { while let Some(&p) = parent.get(i) { if p == i { break; } let gp = parent.get(p).copied().unwrap_or(p); if let Some(slot) = parent.get_mut(i) { *slot = gp; } i = p; } i } /// Residual vector and Jacobian (rows × all n vars) for the given blocks. fn system(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], x: &mut [f64], with_j: bool) -> (Vec, Option, Vec) { let mut r = Vec::new(); let mut owner = Vec::new(); for (bi, (b, _)) in blocks.iter().enumerate() { let before = r.len(); block_residuals(&State { sk, lay, x }, b, &mut r); owner.extend(std::iter::repeat_n(bi, r.len() - before)); } if !with_j { return (r, None, owner); } let mut j = Mat::zeros(r.len(), lay.n); let mut row = 0; let mut plus = Vec::new(); let mut minus = Vec::new(); for (b, vars) in blocks { plus.clear(); block_residuals(&State { sk, lay, x }, b, &mut plus); let rows = plus.len(); for &v in vars { let Some(x0) = x.get(v).copied() else { continue }; let h = 1e-7 * x0.abs().max(1.0); if let Some(xv) = x.get_mut(v) { *xv = x0 + h; } plus.clear(); block_residuals(&State { sk, lay, x }, b, &mut plus); if let Some(xv) = x.get_mut(v) { *xv = x0 - h; } minus.clear(); block_residuals(&State { sk, lay, x }, b, &mut minus); if let Some(xv) = x.get_mut(v) { *xv = x0; } for k in 0..rows { let d = (plus.get(k).copied().unwrap_or(0.0) - minus.get(k).copied().unwrap_or(0.0)) / (2.0 * h); j.set(row + k, v, d); } } row += rows; } (r, Some(j), owner) } /// Residuals and the Jacobian restricted to `vars` (columns in that order; `col` maps a global /// variable to its column, usize::MAX elsewhere). Blocks only touch their component's variables, /// so a component's Jacobian is small even in a huge sketch. fn local_system( sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], x: &mut [f64], col: &[usize], ncols: usize, ) -> (Vec, Mat, Vec) { let (r, _, owner) = system(sk, lay, blocks, x, false); let mut j = Mat::zeros(r.len(), ncols); let mut row = 0; let mut plus = Vec::new(); let mut minus = Vec::new(); for (b, vars) in blocks { plus.clear(); block_residuals(&State { sk, lay, x }, b, &mut plus); let rows = plus.len(); for &v in vars { let Some(c) = col.get(v).copied().filter(|c| *c < ncols) else { continue }; let Some(x0) = x.get(v).copied() else { continue }; let h = 1e-7 * x0.abs().max(1.0); if let Some(xv) = x.get_mut(v) { *xv = x0 + h; } plus.clear(); block_residuals(&State { sk, lay, x }, b, &mut plus); if let Some(xv) = x.get_mut(v) { *xv = x0 - h; } minus.clear(); block_residuals(&State { sk, lay, x }, b, &mut minus); if let Some(xv) = x.get_mut(v) { *xv = x0; } for k in 0..rows { let d = (plus.get(k).copied().unwrap_or(0.0) - minus.get(k).copied().unwrap_or(0.0)) / (2.0 * h); j.set(row + k, c, d); } } row += rows; } (r, j, owner) } fn max_abs(v: &[f64]) -> f64 { v.iter().fold(0.0_f64, |m, x| if x.is_finite() { m.max(x.abs()) } else { f64::INFINITY }) } fn norm2(v: &[f64]) -> f64 { v.iter().map(|x| x * x).sum() } /// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns iterations. fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], x: &mut Vec, col: &[usize]) -> usize { let n = vars.len(); let mut lambda = 1e-3; let (mut r, _, _) = system(sk, lay, blocks, x, false); let mut f = norm2(&r); for it in 0..MAX_ITERS { if max_abs(&r) < TOL { return it; } let (_, j, _) = local_system(sk, lay, blocks, x, col, n); // Normal equations of this component. let mut a = Mat::zeros(n, n); let mut g = vec![0.0; n]; for row in 0..j.rows { let ri = r.get(row).copied().unwrap_or(0.0); let nz: Vec<(usize, f64)> = (0..n).map(|li| (li, j.get(row, li))).filter(|(_, d)| *d != 0.0).collect(); for &(li, di) in &nz { if let Some(gi) = g.get_mut(li) { *gi += di * ri; } for &(lk, dk) in &nz { a.add(li, lk, di * dk); } } } 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; 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); } let rhs: Vec = g.iter().map(|v| -v).collect(); let Some(step) = lin_solve(m, rhs) else { lambda *= 10.0; continue; }; let mut trial = x.clone(); for (li, &v) in vars.iter().enumerate() { if let (Some(t), Some(s)) = (trial.get_mut(v), step.get(li)) { *t += s; } } let (rt, _, _) = system(sk, lay, blocks, &mut trial, false); let ft = norm2(&rt); if ft.is_finite() && ft < f { *x = trial; r = rt; f = ft; lambda = (lambda / 3.0).max(1e-12); improved = true; break; } lambda *= 4.0; if lambda > 1e12 { break; } } if !improved { return it; } } MAX_ITERS } /// Solve the sketch in place. On failure the geometry is restored and the report lists the /// violated constraints. pub fn solve(sk: &mut Sketch) -> SolveReport { solve_holding(sk, &Hold::default()) } /// Solve with some geometry held as if it were fixed (for this solve only): applying a /// constraint between two entities holds the first so only the second moves. Everything else /// moves as little as it can. On failure the geometry is restored. pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { let mut orientation_before = Vec::new(); for constraint in &sk.constraints { if !constraint.driven && let ConstraintKind::ArcSweep { c, value } = constraint.kind && value.is_finite() && let Some(curve) = sk.curves.get_mut(c) && let CurveKind::Arc { a, b, .. } = &mut curve.kind && curve.reversed != (value < 0.0) { orientation_before.push((c, *a, *b, curve.reversed)); std::mem::swap(a, b); curve.reversed = value < 0.0; } } let lay = layout(sk, hold); let mut x = vec![0.0; lay.n]; for (i, p) in sk.points.iter().enumerate() { if let Some(v) = lay.pvar.get(i).copied().flatten() { if let Some(s) = x.get_mut(v) { *s = p.pos.x; } if let Some(s) = x.get_mut(v + 1) { *s = p.pos.y; } } } for (i, c) in sk.curves.iter().enumerate() { if let (CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, Some(v)) = (&c.kind, lay.rvar.get(i).copied().flatten()) && let Some(s) = x.get_mut(v) { *s = *r; } } let x0 = x.clone(); // Exactly opposite directions sit on the wrapped angle's derivative discontinuity. // Nudge one free endpoint into a continuous branch before Newton iterations. The // untouched x0 still restores the complete original geometry on a failed solve. for constraint in &sk.constraints { if constraint.driven { continue; } let ConstraintKind::LineAngle { l, value } = constraint.kind else { continue }; let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { continue }; let (pa, pb) = { let state = State { sk, lay: &lay, x: &x }; (state.p(*a), state.p(*b)) }; let delta = wrap_angle((pb - pa).angle() - value); if delta.abs() < std::f64::consts::PI - 1e-5 || !delta.is_finite() { continue; } let d = Vec2::from_angle((pb - pa).angle() - delta.signum() * 0.01) * pa.dist(pb); let (point, at) = if lay.pvar.get(*b).copied().flatten().is_some() { (*b, pa + d) } else { (*a, pb - d) }; if let Some(index) = lay.pvar.get(point).copied().flatten() { if let Some(v) = x.get_mut(index) { *v = at.x; } if let Some(v) = x.get_mut(index + 1) { *v = at.y; } } } let mut blocks: Vec<(Block, Vec)> = Vec::new(); for c in &sk.curves { if let CurveKind::Spline { pts, periodic: true, .. } = &c.kind && let (Some(a), Some(b)) = (pts.first(), pts.last()) && a != b { let bl = Block::Closure { a: *a, b: *b }; let vars = block_vars(sk, &lay, &bl); blocks.push((bl, vars)); } if let CurveKind::Arc { c, a, b } = c.kind { let bl = Block::Arc { c, a, b }; let vars = block_vars(sk, &lay, &bl); blocks.push((bl, vars)); } } for (i, c) in sk.constraints.iter().enumerate() { if c.driven { continue; } let bl = match touch_point(sk, &c.kind) { Some(p) => Block::TangentAt(i, &c.kind, p), None => Block::User(i, &c.kind), }; let vars = block_vars(sk, &lay, &bl); blocks.push((bl, vars)); } // Connected components over variables. let mut parent: Vec = (0..lay.n).collect(); for (_, vars) in &blocks { if let Some(&first) = vars.first() { for &v in vars.iter().skip(1) { let (ra, rb) = (find(&mut parent, first), find(&mut parent, v)); if let Some(p) = parent.get_mut(ra) { *p = rb; } } } } let mut comps: Vec<(usize, Vec, Vec)> = Vec::new(); // (root, vars, block idx) let mut comp_of: std::collections::HashMap = std::collections::HashMap::new(); for v in 0..lay.n { let r = find(&mut parent, v); match comp_of.get(&r) { Some(&k) => { if let Some(c) = comps.get_mut(k) { c.1.push(v); } } None => { comp_of.insert(r, comps.len()); comps.push((r, vec![v], Vec::new())); } } } let mut constant_blocks = Vec::new(); for (bi, (_, vars)) in blocks.iter().enumerate() { match vars.first() { Some(&v) => { let r = find(&mut parent, v); if let Some(c) = comp_of.get(&r).and_then(|k| comps.get_mut(*k)) { c.2.push(bi); } } None => constant_blocks.push(bi), } } // Column of each variable within its component. let mut col = vec![usize::MAX; lay.n]; for (_, vars, _) in &comps { for (k, v) in vars.iter().enumerate() { if let Some(c) = col.get_mut(*v) { *c = k; } } } let mut iterations = 0; // Solve each component; `blocks` is consumed by index so rebuild per component. let all_blocks = blocks; for (_, vars, bis) in &comps { if bis.is_empty() { continue; } let sub: Vec<(Block, Vec)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect(); iterations += lm(sk, &lay, &sub, vars, &mut x, &col); } // Final residuals and analysis, component by component (the Jacobian is block diagonal: // its rank is the sum of the components' ranks). let mut max_residual = 0.0_f64; let mut failing: Vec = Vec::new(); let mut det = vec![false; lay.n]; let mut rank = 0; let mut check = |r: &[f64], owner: &[usize], blocks: &[(Block, Vec)], failing: &mut Vec| { max_residual = max_residual.max(max_abs(r)); for (row, v) in r.iter().enumerate() { if !(v.abs() < ACCEPT) && let Some((Block::User(ci, _) | Block::TangentAt(ci, _, _), _)) = owner.get(row).and_then(|bi| blocks.get(*bi)) && let Some(c) = sk.constraints.get(*ci) && !failing.contains(&c.id) { failing.push(c.id.clone()); } } }; for (_, vars, bis) in &comps { let sub: Vec<(Block, Vec)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect(); if sub.is_empty() { continue; } let (r, j, owner) = local_system(sk, &lay, &sub, &mut x, &col, vars.len()); check(&r, &owner, &sub, &mut failing); if j.rows > 0 { let (d, rk) = determined_vars(&j, 1e-7); rank += rk; for (k, v) in vars.iter().enumerate() { if let (Some(slot), Some(flag)) = (det.get_mut(*v), d.get(k)) { *slot = *flag; } } } } let consts: Vec<(Block, Vec)> = constant_blocks.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect(); if !consts.is_empty() { let (r, _, owner) = system(sk, &lay, &consts, &mut x, false); check(&r, &owner, &consts, &mut failing); } // Weld the whole connected closure graph, preserving every immutable node. // Validate the actual prospective geometry before accepting a numerical solve. let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. })); let periodic_positions = if has_periodic { let state = State { sk, lay: &lay, x: &x }; let mut positions = (0..sk.points.len()).map(|i| state.p(i)).collect::>(); let mut parent = (0..positions.len()).collect::>(); for curve in &sk.curves { if let CurveKind::Spline { pts, periodic: true, .. } = &curve.kind && let (Some(a), Some(b)) = (pts.first(), pts.last()) && *a < parent.len() && *b < parent.len() { let (a, b) = (find(&mut parent, *a), find(&mut parent, *b)); if let Some(slot) = parent.get_mut(b) { *slot = a; } } } let mut representatives = std::collections::BTreeMap::new(); let mut valid = true; for i in 0..positions.len() { let root = find(&mut parent, i); if let Some(previous) = representatives.get(&root).copied() { let fixed = |p| lay.pvar.get(p).is_some_and(Option::is_none); if fixed(i) && fixed(previous) && positions[i] != positions[previous] { valid = false; } if fixed(i) && !fixed(previous) { representatives.insert(root, i); } } else { representatives.insert(root, i); } } for (i, position) in positions.iter_mut().enumerate() { if let Some(index) = representatives.get(&find(&mut parent, i)) { *position = state.p(*index); } } for curve in &sk.curves { if let CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { let points = pts.iter().map(|i| positions.get(*i).copied()).collect::>>(); if *control || *degree != 3 || points.as_ref().is_none_or(|p| crate::periodic::validate_periodic_points(p).is_err()) { valid = false; } } } if !valid { max_residual = max_residual.max(1.0); None } else { Some(positions) } } else { None }; let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed }; let use_x = if status == SolveStatus::Solved { &x } else { &x0 }; // Write back. for (i, p) in sk.points.iter_mut().enumerate() { if let Some(v) = lay.pvar.get(i).copied().flatten() { p.pos = Vec2::new(use_x.get(v).copied().unwrap_or(p.pos.x), use_x.get(v + 1).copied().unwrap_or(p.pos.y)); } } for (i, c) in sk.curves.iter_mut().enumerate() { if let (CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, Some(v)) = (&mut c.kind, lay.rvar.get(i).copied().flatten()) { *r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9); } } if status == SolveStatus::Solved && let Some(positions) = periodic_positions { for (point, position) in sk.points.iter_mut().zip(positions) { point.pos = position; } } if status == SolveStatus::Failed { for (ci, a_before, b_before, reversed) in orientation_before.into_iter().rev() { if let Some(curve) = sk.curves.get_mut(ci) && let CurveKind::Arc { a, b, .. } = &mut curve.kind { *a = a_before; *b = b_before; curve.reversed = reversed; } } } let vdet = |v: Option| v.is_none_or(|v| det.get(v).copied().unwrap_or(false)); let pdet = |p: usize| match lay.pvar.get(p).copied().flatten() { Some(v) => vdet(Some(v)) && vdet(Some(v + 1)), 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 }; 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) { if let Some(v) = m && v.is_finite() { c.kind.set_value(v); } } let point_determined: Vec = (0..sk.points.len()).map(pdet).collect(); let curve_determined = sk.curves.iter().enumerate().map(|(i, c)| c.kind.point_ids().into_iter().all(pdet) && vdet(lay.rvar.get(i).copied().flatten())).collect(); SolveReport { status, dof: lay.n.saturating_sub(rank), iterations, max_residual: if max_residual.is_finite() { max_residual } else { f64::MAX }, failing, point_determined, curve_determined, } } #[cfg(test)] mod typed_dimension_tests { use super::*; #[test] fn failed_signed_sweep_restores_arc_orientation_and_coordinates() { let mut sk = Sketch::new(); let c = sk.add_arc(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), [Some(0), None, None], None).unwrap(); let id = sk.add_constraint(ConstraintKind::ArcSweep { c, value: std::f64::consts::FRAC_PI_2 }, None).unwrap(); sk.curves[c].fixed = true; assert!(solve(&mut sk).ok()); let before = sk.curves.clone(); let points = sk.points.clone(); sk.constraints.iter_mut().find(|constraint| constraint.id == id).unwrap().kind.set_value(-std::f64::consts::FRAC_PI_2); assert!(!solve(&mut sk).ok()); assert_eq!(sk.curves, before); assert_eq!(sk.points, points); } #[test] fn signed_dimensions_validate_kinds_values_and_survive_curve_removal() { let mut sk = Sketch::new(); let l = sk.add_line(Vec2::ZERO, Vec2::new(10.0, 10.0), Some(0), None, None).unwrap(); assert!(sk.add_constraint(ConstraintKind::ArcSweep { c: l, value: 1.0 }, None).is_err()); assert!(sk.add_constraint(ConstraintKind::LineAngle { l, value: f64::NAN }, None).is_err()); sk.add_constraint(ConstraintKind::LineAngle { l, value: -std::f64::consts::FRAC_PI_4 }, None).unwrap(); assert!(solve(&mut sk).ok()); let CurveKind::Line { a, b } = sk.curves[l].kind else { panic!("line") }; let direction = sk.points[b].pos - sk.points[a].pos; assert!((direction.angle() + std::f64::consts::FRAC_PI_4).abs() < 1e-7); sk.remove_curves(&[l]); assert!(sk.constraints.is_empty()); } }