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;
327pub use jsnum::fmt_id as sketch_id_key;
328#[path = "sketch_solver/hot_constraint.rs"]
329mod hot_constraint;
330#[path = "sketch_solver/engine.rs"]
331mod engine;
332#[path = "sketch_solver/constraints_basic.rs"]
333mod constraints_basic;
334#[path = "sketch_solver/constraints_shape.rs"]
335mod constraints_shape;
336#[path = "sketch_solver/dof_polish.rs"]
337mod dof_polish;
338#[path = "sketch_solver/api.rs"]
339mod api;
340
341// ---------------------------------------------------------------------------
342// Tests
343// ---------------------------------------------------------------------------
344
345// BREP private tests: 27e500795ef801f8
346
347use jsnum::*;
348
349pub use api::{solve_sketch, solve_sketch_from_json};