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];
353
354/// Look up a constraint type by its `type` string.
355pub fn sketch3d_constraint_type(name: &str) -> Option<&'static Sketch3dConstraintType> {
356    SKETCH3D_CONSTRAINT_TYPES.iter().find(|t| t.name == name)
357}
358
359// ---------------------------------------------------------------------------
360// Request / options / diagnostics
361// ---------------------------------------------------------------------------
362
363#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
364pub struct Sketch3dSolveOptions {
365    /// Convergence target for the maximum residual, relative to the sketch's
366    /// length scale.
367    #[serde(default = "default_tolerance")]
368    pub tolerance: f64,
369    /// Maximum accepted Levenberg-Marquardt steps.
370    #[serde(default = "default_max_iterations")]
371    pub max_iterations: usize,
372}
373
374impl Default for Sketch3dSolveOptions {
375    fn default() -> Self {
376        Self {
377            tolerance: default_tolerance(),
378            max_iterations: default_max_iterations(),
379        }
380    }
381}
382
383/// The JSON request: the document plus optional solver options.
384#[derive(Debug, Deserialize)]
385pub struct SolveSketch3dRequest {
386    pub sketch: Value,
387    #[serde(default)]
388    pub tolerance: Option<f64>,
389    #[serde(default)]
390    pub max_iterations: Option<usize>,
391}
392
393/// The solver's read-only diagnostics: the same shape the 2D sketch solver
394/// publishes (`dof`, `rank`, `unknowns`, `equations`, `redundant`, `status`,
395/// `conflicting`, `conflictingConstraints`, `pointMobility`,
396/// `geometryMobility`), so the editor's readouts are shared.
397#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
398pub struct Sketch3dDiagnostics {
399    #[serde(default)]
400    pub dof: i64,
401    #[serde(default)]
402    pub rank: i64,
403    #[serde(default)]
404    pub unknowns: i64,
405    #[serde(default)]
406    pub equations: i64,
407    #[serde(default)]
408    pub redundant: i64,
409    /// `"well"`, `"under"` or `"over"`.
410    #[serde(default)]
411    pub status: String,
412    #[serde(default)]
413    pub conflicting: bool,
414    /// The constraints (as [`id_key`] strings) still violated at the
415    /// least-squares rest point. Empty exactly when `conflicting` is clear.
416    #[serde(default, rename = "conflictingConstraints")]
417    pub conflicting_constraints: Vec<String>,
418    /// `id_key -> "movable" | "locked"` per point.
419    #[serde(default, rename = "pointMobility")]
420    pub point_mobility: std::collections::BTreeMap<String, String>,
421    #[serde(default, rename = "geometryMobility")]
422    pub geometry_mobility: std::collections::BTreeMap<String, String>,
423    /// Accepted Levenberg-Marquardt steps.
424    #[serde(default)]
425    pub iterations: i64,
426    /// The worst residual row at the solution, in length units.
427    #[serde(default, rename = "maxResidual")]
428    pub max_residual: f64,
429}
430
431impl Sketch3dDiagnostics {
432    pub fn point_movable(&self, id: &Value) -> Option<bool> {
433        self.point_mobility.get(&id_key(id)).map(|v| v == "movable")
434    }
435
436    pub fn geometry_movable(&self, id: &Value) -> Option<bool> {
437        self.geometry_mobility.get(&id_key(id)).map(|v| v == "movable")
438    }
439
440    pub fn constraint_conflicting(&self, id: &Value) -> bool {
441        if self.conflicting_constraints.is_empty() {
442            return false;
443        }
444        let key = id_key(id);
445        self.conflicting_constraints.iter().any(|c| *c == key)
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Residual atoms over world points
451// ---------------------------------------------------------------------------
452
453/// One residual atom over point INDICES into the coordinate array. `rows()`
454/// is the number of scalar rows; `eff_rows()` the atom's rank at a solution
455/// (a 3-row cross-product block only ever removes 2 DOF).
456#[derive(Clone, Copy, Debug)]
457enum Atom {
458    /// `a − b` (3 rows, eff 3).
459    Coincide(usize, usize),
460    /// `‖a − b‖ − target` (1 row).
461    Distance(usize, usize, f64),
462    /// Component of `p − o` perpendicular to `q − o` (3 rows, eff 2): point on
463    /// the infinite line `o → q`.
464    Lateral(usize, usize, usize),
465    /// `‖(p − o) ⊥ (q − o)‖ − target` (1 row).
466    LineDistance(usize, usize, usize, f64),
467    /// `n̂·(p − o) − target` with `n̂ = unit(t − o)` (1 row).
468    PlaneOffset(usize, usize, usize, f64),
469    /// `unit(a1 − a0) × unit(b1 − b0)` (3 rows, eff 2).
470    DirCross(usize, usize, usize, usize),
471    /// `unit(a1 − a0) · unit(b1 − b0) − target` (1 row).
472    DirDot(usize, usize, usize, usize, f64),
473    /// `unit(a1 − a0) × axis` (3 rows, eff 2).
474    AxisCross(usize, usize, Vec3),
475    /// `‖a1 − a0‖ − ‖b1 − b0‖` (1 row).
476    EqualLength(usize, usize, usize, usize),
477    /// `unit(t − c) · unit(a1 − a0)` (1 row): the line is perpendicular to
478    /// the arc radius at the touch point `t`.
479    TangentTouch(usize, usize, usize, usize),
480    /// `unit(a1 − a0) · unit((s − c) × (e − c))` (1 row): the line lies in
481    /// the arc's plane.
482    InArcPlane(usize, usize, usize, usize, usize),
483}
484
485impl Atom {
486    fn rows(&self) -> usize {
487        match self {
488            Atom::Coincide(..) | Atom::Lateral(..) | Atom::DirCross(..) | Atom::AxisCross(..) => 3,
489            Atom::Distance(..)
490            | Atom::LineDistance(..)
491            | Atom::PlaneOffset(..)
492            | Atom::DirDot(..)
493            | Atom::EqualLength(..)
494            | Atom::TangentTouch(..)
495            | Atom::InArcPlane(..) => 1,
496        }
497    }
498
499    fn eff_rows(&self) -> usize {
500        match self {
501            Atom::Coincide(..) => 3,
502            Atom::Lateral(..) | Atom::DirCross(..) | Atom::AxisCross(..) => 2,
503            _ => 1,
504        }
505    }
506
507    fn eval(&self, p: &[Vec3], dir_eps: f64, out: &mut Vec<f64>) {
508        let unit = |v: Vec3| {
509            let len = v.length();
510            if len > dir_eps {
511                v.scale(1.0 / len)
512            } else {
513                v.scale(1.0 / dir_eps)
514            }
515        };
516        match *self {
517            Atom::Coincide(a, b) => {
518                let d = p[a].sub(p[b]);
519                out.extend([d.x, d.y, d.z]);
520            }
521            Atom::Distance(a, b, target) => out.push(p[a].sub(p[b]).length() - target),
522            Atom::Lateral(pt, o, q) => {
523                let d = unit(p[q].sub(p[o]));
524                let v = p[pt].sub(p[o]);
525                let lateral = v.sub(d.scale(d.dot(v)));
526                out.extend([lateral.x, lateral.y, lateral.z]);
527            }
528            Atom::LineDistance(pt, o, q, target) => {
529                let d = unit(p[q].sub(p[o]));
530                let v = p[pt].sub(p[o]);
531                let lateral = v.sub(d.scale(d.dot(v)));
532                out.push(lateral.length() - target);
533            }
534            Atom::PlaneOffset(pt, o, t, target) => {
535                let n = unit(p[t].sub(p[o]));
536                out.push(n.dot(p[pt].sub(p[o])) - target);
537            }
538            Atom::DirCross(a0, a1, b0, b1) => {
539                let c = unit(p[a1].sub(p[a0])).cross(unit(p[b1].sub(p[b0])));
540                out.extend([c.x, c.y, c.z]);
541            }
542            Atom::DirDot(a0, a1, b0, b1, target) => {
543                out.push(unit(p[a1].sub(p[a0])).dot(unit(p[b1].sub(p[b0]))) - target);
544            }
545            Atom::AxisCross(a0, a1, axis) => {
546                let c = unit(p[a1].sub(p[a0])).cross(axis);
547                out.extend([c.x, c.y, c.z]);
548            }
549            Atom::EqualLength(a0, a1, b0, b1) => {
550                out.push(p[a1].sub(p[a0]).length() - p[b1].sub(p[b0]).length());
551            }
552            Atom::TangentTouch(a0, a1, c, t) => {
553                out.push(unit(p[t].sub(p[c])).dot(unit(p[a1].sub(p[a0]))));
554            }
555            Atom::InArcPlane(a0, a1, c, s, e) => {
556                let n = unit(p[s].sub(p[c]).cross(p[e].sub(p[c])));
557                out.push(unit(p[a1].sub(p[a0])).dot(n));
558            }
559        }
560    }
561}
562
563// ---------------------------------------------------------------------------
564// Problem preparation
565// ---------------------------------------------------------------------------
566
567struct Prepared {
568    /// Every point's coordinates (fixed ones never change).
569    coords: Vec<Vec3>,
570    /// Indices of the free points, ascending.
571    free: Vec<usize>,
572    atoms: Vec<Atom>,
573    /// Atom → owning constraint index, or `None` for an implied row.
574    atom_owner: Vec<Option<usize>>,
575    m: usize,
576    effective_rows: usize,
577    scale: f64,
578    tight: f64,
579}
580
581fn prepare(doc: &Sketch3dDoc, options: &Sketch3dSolveOptions) -> Result<Prepared, String> {
582    let mut index: HashMap<String, usize> = HashMap::with_capacity(doc.points.len());
583    let mut coords: Vec<Vec3> = Vec::with_capacity(doc.points.len());
584    let mut fixed: Vec<bool> = Vec::with_capacity(doc.points.len());
585    for (i, point) in doc.points.iter().enumerate() {
586        if !(point.x.is_finite() && point.y.is_finite() && point.z.is_finite()) {
587            return Err(format!("sketch3d: point {} has a non-finite coordinate", id_key(&point.id)));
588        }
589        // A duplicate id: the later one wins (matches the 2D solver's map).
590        index.insert(id_key(&point.id), i);
591        coords.push(point.position());
592        fixed.push(point.fixed);
593    }
594    let lookup = |what: &str, id: &Value| -> Result<usize, String> {
595        index
596            .get(&id_key(id))
597            .copied()
598            .ok_or_else(|| format!("sketch3d: {what} references unknown point {}", id_key(id)))
599    };
600
601    let mut atoms: Vec<Atom> = Vec::new();
602    let mut atom_owner: Vec<Option<usize>> = Vec::new();
603
604    // User constraints.
605    for (ci, constraint) in doc.constraints.iter().enumerate() {
606        let Some(ctype) = constraint.ctype() else {
607            return Err(format!("sketch3d: constraint {ci} has no type"));
608        };
609        let spec = sketch3d_constraint_type(ctype)
610            .ok_or_else(|| format!("sketch3d: unknown constraint type '{ctype}'"))?;
611        let ids = constraint.points();
612        if ids.len() < spec.arity {
613            return Err(format!(
614                "sketch3d: constraint '{ctype}' needs {} points, got {}",
615                spec.arity,
616                ids.len()
617            ));
618        }
619        let what = format!("constraint '{ctype}'");
620        let mut idx = Vec::with_capacity(spec.arity);
621        for id in &ids[..spec.arity] {
622            idx.push(lookup(&what, id)?);
623        }
624        let value = || -> Result<f64, String> {
625            constraint
626                .value()
627                .filter(|v| v.is_finite())
628                .ok_or_else(|| format!("sketch3d: constraint '{ctype}' needs a finite value"))
629        };
630        let mut push = |atom: Atom| {
631            atoms.push(atom);
632            atom_owner.push(Some(ci));
633        };
634        match ctype {
635            "fixed" => fixed[idx[0]] = true,
636            "coincident" => push(Atom::Coincide(idx[0], idx[1])),
637            "distance" => {
638                let target = value()?;
639                if target.abs() <= 0.0 {
640                    push(Atom::Coincide(idx[0], idx[1]));
641                } else {
642                    push(Atom::Distance(idx[0], idx[1], target.abs()));
643                }
644            }
645            "onLine" => push(Atom::Lateral(idx[0], idx[1], idx[2])),
646            "lineDistance" => {
647                let target = value()?;
648                if target.abs() <= 0.0 {
649                    push(Atom::Lateral(idx[0], idx[1], idx[2]));
650                } else {
651                    push(Atom::LineDistance(idx[0], idx[1], idx[2], target.abs()));
652                }
653            }
654            "onPlane" => push(Atom::PlaneOffset(idx[0], idx[1], idx[2], 0.0)),
655            "planeDistance" => push(Atom::PlaneOffset(idx[0], idx[1], idx[2], value()?)),
656            "parallel" => push(Atom::DirCross(idx[0], idx[1], idx[2], idx[3])),
657            "perpendicular" => push(Atom::DirDot(idx[0], idx[1], idx[2], idx[3], 0.0)),
658            "angle" => {
659                let target = value()?.to_radians().cos();
660                push(Atom::DirDot(idx[0], idx[1], idx[2], idx[3], target));
661            }
662            "alongX" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(1.0, 0.0, 0.0))),
663            "alongY" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(0.0, 1.0, 0.0))),
664            "alongZ" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(0.0, 0.0, 1.0))),
665            "equal" => push(Atom::EqualLength(idx[0], idx[1], idx[2], idx[3])),
666            "tangent" => {
667                // The touch point is the arc end the line shares (by id, else
668                // the nearer one): [a0, a1, center, start, end].
669                let (a0, a1, c, s, e) = (idx[0], idx[1], idx[2], idx[3], idx[4]);
670                let touch = if s == a0 || s == a1 {
671                    s
672                } else if e == a0 || e == a1 {
673                    e
674                } else {
675                    let near = |t: usize| {
676                        coords[t]
677                            .sub(coords[a0])
678                            .length()
679                            .min(coords[t].sub(coords[a1]).length())
680                    };
681                    if near(s) <= near(e) {
682                        s
683                    } else {
684                        e
685                    }
686                };
687                push(Atom::TangentTouch(a0, a1, c, touch));
688                push(Atom::InArcPlane(a0, a1, c, s, e));
689            }
690            other => return Err(format!("sketch3d: unknown constraint type '{other}'")),
691        }
692    }
693
694    // Implied rows: arc radius equality, bezier G1 at interior anchors.
695    for geometry in &doc.geometries {
696        match geometry.geom_type.as_str() {
697            "arc" if geometry.points.len() >= 3 => {
698                let what = "arc geometry";
699                let c = lookup(what, &geometry.points[0])?;
700                let s = lookup(what, &geometry.points[1])?;
701                let e = lookup(what, &geometry.points[2])?;
702                atoms.push(Atom::EqualLength(c, s, c, e));
703                atom_owner.push(None);
704            }
705            "bezier" if geometry.points.len() >= 7 => {
706                let what = "bezier geometry";
707                let ids = &geometry.points;
708                let span_count = (ids.len() - 1) / 3;
709                let last_anchor = span_count * 3;
710                let mut i = 3usize;
711                while i < last_anchor {
712                    let prev = lookup(what, &ids[i - 1])?;
713                    let anchor = lookup(what, &ids[i])?;
714                    let next = lookup(what, &ids[i + 1])?;
715                    if prev != anchor && next != anchor && prev != next {
716                        atoms.push(Atom::Lateral(anchor, prev, next));
717                        atom_owner.push(None);
718                    }
719                    i += 3;
720                }
721            }
722            _ => {}
723        }
724    }
725
726    let free: Vec<usize> = (0..coords.len()).filter(|&i| !fixed[i]).collect();
727    let m: usize = atoms.iter().map(Atom::rows).sum();
728    let effective_rows: usize = atoms.iter().map(Atom::eff_rows).sum();
729
730    let mut scale = 1.0f64;
731    for c in &coords {
732        scale = scale.max(c.x.abs()).max(c.y.abs()).max(c.z.abs());
733    }
734    let tight = options.tolerance * scale;
735    Ok(Prepared {
736        coords,
737        free,
738        atoms,
739        atom_owner,
740        m,
741        effective_rows,
742        scale,
743        tight,
744    })
745}
746
747// ---------------------------------------------------------------------------
748// Numerics (mirroring assembly_solver::numerics)
749// ---------------------------------------------------------------------------
750
751fn apply_delta(base: &[Vec3], free: &[usize], delta: &[f64], out: &mut Vec<Vec3>) {
752    out.clear();
753    out.extend_from_slice(base);
754    for (k, &i) in free.iter().enumerate() {
755        out[i] = base[i].add(Vec3::new(delta[3 * k], delta[3 * k + 1], delta[3 * k + 2]));
756    }
757}
758
759fn eval_residuals(
760    atoms: &[Atom],
761    base: &[Vec3],
762    free: &[usize],
763    delta: &[f64],
764    dir_eps: f64,
765    scratch: &mut Vec<Vec3>,
766    out: &mut Vec<f64>,
767) {
768    apply_delta(base, free, delta, scratch);
769    out.clear();
770    for atom in atoms {
771        atom.eval(scratch, dir_eps, out);
772    }
773}
774
775fn finite_diff_jacobian(
776    atoms: &[Atom],
777    base: &[Vec3],
778    free: &[usize],
779    h: f64,
780    dir_eps: f64,
781    m: usize,
782) -> Vec<Vec<f64>> {
783    let n = 3 * free.len();
784    let mut jac = vec![vec![0.0f64; n]; m];
785    if n == 0 || m == 0 {
786        return jac;
787    }
788    let mut delta = vec![0.0f64; n];
789    let mut scratch: Vec<Vec3> = Vec::with_capacity(base.len());
790    let mut rp: Vec<f64> = Vec::with_capacity(m);
791    let mut rm: Vec<f64> = Vec::with_capacity(m);
792    let inv = 1.0 / (2.0 * h);
793    for col in 0..n {
794        delta[col] = h;
795        eval_residuals(atoms, base, free, &delta, dir_eps, &mut scratch, &mut rp);
796        delta[col] = -h;
797        eval_residuals(atoms, base, free, &delta, dir_eps, &mut scratch, &mut rm);
798        delta[col] = 0.0;
799        for i in 0..m {
800            jac[i][col] = (rp[i] - rm[i]) * inv;
801        }
802    }
803    jac
804}
805
806/// Levenberg-Marquardt over the free coordinates (`assembly_solver::lm_core`'s
807/// loop with point increments in place of pose increments). On return `coords`
808/// holds the best (max-residual) iterate found. Returns the accepted steps.
809fn lm_core(prep: &Prepared, coords: &mut Vec<Vec3>, max_iterations: usize) -> Result<usize, String> {
810    let atoms = &prep.atoms;
811    let free = &prep.free;
812    let n = 3 * free.len();
813    let m = prep.m;
814    let scale = prep.scale;
815    let dir_eps = DIR_EPS_REL * scale;
816    let h = FD_STEP * scale;
817    let step_tol = STEP_REL_TOL * scale;
818    let tight = prep.tight;
819
820    let max_abs = |v: &[f64]| v.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
821    let sum_sq = |v: &[f64]| v.iter().fold(0.0f64, |acc, &x| acc + x * x);
822
823    let zeros = vec![0.0f64; n];
824    let mut scratch: Vec<Vec3> = Vec::with_capacity(coords.len());
825    let mut r: Vec<f64> = Vec::with_capacity(m);
826    eval_residuals(atoms, coords, free, &zeros, dir_eps, &mut scratch, &mut r);
827    if !max_abs(&r).is_finite() {
828        return Err("sketch3d: non-finite residual at the initial coordinates".into());
829    }
830
831    let mut iterations = 0usize;
832    let mut s = sum_sq(&r);
833    let mut best_coords = coords.clone();
834    let mut best_max = max_abs(&r);
835    let mut lambda = -1.0f64;
836
837    if n > 0 && m > 0 {
838        'outer: for _iter in 0..max_iterations {
839            if best_max <= tight {
840                break;
841            }
842            let jac = finite_diff_jacobian(atoms, coords, free, h, dir_eps, m);
843            let mut hm = vec![vec![0.0f64; n]; n];
844            let mut g = vec![0.0f64; n];
845            for i in 0..m {
846                let row = &jac[i];
847                let ri = r[i];
848                for a in 0..n {
849                    let ja = row[a];
850                    if ja == 0.0 {
851                        continue;
852                    }
853                    g[a] += ja * ri;
854                    for b in a..n {
855                        hm[a][b] += ja * row[b];
856                    }
857                }
858            }
859            for a in 0..n {
860                for b in (a + 1)..n {
861                    hm[b][a] = hm[a][b];
862                }
863            }
864            let mut max_diag = 0.0f64;
865            for a in 0..n {
866                max_diag = max_diag.max(hm[a][a]);
867            }
868            if !(max_diag > 0.0) {
869                break;
870            }
871            if lambda < 0.0 {
872                lambda = 1e-9 * max_diag;
873            }
874            let lambda_ceiling = 1e12 * max_diag;
875
876            let mut stepped = false;
877            let mut step_small = false;
878            for _try in 0..MAX_DAMPING {
879                let mut a_mat = hm.clone();
880                for d in 0..n {
881                    a_mat[d][d] += lambda;
882                }
883                let mut delta: Vec<f64> = g.iter().map(|v| -v).collect();
884                if !solve_spd(&mut a_mat, &mut delta) {
885                    lambda *= LAMBDA_UP;
886                    if lambda > lambda_ceiling {
887                        break;
888                    }
889                    continue;
890                }
891                let mut r_trial: Vec<f64> = Vec::with_capacity(m);
892                eval_residuals(atoms, coords, free, &delta, dir_eps, &mut scratch, &mut r_trial);
893                let s_trial = sum_sq(&r_trial);
894                if s_trial.is_finite() && s_trial < s {
895                    let step_norm = sum_sq(&delta).sqrt();
896                    apply_delta(coords, free, &delta, &mut scratch);
897                    coords.copy_from_slice(&scratch);
898                    eval_residuals(atoms, coords, free, &zeros, dir_eps, &mut scratch, &mut r);
899                    s = sum_sq(&r);
900                    let m_now = max_abs(&r);
901                    if m_now < best_max {
902                        best_max = m_now;
903                        best_coords.copy_from_slice(coords);
904                    }
905                    lambda = (lambda * LAMBDA_DOWN).max(1e-12 * max_diag);
906                    iterations += 1;
907                    stepped = true;
908                    step_small = step_norm <= step_tol;
909                    break;
910                }
911                lambda *= LAMBDA_UP;
912                if lambda > lambda_ceiling {
913                    break;
914                }
915            }
916            if !stepped || step_small {
917                break 'outer;
918            }
919        }
920    }
921    coords.copy_from_slice(&best_coords);
922    Ok(iterations)
923}
924
925/// Numerical rank + a null-space basis of a dense `m × n` matrix (the 2D
926/// solver's `rank_and_nullspace`).
927fn rank_and_nullspace(mut a: Vec<Vec<f64>>, m: usize, n: usize) -> (usize, Vec<Vec<f64>>) {
928    if n == 0 {
929        return (0, Vec::new());
930    }
931    let mut maxabs = 0.0f64;
932    for row in &a {
933        for &v in row {
934            maxabs = maxabs.max(v.abs());
935        }
936    }
937    let tol = if maxabs > 0.0 {
938        RANK_REL_TOL * maxabs
939    } else {
940        RANK_REL_TOL
941    };
942    let mut pivot_cols: Vec<usize> = Vec::new();
943    row_reduce(&mut a, m, n, tol, |col| pivot_cols.push(col));
944    let rank = pivot_cols.len();
945    let mut is_pivot = vec![false; n];
946    for &pc in &pivot_cols {
947        is_pivot[pc] = true;
948    }
949    let mut basis: Vec<Vec<f64>> = Vec::new();
950    for free_col in 0..n {
951        if is_pivot[free_col] {
952            continue;
953        }
954        let mut v = vec![0.0f64; n];
955        v[free_col] = 1.0;
956        for (i, &pc) in pivot_cols.iter().enumerate() {
957            v[pc] = -a[i][free_col];
958        }
959        basis.push(v);
960    }
961    (rank, basis)
962}
963
964// ---------------------------------------------------------------------------
965// Public entry points
966// ---------------------------------------------------------------------------
967
968/// Solve `doc` in place: the free points take their solved coordinates and the
969/// diagnostics describe the constraint system at that configuration.
970pub fn solve_sketch3d_doc(
971    doc: &mut Sketch3dDoc,
972    options: &Sketch3dSolveOptions,
973) -> Result<Sketch3dDiagnostics, String> {
974    let prep = prepare(doc, options)?;
975    let mut coords = prep.coords.clone();
976    let iterations = lm_core(&prep, &mut coords, options.max_iterations)?;
977
978    let n = 3 * prep.free.len();
979    let m = prep.m;
980    let dir_eps = DIR_EPS_REL * prep.scale;
981    let zeros = vec![0.0f64; n];
982    let mut scratch: Vec<Vec3> = Vec::with_capacity(coords.len());
983    let mut r: Vec<f64> = Vec::with_capacity(m);
984    eval_residuals(&prep.atoms, &coords, &prep.free, &zeros, dir_eps, &mut scratch, &mut r);
985
986    let jac = finite_diff_jacobian(&prep.atoms, &coords, &prep.free, FD_STEP * prep.scale, dir_eps, m);
987    // Per-row residual in LENGTH units: divide by the row's Jacobian norm so
988    // a direction row (dimensionless) reads as the coordinate distance that
989    // would zero it, like the 2D solver's `scaled_res`.
990    let row_norms: Vec<f64> = jac
991        .iter()
992        .map(|row| row.iter().fold(0.0f64, |acc, &v| acc + v * v).sqrt())
993        .collect();
994    let max_row_norm = row_norms.iter().fold(0.0f64, |a, &v| a.max(v));
995    let norm_floor = if max_row_norm > 0.0 { 1e-9 * max_row_norm } else { 1.0 };
996    let scaled: Vec<f64> = r
997        .iter()
998        .zip(row_norms.iter())
999        .map(|(&v, &norm)| v / norm.max(norm_floor))
1000        .collect();
1001    let max_residual = scaled.iter().fold(0.0f64, |acc, &v| acc.max(v.abs()));
1002
1003    let (rank, basis) = rank_and_nullspace(jac, m, n);
1004    let dof = n - rank;
1005    let redundant = prep.effective_rows.saturating_sub(rank);
1006
1007    // Conflict: a row still violated at rest names its constraint. Implied
1008    // rows (arc radius, bezier G1) are analysed but never named.
1009    let conflict_tol = CONFLICT_REL_TOL * prep.scale;
1010    let mut flagged = vec![false; doc.constraints.len()];
1011    let mut row = 0usize;
1012    for (ai, atom) in prep.atoms.iter().enumerate() {
1013        let rows = atom.rows();
1014        let violated = scaled[row..row + rows].iter().any(|v| v.abs() > conflict_tol);
1015        if violated {
1016            if let Some(owner) = prep.atom_owner[ai] {
1017                flagged[owner] = true;
1018            }
1019        }
1020        row += rows;
1021    }
1022    let mut conflicting_constraints: Vec<String> = Vec::new();
1023    for (ci, &hit) in flagged.iter().enumerate() {
1024        if hit {
1025            if let Some(id) = doc.constraints[ci].id() {
1026                conflicting_constraints.push(id_key(id));
1027            }
1028        }
1029    }
1030    // An implied row alone can be violated (an arc that cannot keep equal
1031    // radii under the user's dimensions): then the flag stands with the
1032    // geometry's constraints unnamed, so name every constraint touching a
1033    // violated implied row's points instead.
1034    if max_residual > conflict_tol && conflicting_constraints.is_empty() {
1035        let mut row = 0usize;
1036        for (ai, atom) in prep.atoms.iter().enumerate() {
1037            let rows = atom.rows();
1038            let violated = scaled[row..row + rows].iter().any(|v| v.abs() > conflict_tol);
1039            row += rows;
1040            if !violated || prep.atom_owner[ai].is_some() {
1041                continue;
1042            }
1043            for (ci, constraint) in doc.constraints.iter().enumerate() {
1044                if flagged[ci] {
1045                    continue;
1046                }
1047                if let Some(id) = constraint.id() {
1048                    flagged[ci] = true;
1049                    conflicting_constraints.push(id_key(id));
1050                }
1051            }
1052        }
1053    }
1054    let conflicting = !conflicting_constraints.is_empty();
1055
1056    let status = if redundant > 0 {
1057        "over"
1058    } else if dof > 0 {
1059        "under"
1060    } else {
1061        "well"
1062    };
1063
1064    // Mobility: a free point moves iff some null-space vector has a
1065    // component at one of its three columns.
1066    let basis_max: Vec<f64> = basis
1067        .iter()
1068        .map(|v| v.iter().fold(0.0f64, |a, &x| a.max(x.abs())).max(1e-300))
1069        .collect();
1070    let mut col_of: HashMap<usize, usize> = HashMap::with_capacity(prep.free.len());
1071    for (k, &i) in prep.free.iter().enumerate() {
1072        col_of.insert(i, k);
1073    }
1074    let mut point_mobility = std::collections::BTreeMap::new();
1075    for (i, point) in doc.points.iter().enumerate() {
1076        let movable = match col_of.get(&i) {
1077            None => false,
1078            Some(&k) => basis.iter().enumerate().any(|(b, v)| {
1079                let c = 3 * k;
1080                let comp = ((v[c] / basis_max[b]).powi(2)
1081                    + (v[c + 1] / basis_max[b]).powi(2)
1082                    + (v[c + 2] / basis_max[b]).powi(2))
1083                .sqrt();
1084                comp > MOBILITY_TOL
1085            }),
1086        };
1087        point_mobility.insert(
1088            id_key(&point.id),
1089            (if movable { "movable" } else { "locked" }).to_string(),
1090        );
1091    }
1092    let mut geometry_mobility = std::collections::BTreeMap::new();
1093    for geometry in &doc.geometries {
1094        let movable = geometry
1095            .points
1096            .iter()
1097            .any(|pid| point_mobility.get(&id_key(pid)).map(String::as_str) == Some("movable"));
1098        geometry_mobility.insert(
1099            id_key(&geometry.id),
1100            (if movable { "movable" } else { "locked" }).to_string(),
1101        );
1102    }
1103
1104    // Write the solved coordinates back.
1105    for (i, point) in doc.points.iter_mut().enumerate() {
1106        point.x = coords[i].x;
1107        point.y = coords[i].y;
1108        point.z = coords[i].z;
1109    }
1110
1111    Ok(Sketch3dDiagnostics {
1112        dof: dof as i64,
1113        rank: rank as i64,
1114        unknowns: n as i64,
1115        equations: m as i64,
1116        redundant: redundant as i64,
1117        status: status.into(),
1118        conflicting,
1119        conflicting_constraints,
1120        point_mobility,
1121        geometry_mobility,
1122        iterations: iterations as i64,
1123        max_residual,
1124    })
1125}
1126
1127/// Solve a JSON request: returns `{"sketch": {points, geometries, constraints,
1128/// diagnostics}}`, the 2D solver's response shape.
1129pub fn solve_sketch3d(request: &SolveSketch3dRequest) -> Result<Value, String> {
1130    let mut doc: Sketch3dDoc = serde_json::from_value(request.sketch.clone())
1131        .map_err(|e| format!("sketch3d: invalid sketch document: {e}"))?;
1132    let options = Sketch3dSolveOptions {
1133        tolerance: request.tolerance.unwrap_or_else(default_tolerance),
1134        max_iterations: request.max_iterations.unwrap_or_else(default_max_iterations),
1135    };
1136    let diagnostics = solve_sketch3d_doc(&mut doc, &options)?;
1137    let mut sketch = serde_json::to_value(&doc).map_err(|e| format!("sketch3d: {e}"))?;
1138    if let Some(map) = sketch.as_object_mut() {
1139        map.insert(
1140            "diagnostics".into(),
1141            serde_json::to_value(&diagnostics).map_err(|e| format!("sketch3d: {e}"))?,
1142        );
1143    }
1144    Ok(serde_json::json!({ "sketch": sketch }))
1145}
1146
1147/// Solve a JSON request given as text; the response is JSON text.
1148pub fn solve_sketch3d_from_json(request: &str) -> Result<String, String> {
1149    let request: SolveSketch3dRequest =
1150        serde_json::from_str(request).map_err(|e| format!("sketch3d: invalid request: {e}"))?;
1151    let response = solve_sketch3d(&request)?;
1152    serde_json::to_string(&response).map_err(|e| format!("sketch3d: {e}"))
1153}
1154