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#[derive(Debug, Deserialize)]
92pub struct SolveSketchRequest {
93    pub sketch: Value,
94    #[serde(default)]
95    pub iterations: Option<u32>,
96    #[serde(default)]
97    pub remove_implied_duplicates: bool,
98    #[serde(default)]
99    pub tolerance: Option<f64>,
100    #[serde(default)]
101    pub distance_slide_threshold_ratio: Option<f64>,
102    #[serde(default)]
103    pub distance_slide_step_ratio: Option<f64>,
104    #[serde(default)]
105    pub distance_slide_min_step: Option<f64>,
106    /// Enable/disable the post-relaxation Levenberg-Marquardt polish. Absent
107    /// (`None`) keeps it enabled; interactive drag frames may pass `false` to
108    /// keep only the cheap relaxation solve. Committed solves keep it on so the
109    /// emitted geometry is exact.
110    #[serde(default)]
111    pub polish: Option<bool>,
112}
113
114// ---------------------------------------------------------------------------
115// Point identity keys (JavaScript Map SameValueZero semantics)
116// ---------------------------------------------------------------------------
117
118#[derive(Clone, Debug, PartialEq, Eq, Hash)]
119enum PointKey {
120    Num(u64),
121    Str(String),
122    Bool(bool),
123    Null,
124}
125
126fn point_key(value: &Value) -> PointKey {
127    match value {
128        Value::Number(number) => match number.as_f64() {
129            Some(float) => PointKey::Num(sig_bits(float)),
130            None => PointKey::Null,
131        },
132        Value::String(text) => PointKey::Str(text.clone()),
133        Value::Bool(flag) => PointKey::Bool(*flag),
134        _ => PointKey::Null,
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Typed constraint model
140// ---------------------------------------------------------------------------
141
142// Symbols for the constraint types added on top of the original engine.
143const TANGENT_TYPE: &str = "⌒";
144const CONCENTRIC_TYPE: &str = "◎";
145const EQUAL_RADIUS_TYPE: &str = "⊜";
146const COLLINEAR_TYPE: &str = "⋰";
147const SYMMETRIC_TYPE: &str = "⋈";
148/// Curvature (G2) continuity. The plan's WORKING glyph — the final glyph is
149/// still an open question there, and it is the stored constraint `type`, so
150/// changing it later changes saved documents.
151const SPLINE_CURVATURE_TYPE: &str = "ϰ";
152/// Tangency at a point INTERIOR TO A SPAN — the foot-parameter lane. A separate
153/// type string because `⌒` with a spline pair already MEANS anchor tangency and
154/// this constraint's point list names a spline BODY, not an (anchor, handle)
155/// pair. The plan's working glyph, an open question the same way `ϰ`'s is.
156const SPLINE_FOOT_TANGENT_TYPE: &str = "∿";
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
159enum CType {
160    Horizontal,
161    Vertical,
162    Distance,
163    PointLine,
164    EqualDistance,
165    Parallel,
166    Perpendicular,
167    Angle,
168    Coincident,
169    PointOnLine,
170    Midpoint,
171    Ground,
172    // New point-based constraints (all CAD math lives here in Rust).
173    Tangent,
174    /// Curvature (G2) continuity at a spline joint, or between a spline end and
175    /// a circle/arc. Emitted rows always INCLUDE the tangency (G1) the join
176    /// needs, so "curvature continuous" implies "tangent" the way every
177    /// mainstream CAD UX reads it.
178    SplineCurvature,
179    /// Tangency between a spline and a line or circle/arc at a point INTERIOR
180    /// to one of the spline's spans. The touch parameter is not a solver
181    /// unknown: it is eliminated by projection (the foot of the stationarity
182    /// condition) and the residual is evaluated there, so the constraint is one
183    /// row like every other tangency.
184    SplineFootTangent,
185    Concentric,
186    EqualRadius,
187    Collinear,
188    Symmetric,
189    #[default]
190    Other,
191}
192
193fn ctype_from(type_str: &str) -> CType {
194    match type_str {
195        "━" => CType::Horizontal,
196        "│" => CType::Vertical,
197        "⟺" => CType::Distance,
198        POINT_LINE_DISTANCE_TYPE => CType::PointLine,
199        "⇌" => CType::EqualDistance,
200        "∥" => CType::Parallel,
201        "⟂" => CType::Perpendicular,
202        "∠" => CType::Angle,
203        "≡" => CType::Coincident,
204        "⏛" => CType::PointOnLine,
205        "⋯" => CType::Midpoint,
206        "⏚" => CType::Ground,
207        TANGENT_TYPE => CType::Tangent,
208        SPLINE_CURVATURE_TYPE => CType::SplineCurvature,
209        SPLINE_FOOT_TANGENT_TYPE => CType::SplineFootTangent,
210        CONCENTRIC_TYPE => CType::Concentric,
211        EQUAL_RADIUS_TYPE => CType::EqualRadius,
212        COLLINEAR_TYPE => CType::Collinear,
213        SYMMETRIC_TYPE => CType::Symmetric,
214        _ => CType::Other,
215    }
216}
217
218fn is_distance_ctype(ctype: CType) -> bool {
219    matches!(ctype, CType::Distance | CType::PointLine)
220}
221
222/// Spline geometry kind. The app's sketcher stores a spline as geometry
223/// `type: "bezier"`: a chained cubic Bézier CONTROL POLYGON (verified against
224/// SketchMode3D — the sampler evaluates cubic Bernstein polynomials over
225/// `points[3k..3k+3]`), NOT an interpolating through-point spline. `points`
226/// holds `3*segCount + 1` point ids; on-curve anchors sit at indices 0, 3, 6,
227/// …, and the ids between them are off-curve handles. Every one of those
228/// points is an ordinary sketch point, so it participates in constraints, the
229/// relaxation solve, the Newton polish and the DOF diagnostics exactly like a
230/// free point. `"spline"` is accepted as an alias so a future app-side rename
231/// keeps working.
232fn is_spline_geometry_type(type_str: &str) -> bool {
233    type_str == "bezier" || type_str == "spline"
234}
235
236/// One slot of a point-state signature (None = missing point → "null;").
237type SigEntry = Option<(u64, u64, bool)>;
238
239/// `_previousSolveValue` as seen through JavaScript strict-equality semantics.
240#[derive(Clone, Copy, Debug, Default)]
241enum PrevSolve {
242    #[default]
243    Missing,
244    /// Numeric stored value; JSON null maps to NaN (a NaN that
245    /// round-tripped through JSON as null).
246    Num(f64),
247    /// Any other stored type: strict equality with a number is always false.
248    OtherType,
249}
250
251#[derive(Clone, Debug, Default)]
252enum PrevPoints {
253    /// Field absent in the input (JavaScript `undefined`).
254    #[default]
255    Missing,
256    /// Field present but not a string (can never match a signature).
257    PresentNonString,
258    /// Signature string from the input JSON.
259    Str(String),
260    /// Signature written during this solve: bits for fast comparison plus the
261    /// materialized string for JSON output.
262    Bits(Vec<SigEntry>, String),
263}
264
265#[derive(Debug, Default)]
266struct HotConstraint {
267    /// Original JSON object; hot fields are written back on output.
268    raw: Map<String, Value>,
269    ctype: CType,
270    /// Raw `type` string (None when missing or not a string).
271    type_str: Option<String>,
272    /// Raw point id values (mutated only by the ∠ negative-value swap).
273    point_ids: Vec<Value>,
274    /// Resolved indices into the engine point list (parallel to point_ids).
275    point_idx: Vec<Option<usize>>,
276    points_written: bool,
277    temporary: bool,
278
279    // --- value ---
280    /// Overridden numeric value (set when the engine writes `value`).
281    value_num: Option<f64>,
282    /// parseFloat cache of the effective value.
283    value_parsed: f64,
284    /// Whether the effective value is null/undefined (∠ seeding check).
285    value_nullish: bool,
286    /// Number-coercion cache of the effective value (⇌ lookups).
287    value_cv: f64,
288    value_written: bool,
289
290    // --- status / error ---
291    status_solved: bool,
292    status_written: bool,
293    error: Option<Value>,
294    error_written: bool,
295
296    // --- solve bookkeeping ---
297    prev_solve: PrevSolve,
298    prev_solve_written: bool,
299    prev_points: PrevPoints,
300    prev_points_written: bool,
301
302    // --- distance throttle state ---
303    /// Number.isFinite view of `_distanceRequestedTarget`.
304    req_target: Option<f64>,
305    req_target_written: bool,
306    /// Number.isFinite view of `_distanceAppliedTarget`.
307    app_target: Option<f64>,
308    app_target_written: bool,
309    /// Strict `=== true` view of `_distanceThrottleActive`.
310    throttle_true: bool,
311    /// Truthiness view of `_distanceThrottleActive` (differs from strict for
312    /// odd input values until first write).
313    throttle_truthy: bool,
314    throttle_written: bool,
315    /// String view of `_distanceLastAppliedPassToken`.
316    pass_token: Option<String>,
317    pass_token_written: bool,
318    /// Number() view of `_linePointDistanceSign` (missing → NaN, null → 0).
319    line_sign: f64,
320    line_sign_written: bool,
321    /// `_splineFootT`: the foot-parameter tangency's touch point as
322    /// `span + t` in the spline's own chain parameter. The SEED the next solve's
323    /// foot solve starts from and the span index it freezes, so the touch point
324    /// is stable across solves and across the relaxation-only frames an
325    /// interactive drag runs. `None` when the field is absent or not finite —
326    /// which is the FIRST solve of a freshly applied constraint, and is why the
327    /// foot solve has a no-seed path (a coarse scan) at all.
328    foot_t: Option<f64>,
329    foot_t_written: bool,
330}
331
332enum Filter<'a> {
333    All,
334    Type(CType, &'a str),
335}
336
337// ---------------------------------------------------------------------------
338// Engine
339// ---------------------------------------------------------------------------
340
341#[derive(Clone, Debug)]
342struct EnginePoint {
343    id: Value,
344    x: f64,
345    y: f64,
346    fixed: bool,
347    construction: bool,
348    external_reference: bool,
349}
350
351type CResult = Result<Value, String>;
352
353struct Engine {
354    points: Vec<EnginePoint>,
355    /// `point_key` → index into `points`. Built once in [`Engine::new`] and valid
356    /// for the whole solve: a solve moves points and flips their `fixed` flag but
357    /// never adds, removes or renames one. Constraint point lists are resolved
358    /// through it at parse time; the geometry lookups that resolve a control
359    /// point's id DURING the solve (a curvature side's second control, a spline
360    /// body's whole polygon) go through it too, because a linear scan per control
361    /// per pass is quadratic in the sketch.
362    point_index: HashMap<PointKey, usize>,
363    geometries: Vec<Value>,
364    constraints: Vec<HotConstraint>,
365    settings: SketchSolverSettings,
366    pass_token: String,
367    sig_scratch_before: Vec<SigEntry>,
368    sig_scratch_after: Vec<SigEntry>,
369}
370
371#[path = "sketch_solver/jsnum.rs"]
372mod jsnum;
373pub use jsnum::fmt_id as sketch_id_key;
374#[path = "sketch_solver/hot_constraint.rs"]
375mod hot_constraint;
376#[path = "sketch_solver/engine.rs"]
377mod engine;
378#[path = "sketch_solver/constraints_basic.rs"]
379mod constraints_basic;
380#[path = "sketch_solver/constraints_shape.rs"]
381mod constraints_shape;
382#[path = "sketch_solver/constraints_foot.rs"]
383mod constraints_foot;
384#[path = "sketch_solver/dof_polish.rs"]
385mod dof_polish;
386#[path = "sketch_solver/api.rs"]
387mod api;
388
389// ---------------------------------------------------------------------------
390// Tests
391// ---------------------------------------------------------------------------
392
393
394use constraints_foot::{
395    foot_in_span, solve_spline_foot, FootTarget, SpanControls, FOOT_EVAL_MARGIN,
396};
397use constraints_shape::{
398    circle_curvature_residual, circle_curvature_sign, joint_curvature_residual,
399    spline_end_curvature,
400};
401use jsnum::*;
402
403pub use api::{solve_sketch, solve_sketch_from_json};