Skip to main content

brep_kernel/solvers/
sketch3d_solver.rs

1//! 3D sketch constraint solver — the free-POINT sibling of `assembly_solver`.
2//!
3//! A 3D sketch is a set of world-space points, the geometry that references
4//! them (points, lines, arcs, bezier chains, reference planes) and constraints
5//! between those points. Unlike the assembly solver, whose unknowns are the
6//! rigid-body POSES of parts, the unknowns here are the free points' `(x, y, z)`
7//! coordinates: a sketch line must stretch, so it is not a rigid body, and a
8//! point carries no rotation. What this solver shares with `assembly_solver`
9//! is everything else — the residual vocabulary (coincident, distance,
10//! point-on-plane, point-on-line, parallel, perpendicular, angle, tangent),
11//! the Levenberg-Marquardt schedule (`assembly_solver::lm_core`: the same
12//! central-difference Jacobian, the same Cholesky solve, the same λ up/down
13//! schedule and non-worsening best-max guard — copied rather than shared so
14//! the pinned assembly numerics never move under a sketch change), and the
15//! diagnostics contract (`dof` / `rank` / `redundant` / `status` /
16//! `conflicting`, plus per-point mobility from the Jacobian null space, the
17//! shape `sketch_solver::compute_diagnostics` publishes for 2D sketches).
18//!
19//! # Document
20//!
21//! `{points, geometries, constraints}`, typed as [`Sketch3dDoc`]:
22//!
23//! - a point is `{id, x, y, z, fixed?, construction?, externalReference?}`;
24//!   a `fixed` point is excluded from the unknowns (grounded);
25//! - a geometry is `{id, type, points: [ids], construction?}` with
26//!   `point = [p]`, `line = [a, b]`, `arc = [center, start, end]` (the minor
27//!   arc from start to end about the center, in the plane of the three points),
28//!   `bezier = [p0, c1, c2, p1, …]` (every 3 ids a new cubic span) and
29//!   `plane = [origin, normalTip]` (a reference plane: its normal is
30//!   `normalTip − origin`; always construction);
31//! - a constraint is `{id, type, points: [ids], value?}`, see
32//!   [`SKETCH3D_CONSTRAINT_TYPES`] for the vocabulary and the point layouts.
33//!
34//! An `arc` carries an IMPLIED equal-radius row (`‖start − center‖ =
35//! ‖end − center‖`), and a multi-span `bezier` an implied G1 row at every
36//! interior anchor (the two handles and the anchor collinear), exactly as the
37//! 2D solver's temporary `⇌` / `⏛` constraints do. Implied rows count towards
38//! rank and redundancy but are never named as conflicting.
39//!
40//! # Solve
41//!
42//! Levenberg-Marquardt on the damped normal equations over the free
43//! coordinates; the step is in the row space of the Jacobian, so an
44//! under-constrained sketch keeps its free degrees of freedom where the user
45//! left them. The solved coordinates are written back into the document and
46//! the diagnostics are returned alongside (see [`Sketch3dDiagnostics`]). A
47//! constraint set with no solution near the current configuration comes back
48//! as the least-squares best with `conflicting = true` and the violated
49//! constraints named — the editor paints them, it does not lose them.
50
51use crate::solver_linear_algebra::{row_reduce, solve_spd};
52use crate::Vec3;
53use serde::{Deserialize, Serialize};
54use serde_json::{Map, Value};
55use std::collections::HashMap;
56
57// ---------------------------------------------------------------------------
58// Tuning constants (mirroring assembly_solver's)
59// ---------------------------------------------------------------------------
60
61/// Maximum damping increases per outer iteration before abandoning the step.
62const MAX_DAMPING: usize = 16;
63/// Damping growth on a rejected step / Cholesky failure.
64const LAMBDA_UP: f64 = 8.0;
65/// Damping shrink after an accepted step.
66const LAMBDA_DOWN: f64 = 0.25;
67/// An accepted step below this (relative to scale) counts as converged.
68const STEP_REL_TOL: f64 = 1e-13;
69/// Central-difference step for the coordinate columns, times the model scale.
70const FD_STEP: f64 = 1e-6;
71/// Relative pivot tolerance for the numerical Jacobian rank.
72const RANK_REL_TOL: f64 = 1e-7;
73/// A null-space component below this (relative to the vector's max) does not
74/// count as mobility.
75const MOBILITY_TOL: f64 = 1e-6;
76/// A residual row still this far (in length units, relative to scale) from
77/// zero once the solve has come to rest is a conflict.
78const CONFLICT_REL_TOL: f64 = 1e-6;
79/// Directions shorter than this (relative to scale) carry no orientation.
80const DIR_EPS_REL: f64 = 1e-12;
81
82fn default_tolerance() -> f64 {
83    1e-10
84}
85
86fn default_max_iterations() -> usize {
87    200
88}
89
90// ---------------------------------------------------------------------------
91// Document types
92// ---------------------------------------------------------------------------
93
94/// A 3D sketch point: world coordinates plus the role flags the solver tracks.
95#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
96pub struct Sketch3dPoint {
97    /// Stable point id (a number in practice; strings are legal — keyed by
98    /// [`crate::sketch_id_key`]).
99    pub id: Value,
100    pub x: f64,
101    pub y: f64,
102    pub z: f64,
103    /// Grounded: excluded from the unknowns, never moves.
104    #[serde(default)]
105    pub fixed: bool,
106    /// Construction point: constrains but never models an edge or a vertex.
107    #[serde(default)]
108    pub construction: bool,
109    /// Adopted from a model reference (a picked vertex, an edge end, a plane
110    /// origin); re-resolved on every feature run.
111    #[serde(default, rename = "externalReference")]
112    pub external_reference: bool,
113}
114
115impl Sketch3dPoint {
116    pub fn position(&self) -> Vec3 {
117        Vec3::new(self.x, self.y, self.z)
118    }
119}
120
121/// A 3D sketch geometry (see the module doc for the point layouts).
122#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
123pub struct Sketch3dGeometry {
124    pub id: Value,
125    #[serde(rename = "type")]
126    pub geom_type: String,
127    #[serde(default)]
128    pub points: Vec<Value>,
129    /// Every field not modelled explicitly (`construction`, authoring
130    /// metadata) rides along so a load/save is lossless.
131    #[serde(flatten)]
132    pub extra: Map<String, Value>,
133}
134
135impl Sketch3dGeometry {
136    /// Whether this geometry is construction-only (never a model edge). A
137    /// reference `plane` is construction by nature.
138    pub fn construction(&self) -> bool {
139        self.geom_type == "plane"
140            || self
141                .extra
142                .get("construction")
143                .and_then(Value::as_bool)
144                .unwrap_or(false)
145    }
146}
147
148/// A 3D sketch constraint, kept as a transparent map so authoring metadata
149/// (expression text, label offsets) persists exactly.
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
151#[serde(transparent)]
152pub struct Sketch3dConstraint {
153    pub raw: Map<String, Value>,
154}
155
156impl Sketch3dConstraint {
157    pub fn ctype(&self) -> Option<&str> {
158        self.raw.get("type").and_then(Value::as_str)
159    }
160
161    pub fn id(&self) -> Option<&Value> {
162        self.raw.get("id")
163    }
164
165    pub fn points(&self) -> &[Value] {
166        self.raw
167            .get("points")
168            .and_then(Value::as_array)
169            .map(Vec::as_slice)
170            .unwrap_or(&[])
171    }
172
173    /// The dimensional value (a number; expression strings are evaluated by
174    /// the feature before the solver sees the doc).
175    pub fn value(&self) -> Option<f64> {
176        self.raw.get("value").and_then(Value::as_f64)
177    }
178}
179
180/// The editable 3D sketch document.
181#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
182pub struct Sketch3dDoc {
183    #[serde(default)]
184    pub points: Vec<Sketch3dPoint>,
185    #[serde(default)]
186    pub geometries: Vec<Sketch3dGeometry>,
187    #[serde(default)]
188    pub constraints: Vec<Sketch3dConstraint>,
189}
190
191impl Sketch3dDoc {
192    pub fn point(&self, id: &Value) -> Option<&Sketch3dPoint> {
193        let key = id_key(id);
194        self.points.iter().find(|p| id_key(&p.id) == key)
195    }
196
197    pub fn point_mut(&mut self, id: &Value) -> Option<&mut Sketch3dPoint> {
198        let key = id_key(id);
199        self.points.iter_mut().find(|p| id_key(&p.id) == key)
200    }
201
202    pub fn geometry(&self, id: &Value) -> Option<&Sketch3dGeometry> {
203        let key = id_key(id);
204        self.geometries.iter().find(|g| id_key(&g.id) == key)
205    }
206
207    pub fn geometry_mut(&mut self, id: &Value) -> Option<&mut Sketch3dGeometry> {
208        let key = id_key(id);
209        self.geometries.iter_mut().find(|g| id_key(&g.id) == key)
210    }
211
212    pub fn constraint(&self, id: &Value) -> Option<&Sketch3dConstraint> {
213        let key = id_key(id);
214        self.constraints
215            .iter()
216            .find(|c| c.id().map(id_key) == Some(key.clone()))
217    }
218
219    /// Mint the next unused POINT id (`max numeric id + 1`).
220    pub fn next_point_id(&self) -> Value {
221        mint_next_id(self.points.iter().map(|p| &p.id))
222    }
223
224    /// Mint the next unused GEOMETRY id.
225    pub fn next_geometry_id(&self) -> Value {
226        mint_next_id(self.geometries.iter().map(|g| &g.id))
227    }
228
229    /// Mint the next unused CONSTRAINT id.
230    pub fn next_constraint_id(&self) -> Value {
231        mint_next_id(self.constraints.iter().filter_map(|c| c.id()))
232    }
233
234    /// Add a free point at `p` and return its id.
235    pub fn add_point(&mut self, p: Vec3) -> Value {
236        let id = self.next_point_id();
237        self.points.push(Sketch3dPoint {
238            id: id.clone(),
239            x: p.x,
240            y: p.y,
241            z: p.z,
242            fixed: false,
243            construction: false,
244            external_reference: false,
245        });
246        id
247    }
248
249    /// Return the id of the existing point within `radius` of `p` (the nearest
250    /// one), else mint a new free point there.
251    pub fn snap_or_add_point(&mut self, p: Vec3, radius: f64) -> Value {
252        let mut best: Option<(f64, Value)> = None;
253        for point in &self.points {
254            let d = point.position().sub(p).length();
255            if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
256                best = Some((d, point.id.clone()));
257            }
258        }
259        match best {
260            Some((_, id)) => id,
261            None => self.add_point(p),
262        }
263    }
264
265    /// Add a geometry of `geom_type` over `points` and return its id.
266    pub fn add_geometry(&mut self, geom_type: &str, points: Vec<Value>, construction: bool) -> Value {
267        let id = self.next_geometry_id();
268        let mut extra = Map::new();
269        if construction {
270            extra.insert("construction".into(), Value::Bool(true));
271        }
272        self.geometries.push(Sketch3dGeometry {
273            id: id.clone(),
274            geom_type: geom_type.to_string(),
275            points,
276            extra,
277        });
278        id
279    }
280
281    /// Add a constraint and return its id. `value` is the dimension for the
282    /// dimensional types (`None` for the geometric ones).
283    pub fn add_constraint(&mut self, ctype: &str, points: Vec<Value>, value: Option<f64>) -> Value {
284        let id = self.next_constraint_id();
285        let mut raw = Map::new();
286        raw.insert("id".into(), id.clone());
287        raw.insert("type".into(), Value::String(ctype.to_string()));
288        raw.insert("points".into(), Value::Array(points));
289        if let Some(value) = value {
290            raw.insert("value".into(), Value::from(value));
291        }
292        self.constraints.push(Sketch3dConstraint { raw });
293        id
294    }
295
296    /// The point ids a geometry carries, resolved to positions (`None` when any
297    /// id is missing).
298    pub fn geometry_positions(&self, geometry: &Sketch3dGeometry) -> Option<Vec<Vec3>> {
299        geometry
300            .points
301            .iter()
302            .map(|id| self.point(id).map(Sketch3dPoint::position))
303            .collect()
304    }
305}
306
307/// Shared solver key for sketch entity and mobility-map lookups.
308pub use crate::sketch_solver::sketch_id_key as id_key;
309
310
311
312fn mint_next_id<'a>(ids: impl Iterator<Item = &'a Value>) -> Value {
313    crate::mint_sketch_id(ids, |_| false)
314}
315
316// ---------------------------------------------------------------------------
317// Constraint vocabulary
318// ---------------------------------------------------------------------------
319
320/// One constraint type: its `type` string, how many point ids it takes, whether
321/// it carries a `value`, and a one-line meaning (the editor's palette reads
322/// these; the solver dispatches on the string).
323#[derive(Clone, Copy, Debug)]
324pub struct Sketch3dConstraintType {
325    pub name: &'static str,
326    /// The point-id layout: how many ids `points` carries, in which roles.
327    pub arity: usize,
328    /// Whether the constraint carries a dimensional `value`.
329    pub dimensional: bool,
330    pub meaning: &'static str,
331}
332
333/// The constraint vocabulary. Point layouts: a LINE is its two endpoints
334/// `[a0, a1]` (direction `a1 − a0`); a PLANE is `[origin, normalTip]`
335/// (normal `normalTip − origin`); an ARC is `[center, start, end]`.
336pub const SKETCH3D_CONSTRAINT_TYPES: &[Sketch3dConstraintType] = &[
337    Sketch3dConstraintType { name: "fixed", arity: 1, dimensional: false, meaning: "ground a point where it is" },
338    Sketch3dConstraintType { name: "coincident", arity: 2, dimensional: false, meaning: "two points touch" },
339    Sketch3dConstraintType { name: "distance", arity: 2, dimensional: true, meaning: "distance between two points (a line's length)" },
340    Sketch3dConstraintType { name: "onLine", arity: 3, dimensional: false, meaning: "[p, a0, a1]: point on the infinite line a0→a1" },
341    Sketch3dConstraintType { name: "lineDistance", arity: 3, dimensional: true, meaning: "[p, a0, a1]: distance from the point to the infinite line" },
342    Sketch3dConstraintType { name: "onPlane", arity: 3, dimensional: false, meaning: "[p, origin, normalTip]: point on the plane" },
343    Sketch3dConstraintType { name: "planeDistance", arity: 3, dimensional: true, meaning: "[p, origin, normalTip]: signed height of the point above the plane" },
344    Sketch3dConstraintType { name: "parallel", arity: 4, dimensional: false, meaning: "[a0, a1, b0, b1]: the two directions are parallel (either sense)" },
345    Sketch3dConstraintType { name: "perpendicular", arity: 4, dimensional: false, meaning: "[a0, a1, b0, b1]: the two directions are perpendicular" },
346    Sketch3dConstraintType { name: "angle", arity: 4, dimensional: true, meaning: "[a0, a1, b0, b1]: angle in degrees between the two directions" },
347    Sketch3dConstraintType { name: "alongX", arity: 2, dimensional: false, meaning: "the line runs along world X" },
348    Sketch3dConstraintType { name: "alongY", arity: 2, dimensional: false, meaning: "the line runs along world Y" },
349    Sketch3dConstraintType { name: "alongZ", arity: 2, dimensional: false, meaning: "the line runs along world Z" },
350    Sketch3dConstraintType { name: "equal", arity: 4, dimensional: false, meaning: "[a0, a1, b0, b1]: equal lengths (an arc's radius is center→start)" },
351    Sketch3dConstraintType { name: "tangent", arity: 5, dimensional: false, meaning: "[a0, a1, center, start, end]: the line is tangent to the arc at the arc end it shares" },
352    Sketch3dConstraintType { name: "midpoint", arity: 3, dimensional: false, meaning: "[p, a0, a1]: the point is halfway between a0 and a1" },
353    Sketch3dConstraintType { name: "onArc", arity: 4, dimensional: false, meaning: "[p, center, start, end]: the point is on the arc's circle, in its plane, at its current radius" },
354    Sketch3dConstraintType { name: "onCircle", arity: 3, dimensional: false, meaning: "[p, center, axisTip]: the point is on the circle whose axis is center->axisTip and whose radius is that length" },
355    Sketch3dConstraintType { name: "symmetric", arity: 4, dimensional: false, meaning: "[a, b, origin, normalTip]: a and b mirror each other in the plane" },
356    Sketch3dConstraintType { name: "onCurve", arity: 5, dimensional: false, meaning: "[p, c0, c1, ...]: the point is on the cubic bezier chain with these 3n + 1 control points" },
357];
358
359/// Look up a constraint type by its `type` string.
360pub fn sketch3d_constraint_type(name: &str) -> Option<&'static Sketch3dConstraintType> {
361    SKETCH3D_CONSTRAINT_TYPES.iter().find(|t| t.name == name)
362}
363
364// ---------------------------------------------------------------------------
365// Request / options / diagnostics
366// ---------------------------------------------------------------------------
367
368#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
369pub struct Sketch3dSolveOptions {
370    /// Convergence target for the maximum residual, relative to the sketch's
371    /// length scale.
372    #[serde(default = "default_tolerance")]
373    pub tolerance: f64,
374    /// Maximum accepted Levenberg-Marquardt steps.
375    #[serde(default = "default_max_iterations")]
376    pub max_iterations: usize,
377}
378
379impl Default for Sketch3dSolveOptions {
380    fn default() -> Self {
381        Self {
382            tolerance: default_tolerance(),
383            max_iterations: default_max_iterations(),
384        }
385    }
386}
387
388/// The JSON request: the document plus optional solver options.
389#[derive(Debug, Deserialize)]
390pub struct SolveSketch3dRequest {
391    pub sketch: Value,
392    #[serde(default)]
393    pub tolerance: Option<f64>,
394    #[serde(default)]
395    pub max_iterations: Option<usize>,
396}
397
398/// The solver's read-only diagnostics: the same shape the 2D sketch solver
399/// publishes (`dof`, `rank`, `unknowns`, `equations`, `redundant`, `status`,
400/// `conflicting`, `conflictingConstraints`, `pointMobility`,
401/// `geometryMobility`), so the editor's readouts are shared.
402#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
403pub struct Sketch3dDiagnostics {
404    #[serde(default)]
405    pub dof: i64,
406    #[serde(default)]
407    pub rank: i64,
408    #[serde(default)]
409    pub unknowns: i64,
410    #[serde(default)]
411    pub equations: i64,
412    #[serde(default)]
413    pub redundant: i64,
414    /// `"well"`, `"under"` or `"over"`.
415    #[serde(default)]
416    pub status: String,
417    #[serde(default)]
418    pub conflicting: bool,
419    /// The constraints (as [`id_key`] strings) still violated at the
420    /// least-squares rest point. Empty exactly when `conflicting` is clear.
421    #[serde(default, rename = "conflictingConstraints")]
422    pub conflicting_constraints: Vec<String>,
423    /// `id_key -> "movable" | "locked"` per point.
424    #[serde(default, rename = "pointMobility")]
425    pub point_mobility: std::collections::BTreeMap<String, String>,
426    #[serde(default, rename = "geometryMobility")]
427    pub geometry_mobility: std::collections::BTreeMap<String, String>,
428    /// Accepted Levenberg-Marquardt steps.
429    #[serde(default)]
430    pub iterations: i64,
431    /// The worst residual row at the solution, in length units.
432    #[serde(default, rename = "maxResidual")]
433    pub max_residual: f64,
434}
435
436impl Sketch3dDiagnostics {
437    pub fn point_movable(&self, id: &Value) -> Option<bool> {
438        self.point_mobility.get(&id_key(id)).map(|v| v == "movable")
439    }
440
441    pub fn geometry_movable(&self, id: &Value) -> Option<bool> {
442        self.geometry_mobility.get(&id_key(id)).map(|v| v == "movable")
443    }
444
445    pub fn constraint_conflicting(&self, id: &Value) -> bool {
446        if self.conflicting_constraints.is_empty() {
447            return false;
448        }
449        let key = id_key(id);
450        self.conflicting_constraints.iter().any(|c| *c == key)
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Residual atoms over world points
456// ---------------------------------------------------------------------------
457
458/// One residual atom over point INDICES into the coordinate array. `rows()`
459/// is the number of scalar rows; `eff_rows()` the atom's rank at a solution
460/// (a 3-row cross-product block only ever removes 2 DOF).
461#[derive(Clone, Debug)]
462enum Atom {
463    /// `a − b` (3 rows, eff 3).
464    Coincide(usize, usize),
465    /// `‖a − b‖ − target` (1 row).
466    Distance(usize, usize, f64),
467    /// Component of `p − o` perpendicular to `q − o` (3 rows, eff 2): point on
468    /// the infinite line `o → q`.
469    Lateral(usize, usize, usize),
470    /// `‖(p − o) ⊥ (q − o)‖ − target` (1 row).
471    LineDistance(usize, usize, usize, f64),
472    /// `n̂·(p − o) − target` with `n̂ = unit(t − o)` (1 row).
473    PlaneOffset(usize, usize, usize, f64),
474    /// `unit(a1 − a0) × unit(b1 − b0)` (3 rows, eff 2).
475    DirCross(usize, usize, usize, usize),
476    /// `unit(a1 − a0) · unit(b1 − b0) − target` (1 row).
477    DirDot(usize, usize, usize, usize, f64),
478    /// `unit(a1 − a0) × axis` (3 rows, eff 2).
479    AxisCross(usize, usize, Vec3),
480    /// `‖a1 − a0‖ − ‖b1 − b0‖` (1 row).
481    EqualLength(usize, usize, usize, usize),
482    /// `unit(t − c) · unit(a1 − a0)` (1 row): the line is perpendicular to
483    /// the arc radius at the touch point `t`.
484    TangentTouch(usize, usize, usize, usize),
485    /// `unit(a1 − a0) · unit((s − c) × (e − c))` (1 row): the line lies in
486    /// the arc's plane.
487    InArcPlane(usize, usize, usize, usize, usize),
488    /// `p − (a + b)/2` (3 rows, eff 3).
489    Midpoint(usize, usize, usize),
490    /// `n̂·((a + b)/2 − o)`, then the part of `a − b` perpendicular to `n̂`,
491    /// with `n̂ = unit(t − o)` (4 rows, eff 3): `a` and `b` mirror in the plane.
492    Symmetric(usize, usize, usize, usize),
493    /// `p − C(t*)`, `C(t*)` the point of the cubic bezier chain nearest `p`
494    /// (3 rows, eff 2).
495    OnBezier(usize, Box<[usize]>),
496}
497
498/// The point of a cubic bezier chain nearest `target`: a coarse scan, then
499/// Newton on `(C − target) · C'` inside the winning span.
500fn nearest_on_bezier(controls: &[Vec3], target: Vec3) -> Vec3 {
501    const SCAN: usize = 16;
502    let value = |c: &[Vec3], t: f64| {
503        let u = 1.0 - t;
504        c[0].scale(u * u * u).add(c[1].scale(3.0 * u * u * t)).add(c[2].scale(3.0 * u * t * t)).add(c[3].scale(t * t * t))
505    };
506    let first = |c: &[Vec3], t: f64| {
507        let u = 1.0 - t;
508        c[1].sub(c[0]).scale(3.0 * u * u).add(c[2].sub(c[1]).scale(6.0 * u * t)).add(c[3].sub(c[2]).scale(3.0 * t * t))
509    };
510    let second = |c: &[Vec3], t: f64| {
511        let d1 = c[2].sub(c[1].scale(2.0)).add(c[0]);
512        let d2 = c[3].sub(c[2].scale(2.0)).add(c[1]);
513        d1.scale(6.0 * (1.0 - t)).add(d2.scale(6.0 * t))
514    };
515    let mut best: Option<(f64, usize, f64)> = None;
516    for span in 0..(controls.len() - 1) / 3 {
517        let c = &controls[span * 3..span * 3 + 4];
518        for i in 0..=SCAN {
519            let t = i as f64 / SCAN as f64;
520            let d = value(c, t).sub(target).length();
521            if best.map_or(true, |(bd, _, _)| d < bd) {
522                best = Some((d, span, t));
523            }
524        }
525    }
526    let Some((_, span, mut t)) = best else { return target };
527    let c = &controls[span * 3..span * 3 + 4];
528    for _ in 0..8 {
529        let offset = value(c, t).sub(target);
530        let d1 = first(c, t);
531        let denominator = d1.dot(d1) + offset.dot(second(c, t));
532        if denominator.abs() <= 1e-14 {
533            break;
534        }
535        t = (t - offset.dot(d1) / denominator).clamp(0.0, 1.0);
536    }
537    value(c, t)
538}
539
540impl Atom {
541    fn rows(&self) -> usize {
542        match self {
543            Atom::Coincide(..) | Atom::Lateral(..) | Atom::DirCross(..) | Atom::AxisCross(..) | Atom::Midpoint(..) | Atom::OnBezier(..) => 3,
544            Atom::Symmetric(..) => 4,
545            Atom::Distance(..)
546            | Atom::LineDistance(..)
547            | Atom::PlaneOffset(..)
548            | Atom::DirDot(..)
549            | Atom::EqualLength(..)
550            | Atom::TangentTouch(..)
551            | Atom::InArcPlane(..) => 1,
552        }
553    }
554
555    fn eff_rows(&self) -> usize {
556        match self {
557            Atom::Coincide(..) | Atom::Midpoint(..) | Atom::Symmetric(..) => 3,
558            Atom::Lateral(..) | Atom::DirCross(..) | Atom::AxisCross(..) | Atom::OnBezier(..) => 2,
559            _ => 1,
560        }
561    }
562
563    fn eval(&self, p: &[Vec3], dir_eps: f64, out: &mut Vec<f64>) {
564        let unit = |v: Vec3| {
565            let len = v.length();
566            if len > dir_eps {
567                v.scale(1.0 / len)
568            } else {
569                v.scale(1.0 / dir_eps)
570            }
571        };
572        match *self {
573            Atom::Coincide(a, b) => {
574                let d = p[a].sub(p[b]);
575                out.extend([d.x, d.y, d.z]);
576            }
577            Atom::Distance(a, b, target) => out.push(p[a].sub(p[b]).length() - target),
578            Atom::Lateral(pt, o, q) => {
579                let d = unit(p[q].sub(p[o]));
580                let v = p[pt].sub(p[o]);
581                let lateral = v.sub(d.scale(d.dot(v)));
582                out.extend([lateral.x, lateral.y, lateral.z]);
583            }
584            Atom::LineDistance(pt, o, q, target) => {
585                let d = unit(p[q].sub(p[o]));
586                let v = p[pt].sub(p[o]);
587                let lateral = v.sub(d.scale(d.dot(v)));
588                out.push(lateral.length() - target);
589            }
590            Atom::PlaneOffset(pt, o, t, target) => {
591                let n = unit(p[t].sub(p[o]));
592                out.push(n.dot(p[pt].sub(p[o])) - target);
593            }
594            Atom::DirCross(a0, a1, b0, b1) => {
595                let c = unit(p[a1].sub(p[a0])).cross(unit(p[b1].sub(p[b0])));
596                out.extend([c.x, c.y, c.z]);
597            }
598            Atom::DirDot(a0, a1, b0, b1, target) => {
599                out.push(unit(p[a1].sub(p[a0])).dot(unit(p[b1].sub(p[b0]))) - target);
600            }
601            Atom::AxisCross(a0, a1, axis) => {
602                let c = unit(p[a1].sub(p[a0])).cross(axis);
603                out.extend([c.x, c.y, c.z]);
604            }
605            Atom::EqualLength(a0, a1, b0, b1) => {
606                out.push(p[a1].sub(p[a0]).length() - p[b1].sub(p[b0]).length());
607            }
608            Atom::TangentTouch(a0, a1, c, t) => {
609                out.push(unit(p[t].sub(p[c])).dot(unit(p[a1].sub(p[a0]))));
610            }
611            Atom::InArcPlane(a0, a1, c, s, e) => {
612                let n = unit(p[s].sub(p[c]).cross(p[e].sub(p[c])));
613                out.push(unit(p[a1].sub(p[a0])).dot(n));
614            }
615            Atom::Midpoint(pt, a, b) => {
616                let d = p[pt].sub(p[a].add(p[b]).scale(0.5));
617                out.extend([d.x, d.y, d.z]);
618            }
619            Atom::Symmetric(a, b, o, t) => {
620                let n = unit(p[t].sub(p[o]));
621                out.push(n.dot(p[a].add(p[b]).scale(0.5).sub(p[o])));
622                let v = p[a].sub(p[b]);
623                let lateral = v.sub(n.scale(n.dot(v)));
624                out.extend([lateral.x, lateral.y, lateral.z]);
625            }
626            Atom::OnBezier(pt, ref controls) => {
627                let positions: Vec<Vec3> = controls.iter().map(|&i| p[i]).collect();
628                let d = p[pt].sub(nearest_on_bezier(&positions, p[pt]));
629                out.extend([d.x, d.y, d.z]);
630            }
631        }
632    }
633}
634
635// ---------------------------------------------------------------------------
636// Problem preparation
637// ---------------------------------------------------------------------------
638
639struct Prepared {
640    /// Every point's coordinates (fixed ones never change).
641    coords: Vec<Vec3>,
642    /// Indices of the free points, ascending.
643    free: Vec<usize>,
644    atoms: Vec<Atom>,
645    /// Atom → owning constraint index, or `None` for an implied row.
646    atom_owner: Vec<Option<usize>>,
647    m: usize,
648    effective_rows: usize,
649    scale: f64,
650    tight: f64,
651}
652
653fn prepare(doc: &Sketch3dDoc, options: &Sketch3dSolveOptions) -> Result<Prepared, String> {
654    let mut index: HashMap<String, usize> = HashMap::with_capacity(doc.points.len());
655    let mut coords: Vec<Vec3> = Vec::with_capacity(doc.points.len());
656    let mut fixed: Vec<bool> = Vec::with_capacity(doc.points.len());
657    for (i, point) in doc.points.iter().enumerate() {
658        if !(point.x.is_finite() && point.y.is_finite() && point.z.is_finite()) {
659            return Err(format!("sketch3d: point {} has a non-finite coordinate", id_key(&point.id)));
660        }
661        // A duplicate id: the later one wins (matches the 2D solver's map).
662        index.insert(id_key(&point.id), i);
663        coords.push(point.position());
664        fixed.push(point.fixed);
665    }
666    let lookup = |what: &str, id: &Value| -> Result<usize, String> {
667        index
668            .get(&id_key(id))
669            .copied()
670            .ok_or_else(|| format!("sketch3d: {what} references unknown point {}", id_key(id)))
671    };
672
673    let mut atoms: Vec<Atom> = Vec::new();
674    let mut atom_owner: Vec<Option<usize>> = Vec::new();
675
676    // User constraints.
677    for (ci, constraint) in doc.constraints.iter().enumerate() {
678        let Some(ctype) = constraint.ctype() else {
679            return Err(format!("sketch3d: constraint {ci} has no type"));
680        };
681        let spec = sketch3d_constraint_type(ctype)
682            .ok_or_else(|| format!("sketch3d: unknown constraint type '{ctype}'"))?;
683        let ids = constraint.points();
684        if ids.len() < spec.arity {
685            return Err(format!(
686                "sketch3d: constraint '{ctype}' needs {} points, got {}",
687                spec.arity,
688                ids.len()
689            ));
690        }
691        let what = format!("constraint '{ctype}'");
692        // A bezier chain takes every control point it was given.
693        let taken = if ctype == "onCurve" { ids.len() } else { spec.arity };
694        if ctype == "onCurve" && (taken - 2) % 3 != 0 {
695            return Err(format!("sketch3d: constraint 'onCurve' needs a point and 3n + 1 control points, got {taken} ids"));
696        }
697        let mut idx = Vec::with_capacity(taken);
698        for id in &ids[..taken] {
699            idx.push(lookup(&what, id)?);
700        }
701        let value = || -> Result<f64, String> {
702            constraint
703                .value()
704                .filter(|v| v.is_finite())
705                .ok_or_else(|| format!("sketch3d: constraint '{ctype}' needs a finite value"))
706        };
707        let mut push = |atom: Atom| {
708            atoms.push(atom);
709            atom_owner.push(Some(ci));
710        };
711        match ctype {
712            "fixed" => fixed[idx[0]] = true,
713            "coincident" => push(Atom::Coincide(idx[0], idx[1])),
714            "distance" => {
715                let target = value()?;
716                if target.abs() <= 0.0 {
717                    push(Atom::Coincide(idx[0], idx[1]));
718                } else {
719                    push(Atom::Distance(idx[0], idx[1], target.abs()));
720                }
721            }
722            "onLine" => push(Atom::Lateral(idx[0], idx[1], idx[2])),
723            "lineDistance" => {
724                let target = value()?;
725                if target.abs() <= 0.0 {
726                    push(Atom::Lateral(idx[0], idx[1], idx[2]));
727                } else {
728                    push(Atom::LineDistance(idx[0], idx[1], idx[2], target.abs()));
729                }
730            }
731            "onPlane" => push(Atom::PlaneOffset(idx[0], idx[1], idx[2], 0.0)),
732            "planeDistance" => push(Atom::PlaneOffset(idx[0], idx[1], idx[2], value()?)),
733            "parallel" => push(Atom::DirCross(idx[0], idx[1], idx[2], idx[3])),
734            "perpendicular" => push(Atom::DirDot(idx[0], idx[1], idx[2], idx[3], 0.0)),
735            "angle" => {
736                let target = value()?.to_radians().cos();
737                push(Atom::DirDot(idx[0], idx[1], idx[2], idx[3], target));
738            }
739            "alongX" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(1.0, 0.0, 0.0))),
740            "alongY" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(0.0, 1.0, 0.0))),
741            "alongZ" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(0.0, 0.0, 1.0))),
742            "equal" => push(Atom::EqualLength(idx[0], idx[1], idx[2], idx[3])),
743            "tangent" => {
744                // The touch point is the arc end the line shares (by id, else
745                // the nearer one): [a0, a1, center, start, end].
746                let (a0, a1, c, s, e) = (idx[0], idx[1], idx[2], idx[3], idx[4]);
747                let touch = if s == a0 || s == a1 {
748                    s
749                } else if e == a0 || e == a1 {
750                    e
751                } else {
752                    let near = |t: usize| {
753                        coords[t]
754                            .sub(coords[a0])
755                            .length()
756                            .min(coords[t].sub(coords[a1]).length())
757                    };
758                    if near(s) <= near(e) {
759                        s
760                    } else {
761                        e
762                    }
763                };
764                push(Atom::TangentTouch(a0, a1, c, touch));
765                push(Atom::InArcPlane(a0, a1, c, s, e));
766            }
767            "midpoint" => push(Atom::Midpoint(idx[0], idx[1], idx[2])),
768            "onArc" => {
769                let (pt, c, s, e) = (idx[0], idx[1], idx[2], idx[3]);
770                push(Atom::EqualLength(c, pt, c, s));
771                push(Atom::InArcPlane(c, pt, c, s, e));
772            }
773            "onCircle" => {
774                let (pt, c, tip) = (idx[0], idx[1], idx[2]);
775                push(Atom::PlaneOffset(pt, c, tip, 0.0));
776                push(Atom::EqualLength(c, pt, c, tip));
777            }
778            "symmetric" => push(Atom::Symmetric(idx[0], idx[1], idx[2], idx[3])),
779            "onCurve" => push(Atom::OnBezier(idx[0], idx[1..].to_vec().into_boxed_slice())),
780            other => return Err(format!("sketch3d: unknown constraint type '{other}'")),
781        }
782    }
783
784    // Implied rows: arc radius equality, bezier G1 at interior anchors.
785    for geometry in &doc.geometries {
786        match geometry.geom_type.as_str() {
787            "arc" if geometry.points.len() >= 3 => {
788                let what = "arc geometry";
789                let c = lookup(what, &geometry.points[0])?;
790                let s = lookup(what, &geometry.points[1])?;
791                let e = lookup(what, &geometry.points[2])?;
792                atoms.push(Atom::EqualLength(c, s, c, e));
793                atom_owner.push(None);
794            }
795            "bezier" if geometry.points.len() >= 7 => {
796                let what = "bezier geometry";
797                let ids = &geometry.points;
798                let span_count = (ids.len() - 1) / 3;
799                let last_anchor = span_count * 3;
800                let mut i = 3usize;
801                while i < last_anchor {
802                    let prev = lookup(what, &ids[i - 1])?;
803                    let anchor = lookup(what, &ids[i])?;
804                    let next = lookup(what, &ids[i + 1])?;
805                    if prev != anchor && next != anchor && prev != next {
806                        atoms.push(Atom::Lateral(anchor, prev, next));
807                        atom_owner.push(None);
808                    }
809                    i += 3;
810                }
811            }
812            _ => {}
813        }
814    }
815
816    let free: Vec<usize> = (0..coords.len()).filter(|&i| !fixed[i]).collect();
817    let m: usize = atoms.iter().map(Atom::rows).sum();
818    let effective_rows: usize = atoms.iter().map(Atom::eff_rows).sum();
819
820    let mut scale = 1.0f64;
821    for c in &coords {
822        scale = scale.max(c.x.abs()).max(c.y.abs()).max(c.z.abs());
823    }
824    let tight = options.tolerance * scale;
825    Ok(Prepared {
826        coords,
827        free,
828        atoms,
829        atom_owner,
830        m,
831        effective_rows,
832        scale,
833        tight,
834    })
835}
836
837// ---------------------------------------------------------------------------
838// Numerics (mirroring assembly_solver::numerics)
839// ---------------------------------------------------------------------------
840
841fn apply_delta(base: &[Vec3], free: &[usize], delta: &[f64], out: &mut Vec<Vec3>) {
842    out.clear();
843    out.extend_from_slice(base);
844    for (k, &i) in free.iter().enumerate() {
845        out[i] = base[i].add(Vec3::new(delta[3 * k], delta[3 * k + 1], delta[3 * k + 2]));
846    }
847}
848
849fn eval_residuals(
850    atoms: &[Atom],
851    base: &[Vec3],
852    free: &[usize],
853    delta: &[f64],
854    dir_eps: f64,
855    scratch: &mut Vec<Vec3>,
856    out: &mut Vec<f64>,
857) {
858    apply_delta(base, free, delta, scratch);
859    out.clear();
860    for atom in atoms {
861        atom.eval(scratch, dir_eps, out);
862    }
863}
864
865fn finite_diff_jacobian(
866    atoms: &[Atom],
867    base: &[Vec3],
868    free: &[usize],
869    h: f64,
870    dir_eps: f64,
871    m: usize,
872) -> Vec<Vec<f64>> {
873    let n = 3 * free.len();
874    let mut jac = vec![vec![0.0f64; n]; m];
875    if n == 0 || m == 0 {
876        return jac;
877    }
878    let mut delta = vec![0.0f64; n];
879    let mut scratch: Vec<Vec3> = Vec::with_capacity(base.len());
880    let mut rp: Vec<f64> = Vec::with_capacity(m);
881    let mut rm: Vec<f64> = Vec::with_capacity(m);
882    let inv = 1.0 / (2.0 * h);
883    for col in 0..n {
884        delta[col] = h;
885        eval_residuals(atoms, base, free, &delta, dir_eps, &mut scratch, &mut rp);
886        delta[col] = -h;
887        eval_residuals(atoms, base, free, &delta, dir_eps, &mut scratch, &mut rm);
888        delta[col] = 0.0;
889        for i in 0..m {
890            jac[i][col] = (rp[i] - rm[i]) * inv;
891        }
892    }
893    jac
894}
895
896/// Levenberg-Marquardt over the free coordinates (`assembly_solver::lm_core`'s
897/// loop with point increments in place of pose increments). On return `coords`
898/// holds the best (max-residual) iterate found. Returns the accepted steps.
899fn lm_core(prep: &Prepared, coords: &mut Vec<Vec3>, max_iterations: usize) -> Result<usize, String> {
900    let atoms = &prep.atoms;
901    let free = &prep.free;
902    let n = 3 * free.len();
903    let m = prep.m;
904    let scale = prep.scale;
905    let dir_eps = DIR_EPS_REL * scale;
906    let h = FD_STEP * scale;
907    let step_tol = STEP_REL_TOL * scale;
908    let tight = prep.tight;
909
910    let max_abs = |v: &[f64]| v.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
911    let sum_sq = |v: &[f64]| v.iter().fold(0.0f64, |acc, &x| acc + x * x);
912
913    let zeros = vec![0.0f64; n];
914    let mut scratch: Vec<Vec3> = Vec::with_capacity(coords.len());
915    let mut r: Vec<f64> = Vec::with_capacity(m);
916    eval_residuals(atoms, coords, free, &zeros, dir_eps, &mut scratch, &mut r);
917    if !max_abs(&r).is_finite() {
918        return Err("sketch3d: non-finite residual at the initial coordinates".into());
919    }
920
921    let mut iterations = 0usize;
922    let mut s = sum_sq(&r);
923    let mut best_coords = coords.clone();
924    let mut best_max = max_abs(&r);
925    let mut lambda = -1.0f64;
926
927    if n > 0 && m > 0 {
928        'outer: for _iter in 0..max_iterations {
929            if best_max <= tight {
930                break;
931            }
932            let jac = finite_diff_jacobian(atoms, coords, free, h, dir_eps, m);
933            let mut hm = vec![vec![0.0f64; n]; n];
934            let mut g = vec![0.0f64; n];
935            for i in 0..m {
936                let row = &jac[i];
937                let ri = r[i];
938                for a in 0..n {
939                    let ja = row[a];
940                    if ja == 0.0 {
941                        continue;
942                    }
943                    g[a] += ja * ri;
944                    for b in a..n {
945                        hm[a][b] += ja * row[b];
946                    }
947                }
948            }
949            for a in 0..n {
950                for b in (a + 1)..n {
951                    hm[b][a] = hm[a][b];
952                }
953            }
954            let mut max_diag = 0.0f64;
955            for a in 0..n {
956                max_diag = max_diag.max(hm[a][a]);
957            }
958            if !(max_diag > 0.0) {
959                break;
960            }
961            if lambda < 0.0 {
962                lambda = 1e-9 * max_diag;
963            }
964            let lambda_ceiling = 1e12 * max_diag;
965
966            let mut stepped = false;
967            let mut step_small = false;
968            for _try in 0..MAX_DAMPING {
969                let mut a_mat = hm.clone();
970                for d in 0..n {
971                    a_mat[d][d] += lambda;
972                }
973                let mut delta: Vec<f64> = g.iter().map(|v| -v).collect();
974                if !solve_spd(&mut a_mat, &mut delta) {
975                    lambda *= LAMBDA_UP;
976                    if lambda > lambda_ceiling {
977                        break;
978                    }
979                    continue;
980                }
981                let mut r_trial: Vec<f64> = Vec::with_capacity(m);
982                eval_residuals(atoms, coords, free, &delta, dir_eps, &mut scratch, &mut r_trial);
983                let s_trial = sum_sq(&r_trial);
984                if s_trial.is_finite() && s_trial < s {
985                    let step_norm = sum_sq(&delta).sqrt();
986                    apply_delta(coords, free, &delta, &mut scratch);
987                    coords.copy_from_slice(&scratch);
988                    eval_residuals(atoms, coords, free, &zeros, dir_eps, &mut scratch, &mut r);
989                    s = sum_sq(&r);
990                    let m_now = max_abs(&r);
991                    if m_now < best_max {
992                        best_max = m_now;
993                        best_coords.copy_from_slice(coords);
994                    }
995                    lambda = (lambda * LAMBDA_DOWN).max(1e-12 * max_diag);
996                    iterations += 1;
997                    stepped = true;
998                    step_small = step_norm <= step_tol;
999                    break;
1000                }
1001                lambda *= LAMBDA_UP;
1002                if lambda > lambda_ceiling {
1003                    break;
1004                }
1005            }
1006            if !stepped || step_small {
1007                break 'outer;
1008            }
1009        }
1010    }
1011    coords.copy_from_slice(&best_coords);
1012    Ok(iterations)
1013}
1014
1015/// Numerical rank + a null-space basis of a dense `m × n` matrix (the 2D
1016/// solver's `rank_and_nullspace`).
1017fn rank_and_nullspace(mut a: Vec<Vec<f64>>, m: usize, n: usize) -> (usize, Vec<Vec<f64>>) {
1018    if n == 0 {
1019        return (0, Vec::new());
1020    }
1021    let mut maxabs = 0.0f64;
1022    for row in &a {
1023        for &v in row {
1024            maxabs = maxabs.max(v.abs());
1025        }
1026    }
1027    let tol = if maxabs > 0.0 {
1028        RANK_REL_TOL * maxabs
1029    } else {
1030        RANK_REL_TOL
1031    };
1032    let mut pivot_cols: Vec<usize> = Vec::new();
1033    row_reduce(&mut a, m, n, tol, |col| pivot_cols.push(col));
1034    let rank = pivot_cols.len();
1035    let mut is_pivot = vec![false; n];
1036    for &pc in &pivot_cols {
1037        is_pivot[pc] = true;
1038    }
1039    let mut basis: Vec<Vec<f64>> = Vec::new();
1040    for free_col in 0..n {
1041        if is_pivot[free_col] {
1042            continue;
1043        }
1044        let mut v = vec![0.0f64; n];
1045        v[free_col] = 1.0;
1046        for (i, &pc) in pivot_cols.iter().enumerate() {
1047            v[pc] = -a[i][free_col];
1048        }
1049        basis.push(v);
1050    }
1051    (rank, basis)
1052}
1053
1054// ---------------------------------------------------------------------------
1055// Public entry points
1056// ---------------------------------------------------------------------------
1057
1058/// Solve `doc` in place: the free points take their solved coordinates and the
1059/// diagnostics describe the constraint system at that configuration.
1060pub fn solve_sketch3d_doc(
1061    doc: &mut Sketch3dDoc,
1062    options: &Sketch3dSolveOptions,
1063) -> Result<Sketch3dDiagnostics, String> {
1064    let prep = prepare(doc, options)?;
1065    let mut coords = prep.coords.clone();
1066    let iterations = lm_core(&prep, &mut coords, options.max_iterations)?;
1067
1068    let n = 3 * prep.free.len();
1069    let m = prep.m;
1070    let dir_eps = DIR_EPS_REL * prep.scale;
1071    let zeros = vec![0.0f64; n];
1072    let mut scratch: Vec<Vec3> = Vec::with_capacity(coords.len());
1073    let mut r: Vec<f64> = Vec::with_capacity(m);
1074    eval_residuals(&prep.atoms, &coords, &prep.free, &zeros, dir_eps, &mut scratch, &mut r);
1075
1076    let jac = finite_diff_jacobian(&prep.atoms, &coords, &prep.free, FD_STEP * prep.scale, dir_eps, m);
1077    // Per-row residual in LENGTH units: divide by the row's Jacobian norm so
1078    // a direction row (dimensionless) reads as the coordinate distance that
1079    // would zero it, like the 2D solver's `scaled_res`.
1080    let row_norms: Vec<f64> = jac
1081        .iter()
1082        .map(|row| row.iter().fold(0.0f64, |acc, &v| acc + v * v).sqrt())
1083        .collect();
1084    let max_row_norm = row_norms.iter().fold(0.0f64, |a, &v| a.max(v));
1085    let norm_floor = if max_row_norm > 0.0 { 1e-9 * max_row_norm } else { 1.0 };
1086    let scaled: Vec<f64> = r
1087        .iter()
1088        .zip(row_norms.iter())
1089        .map(|(&v, &norm)| v / norm.max(norm_floor))
1090        .collect();
1091    let max_residual = scaled.iter().fold(0.0f64, |acc, &v| acc.max(v.abs()));
1092
1093    let (rank, basis) = rank_and_nullspace(jac, m, n);
1094    let dof = n - rank;
1095    let redundant = prep.effective_rows.saturating_sub(rank);
1096
1097    // Conflict: a row still violated at rest names its constraint. Implied
1098    // rows (arc radius, bezier G1) are analysed but never named.
1099    let conflict_tol = CONFLICT_REL_TOL * prep.scale;
1100    let mut flagged = vec![false; doc.constraints.len()];
1101    let mut row = 0usize;
1102    for (ai, atom) in prep.atoms.iter().enumerate() {
1103        let rows = atom.rows();
1104        let violated = scaled[row..row + rows].iter().any(|v| v.abs() > conflict_tol);
1105        if violated {
1106            if let Some(owner) = prep.atom_owner[ai] {
1107                flagged[owner] = true;
1108            }
1109        }
1110        row += rows;
1111    }
1112    let mut conflicting_constraints: Vec<String> = Vec::new();
1113    for (ci, &hit) in flagged.iter().enumerate() {
1114        if hit {
1115            if let Some(id) = doc.constraints[ci].id() {
1116                conflicting_constraints.push(id_key(id));
1117            }
1118        }
1119    }
1120    // An implied row alone can be violated (an arc that cannot keep equal
1121    // radii under the user's dimensions): then the flag stands with the
1122    // geometry's constraints unnamed, so name every constraint touching a
1123    // violated implied row's points instead.
1124    if max_residual > conflict_tol && conflicting_constraints.is_empty() {
1125        let mut row = 0usize;
1126        for (ai, atom) in prep.atoms.iter().enumerate() {
1127            let rows = atom.rows();
1128            let violated = scaled[row..row + rows].iter().any(|v| v.abs() > conflict_tol);
1129            row += rows;
1130            if !violated || prep.atom_owner[ai].is_some() {
1131                continue;
1132            }
1133            for (ci, constraint) in doc.constraints.iter().enumerate() {
1134                if flagged[ci] {
1135                    continue;
1136                }
1137                if let Some(id) = constraint.id() {
1138                    flagged[ci] = true;
1139                    conflicting_constraints.push(id_key(id));
1140                }
1141            }
1142        }
1143    }
1144    let conflicting = !conflicting_constraints.is_empty();
1145
1146    let status = if redundant > 0 {
1147        "over"
1148    } else if dof > 0 {
1149        "under"
1150    } else {
1151        "well"
1152    };
1153
1154    // Mobility: a free point moves iff some null-space vector has a
1155    // component at one of its three columns.
1156    let basis_max: Vec<f64> = basis
1157        .iter()
1158        .map(|v| v.iter().fold(0.0f64, |a, &x| a.max(x.abs())).max(1e-300))
1159        .collect();
1160    let mut col_of: HashMap<usize, usize> = HashMap::with_capacity(prep.free.len());
1161    for (k, &i) in prep.free.iter().enumerate() {
1162        col_of.insert(i, k);
1163    }
1164    let mut point_mobility = std::collections::BTreeMap::new();
1165    for (i, point) in doc.points.iter().enumerate() {
1166        let movable = match col_of.get(&i) {
1167            None => false,
1168            Some(&k) => basis.iter().enumerate().any(|(b, v)| {
1169                let c = 3 * k;
1170                let comp = ((v[c] / basis_max[b]).powi(2)
1171                    + (v[c + 1] / basis_max[b]).powi(2)
1172                    + (v[c + 2] / basis_max[b]).powi(2))
1173                .sqrt();
1174                comp > MOBILITY_TOL
1175            }),
1176        };
1177        point_mobility.insert(
1178            id_key(&point.id),
1179            (if movable { "movable" } else { "locked" }).to_string(),
1180        );
1181    }
1182    let mut geometry_mobility = std::collections::BTreeMap::new();
1183    for geometry in &doc.geometries {
1184        let movable = geometry
1185            .points
1186            .iter()
1187            .any(|pid| point_mobility.get(&id_key(pid)).map(String::as_str) == Some("movable"));
1188        geometry_mobility.insert(
1189            id_key(&geometry.id),
1190            (if movable { "movable" } else { "locked" }).to_string(),
1191        );
1192    }
1193
1194    // Write the solved coordinates back.
1195    for (i, point) in doc.points.iter_mut().enumerate() {
1196        point.x = coords[i].x;
1197        point.y = coords[i].y;
1198        point.z = coords[i].z;
1199    }
1200
1201    Ok(Sketch3dDiagnostics {
1202        dof: dof as i64,
1203        rank: rank as i64,
1204        unknowns: n as i64,
1205        equations: m as i64,
1206        redundant: redundant as i64,
1207        status: status.into(),
1208        conflicting,
1209        conflicting_constraints,
1210        point_mobility,
1211        geometry_mobility,
1212        iterations: iterations as i64,
1213        max_residual,
1214    })
1215}
1216
1217/// Solve a JSON request: returns `{"sketch": {points, geometries, constraints,
1218/// diagnostics}}`, the 2D solver's response shape.
1219pub fn solve_sketch3d(request: &SolveSketch3dRequest) -> Result<Value, String> {
1220    let mut doc: Sketch3dDoc = serde_json::from_value(request.sketch.clone())
1221        .map_err(|e| format!("sketch3d: invalid sketch document: {e}"))?;
1222    let options = Sketch3dSolveOptions {
1223        tolerance: request.tolerance.unwrap_or_else(default_tolerance),
1224        max_iterations: request.max_iterations.unwrap_or_else(default_max_iterations),
1225    };
1226    let diagnostics = solve_sketch3d_doc(&mut doc, &options)?;
1227    let mut sketch = serde_json::to_value(&doc).map_err(|e| format!("sketch3d: {e}"))?;
1228    if let Some(map) = sketch.as_object_mut() {
1229        map.insert(
1230            "diagnostics".into(),
1231            serde_json::to_value(&diagnostics).map_err(|e| format!("sketch3d: {e}"))?,
1232        );
1233    }
1234    Ok(serde_json::json!({ "sketch": sketch }))
1235}
1236
1237/// Solve a JSON request given as text; the response is JSON text.
1238pub fn solve_sketch3d_from_json(request: &str) -> Result<String, String> {
1239    let request: SolveSketch3dRequest =
1240        serde_json::from_str(request).map_err(|e| format!("sketch3d: invalid request: {e}"))?;
1241    let response = solve_sketch3d(&request)?;
1242    serde_json::to_string(&response).map_err(|e| format!("sketch3d: {e}"))
1243}
1244