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.
10//!
11//! Constraints round-trip as JSON objects so that solver bookkeeping fields
12//! (`status`, `error`, `previousPointValues`, `_previousSolveValue`,
13//! `_distanceRequestedTarget`, `_distanceAppliedTarget`,
14//! `_distanceThrottleActive`, `_distanceLastAppliedPassToken`,
15//! `_linePointDistanceSign`) persist between solves exactly like the previous engine
16//! which mutated live objects. Internally the engine parses each constraint
17//! into a typed structure once per solve and compares point-state signatures
18//! as normalized f64 bit patterns; signature strings are only materialized
19//! when they must round-trip through JSON (`previousPointValues`). String
20//! equality of the original signatures is equivalent to bit equality here because both
21//! use shortest round-trip float formatting with -0 and NaN normalized.
22//!
23//! # Module map
24//!
25//! The solver is split into topic submodules (declared with explicit
26//! `#[path]` because this root file is itself declared via `#[path]` in
27//! `lib.rs`); this root file keeps the shared data model:
28//!
29//! - [`jsnum`] — JavaScript numeric semantics helpers (rounding, parseFloat/Number
30//!   coercions, signature bit patterns, JSON number formatting).
31//! - [`hot_constraint`] — `impl HotConstraint`: JSON parse/write-back of one
32//!   constraint record and its solver bookkeeping fields.
33//! - [`engine`] — `Engine` construction from sketch JSON, point/signature
34//!   helpers, constraint dispatch and the iterative relaxation solve loop.
35//! - [`constraints_basic`] — the original ported constraint functions
36//!   (horizontal/vertical, distance, point-line distance, equal distance,
37//!   parallel, perpendicular, angle, coincident, point-on-line, midpoint).
38//! - [`constraints_shape`] — point-based shape constraints added in Rust
39//!   (tangent incl. splines, concentric, equal-radius, collinear, symmetric)
40//!   plus their line/spline/circle geometry helpers.
41//! - [`dof_polish`] — DOF diagnostics (residual atoms, finite-difference
42//!   Jacobian, rank/null-space) and the Levenberg-Marquardt exactness polish.
43//! - [`api`] — implied-duplicate constraint removal and the public
44//!   `solve_sketch` / `solve_sketch_from_json` entry points.
45//! - `tests` — the full test suite.
46
47use rustc_hash::FxHashMap as HashMap;
48use serde::Deserialize;
49use serde_json::{Map, Value};
50use std::sync::atomic::{AtomicU64, Ordering};
51
52/// Mirrors `globalDistanceSolveCycleId` from ConstraintEngine.
53static GLOBAL_DISTANCE_SOLVE_CYCLE_ID: AtomicU64 = AtomicU64::new(0);
54
55const POINT_LINE_DISTANCE_TYPE: &str = "↥";
56
57/// Solver tuning knobs. Defaults mirror constraintDefinitions.
58#[derive(Clone, Copy, Debug)]
59pub struct SketchSolverSettings {
60    pub tolerance: f64,
61    pub distance_slide_threshold_ratio: f64,
62    pub distance_slide_step_ratio: f64,
63    pub distance_slide_min_step: f64,
64    /// Run the global Levenberg-Marquardt least-squares polish after the
65    /// iterative relaxation solve so constrained geometry converges to
66    /// machine precision instead of the ~1e-5 relaxation floor. Defaults on;
67    /// callers can disable it (e.g. per interactive drag frame) via the
68    /// request's `polish` field.
69    pub newton_polish: bool,
70}
71
72impl Default for SketchSolverSettings {
73    fn default() -> Self {
74        Self {
75            tolerance: 0.00001,
76            distance_slide_threshold_ratio: 0.10,
77            distance_slide_step_ratio: 0.10,
78            distance_slide_min_step: 0.001,
79            newton_polish: true,
80        }
81    }
82}
83
84#[derive(Debug, Deserialize)]
85pub struct SolveSketchRequest {
86    pub sketch: Value,
87    #[serde(default)]
88    pub iterations: Option<u32>,
89    #[serde(default)]
90    pub remove_implied_duplicates: bool,
91    #[serde(default)]
92    pub tolerance: Option<f64>,
93    #[serde(default)]
94    pub distance_slide_threshold_ratio: Option<f64>,
95    #[serde(default)]
96    pub distance_slide_step_ratio: Option<f64>,
97    #[serde(default)]
98    pub distance_slide_min_step: Option<f64>,
99    /// Enable/disable the post-relaxation Levenberg-Marquardt polish. Absent
100    /// (`None`) keeps it enabled; interactive drag frames may pass `false` to
101    /// keep only the cheap relaxation solve. Committed solves keep it on so the
102    /// emitted geometry is exact.
103    #[serde(default)]
104    pub polish: Option<bool>,
105}
106
107// ---------------------------------------------------------------------------
108// Point identity keys (JavaScript Map SameValueZero semantics)
109// ---------------------------------------------------------------------------
110
111#[derive(Clone, Debug, PartialEq, Eq, Hash)]
112enum PointKey {
113    Num(u64),
114    Str(String),
115    Bool(bool),
116    Null,
117}
118
119fn point_key(value: &Value) -> PointKey {
120    match value {
121        Value::Number(number) => match number.as_f64() {
122            Some(float) => PointKey::Num(sig_bits(float)),
123            None => PointKey::Null,
124        },
125        Value::String(text) => PointKey::Str(text.clone()),
126        Value::Bool(flag) => PointKey::Bool(*flag),
127        _ => PointKey::Null,
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Typed constraint model
133// ---------------------------------------------------------------------------
134
135// Symbols for the constraint types added on top of the original engine.
136const TANGENT_TYPE: &str = "⌒";
137const CONCENTRIC_TYPE: &str = "◎";
138const EQUAL_RADIUS_TYPE: &str = "⊜";
139const COLLINEAR_TYPE: &str = "⋰";
140const SYMMETRIC_TYPE: &str = "⋈";
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
143enum CType {
144    Horizontal,
145    Vertical,
146    Distance,
147    PointLine,
148    EqualDistance,
149    Parallel,
150    Perpendicular,
151    Angle,
152    Coincident,
153    PointOnLine,
154    Midpoint,
155    Ground,
156    // New point-based constraints (all CAD math lives here in Rust).
157    Tangent,
158    Concentric,
159    EqualRadius,
160    Collinear,
161    Symmetric,
162    #[default]
163    Other,
164}
165
166fn ctype_from(type_str: &str) -> CType {
167    match type_str {
168        "━" => CType::Horizontal,
169        "│" => CType::Vertical,
170        "⟺" => CType::Distance,
171        POINT_LINE_DISTANCE_TYPE => CType::PointLine,
172        "⇌" => CType::EqualDistance,
173        "∥" => CType::Parallel,
174        "⟂" => CType::Perpendicular,
175        "∠" => CType::Angle,
176        "≡" => CType::Coincident,
177        "⏛" => CType::PointOnLine,
178        "⋯" => CType::Midpoint,
179        "⏚" => CType::Ground,
180        TANGENT_TYPE => CType::Tangent,
181        CONCENTRIC_TYPE => CType::Concentric,
182        EQUAL_RADIUS_TYPE => CType::EqualRadius,
183        COLLINEAR_TYPE => CType::Collinear,
184        SYMMETRIC_TYPE => CType::Symmetric,
185        _ => CType::Other,
186    }
187}
188
189fn is_distance_ctype(ctype: CType) -> bool {
190    matches!(ctype, CType::Distance | CType::PointLine)
191}
192
193/// Spline geometry kind. The app's sketcher stores a spline as geometry
194/// `type: "bezier"`: a chained cubic Bézier CONTROL POLYGON (verified against
195/// SketchMode3D — the sampler evaluates cubic Bernstein polynomials over
196/// `points[3k..3k+3]`), NOT an interpolating through-point spline. `points`
197/// holds `3*segCount + 1` point ids; on-curve anchors sit at indices 0, 3, 6,
198/// …, and the ids between them are off-curve handles. Every one of those
199/// points is an ordinary sketch point, so it participates in constraints, the
200/// relaxation solve, the Newton polish and the DOF diagnostics exactly like a
201/// free point. `"spline"` is accepted as an alias so a future app-side rename
202/// keeps working.
203fn is_spline_geometry_type(type_str: &str) -> bool {
204    type_str == "bezier" || type_str == "spline"
205}
206
207/// One slot of a point-state signature (None = missing point → "null;").
208type SigEntry = Option<(u64, u64, bool)>;
209
210/// `_previousSolveValue` as seen through JavaScript strict-equality semantics.
211#[derive(Clone, Copy, Debug, Default)]
212enum PrevSolve {
213    #[default]
214    Missing,
215    /// Numeric stored value; JSON null maps to NaN (a NaN that
216    /// round-tripped through JSON as null).
217    Num(f64),
218    /// Any other stored type: strict equality with a number is always false.
219    OtherType,
220}
221
222#[derive(Clone, Debug, Default)]
223enum PrevPoints {
224    /// Field absent in the input (JavaScript `undefined`).
225    #[default]
226    Missing,
227    /// Field present but not a string (can never match a signature).
228    PresentNonString,
229    /// Signature string from the input JSON.
230    Str(String),
231    /// Signature written during this solve: bits for fast comparison plus the
232    /// materialized string for JSON output.
233    Bits(Vec<SigEntry>, String),
234}
235
236#[derive(Debug, Default)]
237struct HotConstraint {
238    /// Original JSON object; hot fields are written back on output.
239    raw: Map<String, Value>,
240    ctype: CType,
241    /// Raw `type` string (None when missing or not a string).
242    type_str: Option<String>,
243    /// Raw point id values (mutated only by the ∠ negative-value swap).
244    point_ids: Vec<Value>,
245    /// Resolved indices into the engine point list (parallel to point_ids).
246    point_idx: Vec<Option<usize>>,
247    points_written: bool,
248    temporary: bool,
249
250    // --- value ---
251    /// Overridden numeric value (set when the engine writes `value`).
252    value_num: Option<f64>,
253    /// parseFloat cache of the effective value.
254    value_parsed: f64,
255    /// Whether the effective value is null/undefined (∠ seeding check).
256    value_nullish: bool,
257    /// Number-coercion cache of the effective value (⇌ lookups).
258    value_cv: f64,
259    value_written: bool,
260
261    // --- status / error ---
262    status_solved: bool,
263    status_written: bool,
264    error: Option<Value>,
265    error_written: bool,
266
267    // --- solve bookkeeping ---
268    prev_solve: PrevSolve,
269    prev_solve_written: bool,
270    prev_points: PrevPoints,
271    prev_points_written: bool,
272
273    // --- distance throttle state ---
274    /// Number.isFinite view of `_distanceRequestedTarget`.
275    req_target: Option<f64>,
276    req_target_written: bool,
277    /// Number.isFinite view of `_distanceAppliedTarget`.
278    app_target: Option<f64>,
279    app_target_written: bool,
280    /// Strict `=== true` view of `_distanceThrottleActive`.
281    throttle_true: bool,
282    /// Truthiness view of `_distanceThrottleActive` (differs from strict for
283    /// odd input values until first write).
284    throttle_truthy: bool,
285    throttle_written: bool,
286    /// String view of `_distanceLastAppliedPassToken`.
287    pass_token: Option<String>,
288    pass_token_written: bool,
289    /// Number() view of `_linePointDistanceSign` (missing → NaN, null → 0).
290    line_sign: f64,
291    line_sign_written: bool,
292}
293
294enum Filter<'a> {
295    All,
296    Type(CType, &'a str),
297}
298
299// ---------------------------------------------------------------------------
300// Engine
301// ---------------------------------------------------------------------------
302
303#[derive(Clone, Debug)]
304struct EnginePoint {
305    id: Value,
306    x: f64,
307    y: f64,
308    fixed: bool,
309    construction: bool,
310    external_reference: bool,
311}
312
313type CResult = Result<Value, String>;
314
315struct Engine {
316    points: Vec<EnginePoint>,
317    geometries: Vec<Value>,
318    constraints: Vec<HotConstraint>,
319    settings: SketchSolverSettings,
320    pass_token: String,
321    sig_scratch_before: Vec<SigEntry>,
322    sig_scratch_after: Vec<SigEntry>,
323}
324
325#[path = "sketch_solver/jsnum.rs"]
326mod jsnum;
327#[path = "sketch_solver/hot_constraint.rs"]
328mod hot_constraint;
329#[path = "sketch_solver/engine.rs"]
330mod engine;
331#[path = "sketch_solver/constraints_basic.rs"]
332mod constraints_basic;
333#[path = "sketch_solver/constraints_shape.rs"]
334mod constraints_shape;
335#[path = "sketch_solver/dof_polish.rs"]
336mod dof_polish;
337#[path = "sketch_solver/api.rs"]
338mod api;
339
340// ---------------------------------------------------------------------------
341// Tests
342// ---------------------------------------------------------------------------
343
344#[cfg(test)]
345#[path = "sketch_solver/tests.rs"]
346mod tests;
347
348use jsnum::*;
349
350pub use api::{solve_sketch, solve_sketch_from_json};