Skip to main content

brep_kernel/solvers/
sketch_solver.rs

1//! 2D sketch constraint solver.
2//!
3//! Port of the iterative geometric constraint engine that previously lived in
4//! the retiring app's `sketchSolver2D` (ConstraintEngine +
5//! constraintDefinitions). The numerical behaviour is kept faithful to the
6//! original implementation: same constraint residual handling, same pass
7//! ordering, same rounding (6 decimals per tidy pass), same distance-target
8//! slide/throttle semantics, and the same convergence check based on point
9//! state signatures. The rounding is the RELAXATION's: a committed (polished)
10//! solve starts its polish from the caller's own digits wherever relaxation
11//! left a coordinate on the grid, so a sketch that already satisfies its
12//! constraints comes back as it was stored rather than snapped.
13//!
14//! Constraints round-trip as JSON objects so that solver bookkeeping fields
15//! (`status`, `error`, `previousPointValues`, `_previousSolveValue`,
16//! `_distanceRequestedTarget`, `_distanceAppliedTarget`,
17//! `_distanceThrottleActive`, `_distanceLastAppliedPassToken`,
18//! `_linePointDistanceSign`) persist between solves exactly like the previous engine
19//! which mutated live objects. Internally the engine parses each constraint
20//! into a typed structure once per solve and compares point-state signatures
21//! as normalized f64 bit patterns; signature strings are only materialized
22//! when they must round-trip through JSON (`previousPointValues`). String
23//! equality of the original signatures is equivalent to bit equality here because both
24//! use shortest round-trip float formatting with -0 and NaN normalized.
25//!
26//! # Module map
27//!
28//! The solver is split into topic submodules (declared with explicit
29//! `#[path]` because this root file is itself declared via `#[path]` in
30//! `lib.rs`); this root file keeps the shared data model:
31//!
32//! - [`jsnum`] — JavaScript numeric semantics helpers (rounding, parseFloat/Number
33//!   coercions, signature bit patterns, JSON number formatting).
34//! - [`hot_constraint`] — `impl HotConstraint`: JSON parse/write-back of one
35//!   constraint record and its solver bookkeeping fields.
36//! - [`engine`] — `Engine` construction from sketch JSON, point/signature
37//!   helpers, constraint dispatch and the iterative relaxation solve loop.
38//! - [`constraints_basic`] — the original ported constraint functions
39//!   (horizontal/vertical, distance, point-line distance, equal distance,
40//!   parallel, perpendicular, angle, coincident, point-on-line, midpoint).
41//! - [`constraints_shape`] — point-based shape constraints added in Rust
42//!   (tangent incl. splines, spline curvature continuity, concentric,
43//!   equal-radius, collinear, symmetric) plus their line/spline/circle
44//!   geometry helpers.
45//! - [`constraints_foot`] — foot-parameter tangency (`∿`): the stationarity
46//!   solve that eliminates the touch parameter, its per-span roots, the
47//!   residuals and the relaxation nudges.
48//! - [`dof_polish`] — DOF diagnostics (residual atoms, finite-difference
49//!   Jacobian, rank/null-space) and the Levenberg-Marquardt exactness polish.
50//! - [`api`] — implied-duplicate constraint removal and the public
51//!   `solve_sketch` / `solve_sketch_from_json` entry points.
52//! - `tests` — the full test suite.
53
54use rustc_hash::FxHashMap as HashMap;
55use serde::Deserialize;
56use serde_json::{Map, Value};
57use std::sync::atomic::{AtomicU64, Ordering};
58
59/// Mirrors `globalDistanceSolveCycleId` from ConstraintEngine.
60static GLOBAL_DISTANCE_SOLVE_CYCLE_ID: AtomicU64 = AtomicU64::new(0);
61
62const POINT_LINE_DISTANCE_TYPE: &str = "↥";
63
64/// Solver tuning knobs. Defaults mirror constraintDefinitions.
65#[derive(Clone, Copy, Debug)]
66pub struct SketchSolverSettings {
67    pub tolerance: f64,
68    pub distance_slide_threshold_ratio: f64,
69    pub distance_slide_step_ratio: f64,
70    pub distance_slide_min_step: f64,
71    /// Run the global Levenberg-Marquardt least-squares polish after the
72    /// iterative relaxation solve so constrained geometry converges to
73    /// machine precision instead of the ~1e-5 relaxation floor. Defaults on;
74    /// callers can disable it (e.g. per interactive drag frame) via the
75    /// request's `polish` field.
76    pub newton_polish: bool,
77}
78
79impl Default for SketchSolverSettings {
80    fn default() -> Self {
81        Self {
82            tolerance: 0.00001,
83            distance_slide_threshold_ratio: 0.10,
84            distance_slide_step_ratio: 0.10,
85            distance_slide_min_step: 0.001,
86            newton_polish: true,
87        }
88    }
89}
90
91/// The reserved id of every sketch's PART ORIGIN reference point: world
92/// `(0, 0, 0)` projected onto the sketch plane. The editor adds it on entry as
93/// a fixed, grounded, construction external reference, so geometry can be
94/// constrained to the origin. The sketch feature re-projects it onto the
95/// resolved frame before solving and never publishes it — it exists only
96/// inside the sketch (no scene point, no hole center, no 3D display).
97pub const SKETCH_ORIGIN_POINT_ID: &str = "origin";
98
99/// Whether a sketch point id is the reserved [`SKETCH_ORIGIN_POINT_ID`].
100pub fn is_sketch_origin_point_id(id: &Value) -> bool {
101    id.as_str() == Some(SKETCH_ORIGIN_POINT_ID)
102}
103
104#[derive(Debug, Deserialize)]
105pub struct SolveSketchRequest {
106    pub sketch: Value,
107    #[serde(default)]
108    pub iterations: Option<u32>,
109    #[serde(default)]
110    pub remove_implied_duplicates: bool,
111    #[serde(default)]
112    pub tolerance: Option<f64>,
113    #[serde(default)]
114    pub distance_slide_threshold_ratio: Option<f64>,
115    #[serde(default)]
116    pub distance_slide_step_ratio: Option<f64>,
117    #[serde(default)]
118    pub distance_slide_min_step: Option<f64>,
119    /// Enable/disable the post-relaxation Levenberg-Marquardt polish. Absent
120    /// (`None`) keeps it enabled; interactive drag frames may pass `false` to
121    /// keep only the cheap relaxation solve. Committed solves keep it on so the
122    /// emitted geometry is exact.
123    #[serde(default)]
124    pub polish: Option<bool>,
125}
126
127// ---------------------------------------------------------------------------
128// Point identity keys (JavaScript Map SameValueZero semantics)
129// ---------------------------------------------------------------------------
130
131#[derive(Clone, Debug, PartialEq, Eq, Hash)]
132enum PointKey {
133    Num(u64),
134    Str(String),
135    Bool(bool),
136    Null,
137}
138
139fn point_key(value: &Value) -> PointKey {
140    match value {
141        Value::Number(number) => match number.as_f64() {
142            Some(float) => PointKey::Num(sig_bits(float)),
143            None => PointKey::Null,
144        },
145        Value::String(text) => PointKey::Str(text.clone()),
146        Value::Bool(flag) => PointKey::Bool(*flag),
147        _ => PointKey::Null,
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Typed constraint model
153// ---------------------------------------------------------------------------
154
155// Symbols for the constraint types added on top of the original engine.
156const TANGENT_TYPE: &str = "⌒";
157const CONCENTRIC_TYPE: &str = "◎";
158const EQUAL_RADIUS_TYPE: &str = "⊜";
159const COLLINEAR_TYPE: &str = "⋰";
160const SYMMETRIC_TYPE: &str = "⋈";
161/// Curvature (G2) continuity. The plan's WORKING glyph — the final glyph is
162/// still an open question there, and it is the stored constraint `type`, so
163/// changing it later changes saved documents.
164const SPLINE_CURVATURE_TYPE: &str = "ϰ";
165/// Tangency at a point INTERIOR TO A SPAN — the foot-parameter lane. A separate
166/// type string because `⌒` with a spline pair already MEANS anchor tangency and
167/// this constraint's point list names a spline BODY, not an (anchor, handle)
168/// pair. The plan's working glyph, an open question the same way `ϰ`'s is.
169const SPLINE_FOOT_TANGENT_TYPE: &str = "∿";
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
172enum CType {
173    Horizontal,
174    Vertical,
175    Distance,
176    PointLine,
177    EqualDistance,
178    Parallel,
179    Perpendicular,
180    Angle,
181    Coincident,
182    PointOnLine,
183    Midpoint,
184    Ground,
185    // New point-based constraints (all CAD math lives here in Rust).
186    Tangent,
187    /// Curvature (G2) continuity at a spline joint, or between a spline end and
188    /// a circle/arc. Emitted rows always INCLUDE the tangency (G1) the join
189    /// needs, so "curvature continuous" implies "tangent" the way every
190    /// mainstream CAD UX reads it.
191    SplineCurvature,
192    /// Tangency between a spline and a line or circle/arc at a point INTERIOR
193    /// to one of the spline's spans. The touch parameter is not a solver
194    /// unknown: it is eliminated by projection (the foot of the stationarity
195    /// condition) and the residual is evaluated there, so the constraint is one
196    /// row like every other tangency.
197    SplineFootTangent,
198    Concentric,
199    EqualRadius,
200    Collinear,
201    Symmetric,
202    #[default]
203    Other,
204}
205
206fn ctype_from(type_str: &str) -> CType {
207    match type_str {
208        "━" => CType::Horizontal,
209        "│" => CType::Vertical,
210        "⟺" => CType::Distance,
211        POINT_LINE_DISTANCE_TYPE => CType::PointLine,
212        "⇌" => CType::EqualDistance,
213        "∥" => CType::Parallel,
214        "⟂" => CType::Perpendicular,
215        "∠" => CType::Angle,
216        "≡" => CType::Coincident,
217        "⏛" => CType::PointOnLine,
218        "⋯" => CType::Midpoint,
219        "⏚" => CType::Ground,
220        TANGENT_TYPE => CType::Tangent,
221        SPLINE_CURVATURE_TYPE => CType::SplineCurvature,
222        SPLINE_FOOT_TANGENT_TYPE => CType::SplineFootTangent,
223        CONCENTRIC_TYPE => CType::Concentric,
224        EQUAL_RADIUS_TYPE => CType::EqualRadius,
225        COLLINEAR_TYPE => CType::Collinear,
226        SYMMETRIC_TYPE => CType::Symmetric,
227        _ => CType::Other,
228    }
229}
230
231fn is_distance_ctype(ctype: CType) -> bool {
232    matches!(ctype, CType::Distance | CType::PointLine)
233}
234
235/// Spline geometry kind. The app's sketcher stores a spline as geometry
236/// `type: "bezier"`: a chained cubic Bézier CONTROL POLYGON (verified against
237/// SketchMode3D — the sampler evaluates cubic Bernstein polynomials over
238/// `points[3k..3k+3]`), NOT an interpolating through-point spline. `points`
239/// holds `3*segCount + 1` point ids; on-curve anchors sit at indices 0, 3, 6,
240/// …, and the ids between them are off-curve handles. Every one of those
241/// points is an ordinary sketch point, so it participates in constraints, the
242/// relaxation solve, the Newton polish and the DOF diagnostics exactly like a
243/// free point. `"spline"` is accepted as an alias so a future app-side rename
244/// keeps working.
245fn is_spline_geometry_type(type_str: &str) -> bool {
246    type_str == "bezier" || type_str == "spline"
247}
248
249/// One slot of a point-state signature (None = missing point → "null;").
250type SigEntry = Option<(u64, u64, bool)>;
251
252/// `_previousSolveValue` as seen through JavaScript strict-equality semantics.
253#[derive(Clone, Copy, Debug, Default)]
254enum PrevSolve {
255    #[default]
256    Missing,
257    /// Numeric stored value; JSON null maps to NaN (a NaN that
258    /// round-tripped through JSON as null).
259    Num(f64),
260    /// Any other stored type: strict equality with a number is always false.
261    OtherType,
262}
263
264#[derive(Clone, Debug, Default)]
265enum PrevPoints {
266    /// Field absent in the input (JavaScript `undefined`).
267    #[default]
268    Missing,
269    /// Field present but not a string (can never match a signature).
270    PresentNonString,
271    /// Signature string from the input JSON.
272    Str(String),
273    /// Signature written during this solve: bits for fast comparison plus the
274    /// materialized string for JSON output.
275    Bits(Vec<SigEntry>, String),
276}
277
278#[derive(Debug, Default)]
279struct HotConstraint {
280    /// Original JSON object; hot fields are written back on output.
281    raw: Map<String, Value>,
282    ctype: CType,
283    /// Raw `type` string (None when missing or not a string).
284    type_str: Option<String>,
285    /// Raw point id values (mutated only by the ∠ negative-value swap).
286    point_ids: Vec<Value>,
287    /// Resolved indices into the engine point list (parallel to point_ids).
288    point_idx: Vec<Option<usize>>,
289    points_written: bool,
290    temporary: bool,
291
292    // --- value ---
293    /// Overridden numeric value (set when the engine writes `value`).
294    value_num: Option<f64>,
295    /// parseFloat cache of the effective value.
296    value_parsed: f64,
297    /// Whether the effective value is null/undefined (∠ seeding check).
298    value_nullish: bool,
299    /// Number-coercion cache of the effective value (⇌ lookups).
300    value_cv: f64,
301    value_written: bool,
302
303    // --- status / error ---
304    status_solved: bool,
305    status_written: bool,
306    error: Option<Value>,
307    error_written: bool,
308
309    // --- solve bookkeeping ---
310    prev_solve: PrevSolve,
311    prev_solve_written: bool,
312    prev_points: PrevPoints,
313    prev_points_written: bool,
314
315    // --- distance throttle state ---
316    /// Number.isFinite view of `_distanceRequestedTarget`.
317    req_target: Option<f64>,
318    req_target_written: bool,
319    /// Number.isFinite view of `_distanceAppliedTarget`.
320    app_target: Option<f64>,
321    app_target_written: bool,
322    /// Strict `=== true` view of `_distanceThrottleActive`.
323    throttle_true: bool,
324    /// Truthiness view of `_distanceThrottleActive` (differs from strict for
325    /// odd input values until first write).
326    throttle_truthy: bool,
327    throttle_written: bool,
328    /// String view of `_distanceLastAppliedPassToken`.
329    pass_token: Option<String>,
330    pass_token_written: bool,
331    /// Number() view of `_linePointDistanceSign` (missing → NaN, null → 0).
332    line_sign: f64,
333    line_sign_written: bool,
334    /// `_splineFootT`: the foot-parameter tangency's touch point as
335    /// `span + t` in the spline's own chain parameter. The SEED the next solve's
336    /// foot solve starts from and the span index it freezes, so the touch point
337    /// is stable across solves and across the relaxation-only frames an
338    /// interactive drag runs. `None` when the field is absent or not finite —
339    /// which is the FIRST solve of a freshly applied constraint, and is why the
340    /// foot solve has a no-seed path (a coarse scan) at all.
341    foot_t: Option<f64>,
342    foot_t_written: bool,
343}
344
345enum Filter<'a> {
346    All,
347    Type(CType, &'a str),
348}
349
350// ---------------------------------------------------------------------------
351// Engine
352// ---------------------------------------------------------------------------
353
354#[derive(Clone, Debug)]
355struct EnginePoint {
356    id: Value,
357    x: f64,
358    y: f64,
359    fixed: bool,
360    construction: bool,
361    external_reference: bool,
362}
363
364type CResult = Result<Value, String>;
365
366struct Engine {
367    points: Vec<EnginePoint>,
368    /// `point_key` → index into `points`. Built once in [`Engine::new`] and valid
369    /// for the whole solve: a solve moves points and flips their `fixed` flag but
370    /// never adds, removes or renames one. Constraint point lists are resolved
371    /// through it at parse time; the geometry lookups that resolve a control
372    /// point's id DURING the solve (a curvature side's second control, a spline
373    /// body's whole polygon) go through it too, because a linear scan per control
374    /// per pass is quadratic in the sketch.
375    point_index: HashMap<PointKey, usize>,
376    geometries: Vec<Value>,
377    constraints: Vec<HotConstraint>,
378    settings: SketchSolverSettings,
379    pass_token: String,
380    sig_scratch_before: Vec<SigEntry>,
381    sig_scratch_after: Vec<SigEntry>,
382}
383
384#[path = "sketch_solver/jsnum.rs"]
385mod jsnum;
386pub use jsnum::fmt_id as sketch_id_key;
387#[path = "sketch_solver/hot_constraint.rs"]
388mod hot_constraint;
389#[path = "sketch_solver/engine.rs"]
390mod engine;
391#[path = "sketch_solver/constraints_basic.rs"]
392mod constraints_basic;
393#[path = "sketch_solver/constraints_shape.rs"]
394mod constraints_shape;
395#[path = "sketch_solver/constraints_foot.rs"]
396mod constraints_foot;
397#[path = "sketch_solver/dof_polish.rs"]
398mod dof_polish;
399#[path = "sketch_solver/api.rs"]
400mod api;
401
402// ---------------------------------------------------------------------------
403// Tests
404// ---------------------------------------------------------------------------
405
406
407use constraints_foot::{
408    foot_in_span, solve_spline_foot, FootTarget, SpanControls, FOOT_EVAL_MARGIN,
409};
410use constraints_shape::{
411    circle_curvature_residual, circle_curvature_sign, joint_curvature_residual,
412    spline_end_curvature,
413};
414use jsnum::*;
415
416pub use api::{solve_sketch, solve_sketch_from_json};
417
418/// Mint a collision-free sketch ID, with caller-selected reserved IDs omitted.
419pub fn mint_sketch_id<'a>(ids: impl Iterator<Item = &'a Value>, skip: impl Fn(&Value) -> bool) -> Value {
420    let ids: Vec<&Value> = ids.filter(|id| !skip(id)).collect();
421    let mut max: Option<i64> = None;
422    for &id in &ids {
423        if let Some(n) = id_num(id) {
424            if n.is_finite() {
425                let i = n.floor() as i64;
426                max = Some(max.map_or(i, |m| m.max(i)));
427            }
428        }
429    }
430    let mut candidate = max.map(|m| m + 1).unwrap_or(ids.len() as i64);
431    let existing: std::collections::HashSet<String> = ids.iter().map(|&id| sketch_id_key(id)).collect();
432    while existing.contains(&sketch_id_key(&Value::from(candidate))) {
433        candidate += 1;
434    }
435    Value::from(candidate)
436}
437
438fn id_num(value: &Value) -> Option<f64> {
439    match value {
440        Value::Number(number) => number.as_f64(),
441        Value::String(text) => text.trim().parse::<f64>().ok(),
442        _ => None,
443    }
444}