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};