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