Skip to main content

brep_render/sketch/
doc.rs

1//! `SketchDoc` — the engine-native mirror of the 2D solver's sketch JSON.
2//!
3//! The Rust 2D constraint solver (`brep_kernel::solve_sketch`) consumes and
4//! emits a sketch object shaped `{points, geometries, constraints}` (plus a
5//! read-only `diagnostics` block on the solved output). This module is the typed
6//! serde mirror of that shape, so the engine-native sketcher can hold, solve, and
7//! render a sketch WITHOUT a JSON round-trip through the previous app.
8//!
9//! Faithful round-trip is a hard requirement (S0 test): a document deserialized
10//! from the solver's output and re-serialized must equal the original JSON. To
11//! that end the passthrough-heavy sub-objects keep an `#[serde(flatten)]` bag of
12//! any fields this slice does not model explicitly (geometry `construction` flag,
13//! the solver's constraint bookkeeping — `status`, `previousPointValues`, the
14//! `_distance*` fields, …), so nothing is lost across a load/save.
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18
19/// A sketch point: a solved 2D coordinate in the plane's `(u, v)` frame, plus the
20/// three role flags the solver tracks. Emitted by the solver as exactly these six
21/// fields (`solvers/sketch_solver.rs` solve output).
22#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
23pub struct SketchPoint {
24    /// Stable point id (a number in practice, but the solver keys by `Value`, so
25    /// strings are legal too — kept as a `Value` for a lossless round-trip).
26    pub id: Value,
27    pub x: f64,
28    pub y: f64,
29    /// Ground/pinned: the solver removes this point's coordinates from the free
30    /// set entirely (always `locked`).
31    #[serde(default)]
32    pub fixed: bool,
33    /// Construction point: constrains but never models a profile edge; drawn in
34    /// the construction color.
35    #[serde(default)]
36    pub construction: bool,
37    /// A point adopted from an external reference (a picked edge endpoint).
38    #[serde(default, rename = "externalReference")]
39    pub external_reference: bool,
40}
41
42/// A sketch geometry: `line = [p0,p1]`, `circle = [center,radiusPoint]`,
43/// `arc = [center,start,end]` (CCW start→end), `ellipse = [center,majEnd,minEnd]`,
44/// `bezier = [p0,p1,p2,p3,…]` (every 3 ids a new cubic span). Point semantics are
45/// the ground truth from the sketch feature's edge builder.
46///
47/// `points` are point ids (matched against [`SketchPoint::id`] via [`id_key`]).
48/// The `construction` flag (dashed, non-modeling) and any other authored fields
49/// ride along in `extra`, so the geometry round-trips losslessly.
50#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
51pub struct SketchGeometry {
52    pub id: Value,
53    #[serde(rename = "type")]
54    pub geom_type: String,
55    #[serde(default)]
56    pub points: Vec<Value>,
57    /// Every field this slice does not model explicitly (`construction`, authoring
58    /// metadata, …) — preserved verbatim so a load/save is lossless.
59    #[serde(flatten)]
60    pub extra: Map<String, Value>,
61}
62
63impl SketchGeometry {
64    /// Whether this geometry is construction-only (dashed, excluded from profiles).
65    pub fn construction(&self) -> bool {
66        self.extra
67            .get("construction")
68            .and_then(Value::as_bool)
69            .unwrap_or(false)
70    }
71}
72
73/// A sketch constraint. The solver mutates constraints with a large amount of
74/// bookkeeping (`status`, `error`, `previousPointValues`, `_previousSolveValue`,
75/// the `_distance*` slide state, …) that must persist between solves EXACTLY, so
76/// the whole object is kept as a transparent map rather than a lossy typed struct.
77/// Typed accessors ([`ctype`](Self::ctype), [`points`](Self::points)) read the
78/// fields this slice needs.
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80#[serde(transparent)]
81pub struct SketchConstraint {
82    pub raw: Map<String, Value>,
83}
84
85impl SketchConstraint {
86    /// The constraint glyph/type (`"⏚"` ground, `"━"` horizontal, `"⟺"` distance, …).
87    pub fn ctype(&self) -> Option<&str> {
88        self.raw.get("type").and_then(Value::as_str)
89    }
90
91    /// The point ids this constraint references (empty if absent).
92    pub fn points(&self) -> &[Value] {
93        self.raw
94            .get("points")
95            .and_then(Value::as_array)
96            .map(Vec::as_slice)
97            .unwrap_or(&[])
98    }
99
100    /// Whether this is a solver-internal helper constraint (excluded from the DOF
101    /// diagnostics and from the "constrained points" fallback coloring).
102    pub fn temporary(&self) -> bool {
103        self.raw
104            .get("temporary")
105            .and_then(Value::as_bool)
106            .unwrap_or(false)
107    }
108}
109
110/// The editable sketch document — a typed mirror of the solver's
111/// `{points, geometries, constraints}`. Unknown top-level keys (e.g. the solved
112/// output's `diagnostics`) are ignored on load; [`crate::sketch::solve`] pulls
113/// diagnostics out into [`SketchDiagnostics`] separately.
114#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
115pub struct SketchDoc {
116    #[serde(default)]
117    pub points: Vec<SketchPoint>,
118    #[serde(default)]
119    pub geometries: Vec<SketchGeometry>,
120    #[serde(default)]
121    pub constraints: Vec<SketchConstraint>,
122}
123
124impl SketchDoc {
125    /// Look up a point by id (matched via [`id_key`], mirroring the solver's
126    /// `point_key` identity).
127    pub fn point(&self, id: &Value) -> Option<&SketchPoint> {
128        let key = id_key(id);
129        self.points.iter().find(|p| id_key(&p.id) == key)
130    }
131
132    /// Mutable lookup of a point by id (used by the interactive point drag to write
133    /// a solved coordinate / toggle the transient `fixed` anchor flag).
134    pub fn point_mut(&mut self, id: &Value) -> Option<&mut SketchPoint> {
135        let key = id_key(id);
136        self.points.iter_mut().find(|p| id_key(&p.id) == key)
137    }
138
139    /// The set of non-temporary constrained point ids (as [`id_key`] strings) —
140    /// the fallback used to color under-constrained points when the solver did not
141    /// supply per-point mobility (it always does, so this is a safety net).
142    pub fn constrained_point_keys(&self) -> std::collections::HashSet<String> {
143        let mut set = std::collections::HashSet::new();
144        for c in &self.constraints {
145            if c.temporary() {
146                continue;
147            }
148            for pid in c.points() {
149                set.insert(id_key(pid));
150            }
151        }
152        set
153    }
154}
155
156/// The solver's read-only constraint diagnostics: degrees of freedom, over/under
157/// status, and per-point / per-geometry mobility (movable vs locked) derived from
158/// the constraint-Jacobian null space. Keys in the mobility maps are [`id_key`]
159/// strings.
160#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
161pub struct SketchDiagnostics {
162    #[serde(default)]
163    pub dof: i64,
164    #[serde(default)]
165    pub rank: i64,
166    #[serde(default)]
167    pub unknowns: i64,
168    #[serde(default)]
169    pub equations: i64,
170    #[serde(default)]
171    pub redundant: i64,
172    #[serde(default)]
173    pub status: String,
174    #[serde(default)]
175    pub conflicting: bool,
176    /// `id_key -> "movable" | "locked"` per point (`BTreeMap` for a deterministic,
177    /// solver-matching key order).
178    #[serde(default, rename = "pointMobility")]
179    pub point_mobility: std::collections::BTreeMap<String, String>,
180    #[serde(default, rename = "geometryMobility")]
181    pub geometry_mobility: std::collections::BTreeMap<String, String>,
182}
183
184impl SketchDiagnostics {
185    /// `Some(true)` movable, `Some(false)` locked, `None` if the solver gave no
186    /// mobility for this point id.
187    pub fn point_movable(&self, id: &Value) -> Option<bool> {
188        self.point_mobility
189            .get(&id_key(id))
190            .map(|v| v == "movable")
191    }
192
193    /// `Some(true)` movable, `Some(false)` locked, `None` if unknown.
194    pub fn geometry_movable(&self, id: &Value) -> Option<bool> {
195        self.geometry_mobility
196            .get(&id_key(id))
197            .map(|v| v == "movable")
198    }
199}
200
201/// Canonical string key for a point/geometry id — a faithful port of the solver's
202/// `fmt_id`/`fmt_number` so mobility-map lookups (keyed by that formatting) and
203/// point matching agree byte-for-byte. Integral floats print without a decimal
204/// point (`10.0 -> "10"`), `-0`/`0` collapse to `"0"`.
205pub fn id_key(value: &Value) -> String {
206    match value {
207        Value::String(text) => text.clone(),
208        Value::Number(number) => number
209            .as_f64()
210            .map(fmt_number)
211            .unwrap_or_else(|| number.to_string()),
212        Value::Bool(flag) => flag.to_string(),
213        Value::Null => "null".to_string(),
214        other => other.to_string(),
215    }
216}
217
218fn fmt_number(x: f64) -> String {
219    if x.is_nan() {
220        "NaN".to_string()
221    } else if x == 0.0 {
222        "0".to_string()
223    } else {
224        format!("{x}")
225    }
226}
227
228// ---------------------------------------------------------------------------
229// S3a: id minting + snap-or-add (the draw tools' point/geometry factory).
230//
231// Points and geometries have SEPARATE id spaces; a fresh id is `max(numeric ids)
232// + 1`, or a count-based id when nothing parses as a number, and is guaranteed
233// not to collide (under [`id_key`]) with any existing id in the collection.
234// [`SketchDoc::snap_or_add_point`] reuses an existing point within a grab radius
235// so shared vertices coincide (chained lines, closed loops, arc endpoints).
236// ---------------------------------------------------------------------------
237
238impl SketchDoc {
239    /// Mint the next unused POINT id (`max numeric id + 1`, else a count-based id),
240    /// non-colliding within `self.points`.
241    pub fn next_point_id(&self) -> Value {
242        let ids: Vec<&Value> = self.points.iter().map(|p| &p.id).collect();
243        mint_next_id(&ids)
244    }
245
246    /// Mint the next unused GEOMETRY id (`max numeric id + 1`, else a count-based
247    /// id), non-colliding within `self.geometries`.
248    pub fn next_geometry_id(&self) -> Value {
249        let ids: Vec<&Value> = self.geometries.iter().map(|g| &g.id).collect();
250        mint_next_id(&ids)
251    }
252
253    /// Return the id of the existing point within `radius` of `(u, v)` (the nearest
254    /// one, so drawn vertices coincide), else mint a new free point at `(u, v)` and
255    /// return its id. The `radius` is the same ~8px→world grab tolerance S2 uses.
256    pub fn snap_or_add_point(&mut self, u: f64, v: f64, radius: f64) -> Value {
257        let mut best: Option<(f64, Value)> = None;
258        for p in &self.points {
259            let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
260            if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
261                best = Some((d, p.id.clone()));
262            }
263        }
264        if let Some((_, id)) = best {
265            return id;
266        }
267        let id = self.next_point_id();
268        self.points.push(SketchPoint {
269            id: id.clone(),
270            x: u,
271            y: v,
272            fixed: false,
273            construction: false,
274            external_reference: false,
275        });
276        id
277    }
278}
279
280// ---------------------------------------------------------------------------
281// S4: constraint id minting + geometry lookup (the constraint palette's factory).
282//
283// Constraints have their OWN id space (like points/geometries): a fresh id is
284// `max(numeric constraint ids) + 1`. Geometry lookup mirrors [`SketchDoc::point`]
285// so the palette can resolve a selected geometry ref back to its `SketchGeometry`
286// (its point roles + type) to assemble a constraint's ordered point list.
287// ---------------------------------------------------------------------------
288
289impl SketchDoc {
290    /// Mint the next unused CONSTRAINT id (`max numeric id + 1`, else a count-based
291    /// id), non-colliding within `self.constraints`. Constraints carry their id in
292    /// the raw map's `"id"` field.
293    pub fn next_constraint_id(&self) -> Value {
294        let ids: Vec<&Value> = self
295            .constraints
296            .iter()
297            .filter_map(|c| c.raw.get("id"))
298            .collect();
299        mint_next_id(&ids)
300    }
301
302    /// Look up a geometry by id (matched via [`id_key`], mirroring [`Self::point`]).
303    pub fn geometry(&self, id: &Value) -> Option<&SketchGeometry> {
304        let key = id_key(id);
305        self.geometries.iter().find(|g| id_key(&g.id) == key)
306    }
307
308    /// Mutable lookup of a geometry by id (used by the construction toggle to flip a
309    /// selected geometry's `construction` flag in its `extra` bag).
310    pub fn geometry_mut(&mut self, id: &Value) -> Option<&mut SketchGeometry> {
311        let key = id_key(id);
312        self.geometries.iter_mut().find(|g| id_key(&g.id) == key)
313    }
314}
315
316/// Parse an id `Value` as a number (numeric ids, or numeric strings like `"12"`),
317/// or `None` for a non-numeric id.
318fn id_num(value: &Value) -> Option<f64> {
319    match value {
320        Value::Number(number) => number.as_f64(),
321        Value::String(text) => text.trim().parse::<f64>().ok(),
322        _ => None,
323    }
324}
325
326/// Mint the next id for a collection: `max(numeric ids) + 1`, falling back to the
327/// element count when nothing parses as a number, then bumped until it does not
328/// collide (under [`id_key`]) with any existing id.
329fn mint_next_id(ids: &[&Value]) -> Value {
330    let mut max: Option<i64> = None;
331    for &id in ids {
332        if let Some(n) = id_num(id) {
333            if n.is_finite() {
334                let i = n.floor() as i64;
335                max = Some(max.map_or(i, |m| m.max(i)));
336            }
337        }
338    }
339    let mut candidate = max.map(|m| m + 1).unwrap_or(ids.len() as i64);
340    let existing: std::collections::HashSet<String> = ids.iter().map(|&id| id_key(id)).collect();
341    while existing.contains(&id_key(&Value::from(candidate))) {
342        candidate += 1;
343    }
344    Value::from(candidate)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use serde_json::json;
351
352    fn doc_from(value: serde_json::Value) -> SketchDoc {
353        serde_json::from_value(value).expect("sketch doc")
354    }
355
356    #[test]
357    fn next_ids_are_max_numeric_plus_one_per_collection() {
358        let doc = doc_from(json!({
359            "points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 5, "x": 1.0, "y": 1.0 }],
360            "geometries": [{ "id": 10, "type": "line", "points": [0, 5] }],
361            "constraints": []
362        }));
363        // Separate id spaces: points seed 0/5 → 6; geometries seed 10 → 11.
364        assert_eq!(doc.next_point_id(), json!(6));
365        assert_eq!(doc.next_geometry_id(), json!(11));
366    }
367
368    #[test]
369    fn next_id_falls_back_to_count_when_non_numeric_and_avoids_collision() {
370        let doc = doc_from(json!({
371            "points": [{ "id": "a", "x": 0.0, "y": 0.0 }, { "id": "b", "x": 1.0, "y": 1.0 }],
372            "geometries": [],
373            "constraints": []
374        }));
375        // No numeric ids → count-based (2); "2" doesn't collide with "a"/"b".
376        assert_eq!(doc.next_point_id(), json!(2));
377    }
378
379    #[test]
380    fn next_ids_on_empty_doc_start_at_zero() {
381        let doc = SketchDoc::default();
382        assert_eq!(doc.next_point_id(), json!(0));
383        assert_eq!(doc.next_geometry_id(), json!(0));
384    }
385
386    #[test]
387    fn next_id_handles_float_and_string_numbers() {
388        let doc = doc_from(json!({
389            "points": [{ "id": 3.0, "x": 0.0, "y": 0.0 }, { "id": "7", "x": 1.0, "y": 1.0 }],
390            "geometries": [],
391            "constraints": []
392        }));
393        // max(3, 7) + 1 = 8.
394        assert_eq!(doc.next_point_id(), json!(8));
395    }
396
397    #[test]
398    fn snap_or_add_reuses_within_radius_else_mints() {
399        let mut doc = doc_from(json!({
400            "points": [{ "id": 0, "x": 0.0, "y": 0.0 }],
401            "geometries": [],
402            "constraints": []
403        }));
404        // Within radius → reuse id 0, no new point.
405        let a = doc.snap_or_add_point(0.1, 0.0, 1.0);
406        assert_eq!(id_key(&a), "0");
407        assert_eq!(doc.points.len(), 1);
408        // Outside radius → mint a fresh point (id 1).
409        let b = doc.snap_or_add_point(10.0, 0.0, 1.0);
410        assert_eq!(id_key(&b), "1");
411        assert_eq!(doc.points.len(), 2);
412        let p = doc.point(&b).unwrap();
413        assert!((p.x - 10.0).abs() < 1e-9 && p.y.abs() < 1e-9 && !p.fixed && !p.construction);
414    }
415
416    #[test]
417    fn next_constraint_id_is_max_numeric_plus_one() {
418        let doc = doc_from(json!({
419            "points": [{ "id": 0, "x": 0.0, "y": 0.0 }],
420            "geometries": [],
421            "constraints": [
422                { "id": 0, "type": "⏚", "points": [0] },
423                { "id": 6, "type": "━", "points": [0, 1] }
424            ]
425        }));
426        // Constraints have their own id space: seed 0/6 → 7.
427        assert_eq!(doc.next_constraint_id(), json!(7));
428        // Empty constraints → count-based 0.
429        let empty = SketchDoc::default();
430        assert_eq!(empty.next_constraint_id(), json!(0));
431    }
432
433    #[test]
434    fn geometry_lookup_matches_via_id_key() {
435        let doc = doc_from(json!({
436            "points": [],
437            "geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
438            "constraints": []
439        }));
440        assert_eq!(doc.geometry(&json!(10.0)).map(|g| g.geom_type.as_str()), Some("line"));
441        assert!(doc.geometry(&json!(99)).is_none());
442    }
443
444    #[test]
445    fn snap_or_add_picks_the_nearest_point_within_radius() {
446        let mut doc = doc_from(json!({
447            "points": [
448                { "id": 0, "x": 0.0, "y": 0.0 },
449                { "id": 1, "x": 0.5, "y": 0.0 }
450            ],
451            "geometries": [],
452            "constraints": []
453        }));
454        // Closer to id 1 than id 0.
455        let hit = doc.snap_or_add_point(0.6, 0.0, 2.0);
456        assert_eq!(id_key(&hit), "1");
457        assert_eq!(doc.points.len(), 2);
458    }
459}