Skip to main content

brep_render/sketch/
doc.rs

1//! Typed sketch JSON shared with the 2D constraint solver.
2//!
3//! Documents contain points, geometries, constraints, and optional diagnostics.
4//! Flattened serde fields preserve unmodeled geometry and constraint data across
5//! load/save, including solver bookkeeping.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12/// Index points using the solver's identity keys; later duplicate ids take precedence.
13pub(super) fn point_index(doc: &SketchDoc) -> HashMap<String, &SketchPoint> {
14    let mut by_id: HashMap<String, &SketchPoint> = HashMap::with_capacity(doc.points.len());
15    for p in &doc.points {
16        by_id.insert(id_key(&p.id), p);
17    }
18    by_id
19}
20
21/// A sketch point: a solved 2D coordinate in the plane's `(u, v)` frame, plus the
22/// three role flags the solver tracks. Emitted by the solver as exactly these six
23/// fields (`solvers/sketch_solver.rs` solve output).
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
25pub struct SketchPoint {
26    /// Stable point id (a number in practice, but the solver keys by `Value`, so
27    /// strings are legal too — kept as a `Value` for a lossless round-trip).
28    pub id: Value,
29    pub x: f64,
30    pub y: f64,
31    /// Ground/pinned: the solver removes this point's coordinates from the free
32    /// set entirely (always `locked`).
33    #[serde(default)]
34    pub fixed: bool,
35    /// Construction point: constrains but never models a profile edge; drawn in
36    /// the construction color.
37    #[serde(default)]
38    pub construction: bool,
39    /// A point adopted from an external reference (a picked edge endpoint).
40    #[serde(default, rename = "externalReference")]
41    pub external_reference: bool,
42}
43
44/// A sketch geometry: `line = [p0,p1]`, `circle = [center,radiusPoint]`,
45/// `arc = [center,start,end]` (CCW start→end), `ellipse = [center,majEnd,minEnd]`,
46/// `bezier = [p0,p1,p2,p3,…]` (every 3 ids a new cubic span). Point semantics are
47/// the ground truth from the sketch feature's edge builder.
48///
49/// `points` are point ids (matched against [`SketchPoint::id`] via [`id_key`]).
50/// The `construction` flag (dashed, non-modeling) and any other authored fields
51/// ride along in `extra`, so the geometry round-trips losslessly.
52#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
53pub struct SketchGeometry {
54    pub id: Value,
55    #[serde(rename = "type")]
56    pub geom_type: String,
57    #[serde(default)]
58    pub points: Vec<Value>,
59    /// Every field this slice does not model explicitly (`construction`, authoring
60    /// metadata, …) — preserved verbatim so a load/save is lossless.
61    #[serde(flatten)]
62    pub extra: Map<String, Value>,
63}
64
65impl SketchGeometry {
66    /// Whether this geometry is construction-only (dashed, excluded from profiles).
67    pub fn construction(&self) -> bool {
68        self.extra
69            .get("construction")
70            .and_then(Value::as_bool)
71            .unwrap_or(false)
72    }
73}
74
75/// A sketch constraint. The solver mutates constraints with a large amount of
76/// bookkeeping (`status`, `error`, `previousPointValues`, `_previousSolveValue`,
77/// the `_distance*` slide state, …) that must persist between solves EXACTLY, so
78/// the whole object is kept as a transparent map rather than a lossy typed struct.
79/// Typed accessors ([`ctype`](Self::ctype), [`points`](Self::points)) read the
80/// fields this slice needs.
81#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
82#[serde(transparent)]
83pub struct SketchConstraint {
84    pub raw: Map<String, Value>,
85}
86
87impl SketchConstraint {
88    /// The constraint glyph/type (`"⏚"` ground, `"━"` horizontal, `"⟺"` distance, …).
89    pub fn ctype(&self) -> Option<&str> {
90        self.raw.get("type").and_then(Value::as_str)
91    }
92
93    /// The point ids this constraint references (empty if absent).
94    pub fn points(&self) -> &[Value] {
95        self.raw
96            .get("points")
97            .and_then(Value::as_array)
98            .map(Vec::as_slice)
99            .unwrap_or(&[])
100    }
101
102    /// Whether this is a solver-internal helper constraint (excluded from the DOF
103    /// diagnostics and from the "constrained points" fallback coloring).
104    pub fn temporary(&self) -> bool {
105        self.raw
106            .get("temporary")
107            .and_then(Value::as_bool)
108            .unwrap_or(false)
109    }
110}
111
112/// The editable sketch document — a typed mirror of the solver's
113/// `{points, geometries, constraints}`. Unknown top-level keys (e.g. the solved
114/// output's `diagnostics`) are ignored on load; [`crate::sketch::solve`] pulls
115/// diagnostics out into [`SketchDiagnostics`] separately.
116#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
117pub struct SketchDoc {
118    #[serde(default)]
119    pub points: Vec<SketchPoint>,
120    #[serde(default)]
121    pub geometries: Vec<SketchGeometry>,
122    #[serde(default)]
123    pub constraints: Vec<SketchConstraint>,
124}
125
126impl SketchDoc {
127    /// Look up a point by id (matched via [`id_key`], mirroring the solver's
128    /// `point_key` identity).
129    pub fn point(&self, id: &Value) -> Option<&SketchPoint> {
130        let key = id_key(id);
131        self.points.iter().find(|p| id_key(&p.id) == key)
132    }
133
134    /// Mutable lookup of a point by id (used by the interactive point drag to write
135    /// a solved coordinate / toggle the transient `fixed` anchor flag).
136    pub fn point_mut(&mut self, id: &Value) -> Option<&mut SketchPoint> {
137        let key = id_key(id);
138        self.points.iter_mut().find(|p| id_key(&p.id) == key)
139    }
140
141    /// The set of non-temporary constrained point ids (as [`id_key`] strings) —
142    /// the fallback used to color under-constrained points when the solver did not
143    /// supply per-point mobility (it always does, so this is a safety net).
144    pub fn constrained_point_keys(&self) -> std::collections::HashSet<String> {
145        let mut set = std::collections::HashSet::new();
146        for c in &self.constraints {
147            if c.temporary() {
148                continue;
149            }
150            for pid in c.points() {
151                set.insert(id_key(pid));
152            }
153        }
154        set
155    }
156}
157
158/// The solver's read-only constraint diagnostics: degrees of freedom, over/under
159/// status, and per-point / per-geometry mobility (movable vs locked) derived from
160/// the constraint-Jacobian null space. Keys in the mobility maps are [`id_key`]
161/// strings.
162#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
163pub struct SketchDiagnostics {
164    #[serde(default)]
165    pub dof: i64,
166    #[serde(default)]
167    pub rank: i64,
168    #[serde(default)]
169    pub unknowns: i64,
170    #[serde(default)]
171    pub equations: i64,
172    #[serde(default)]
173    pub redundant: i64,
174    #[serde(default)]
175    pub status: String,
176    #[serde(default)]
177    pub conflicting: bool,
178    /// The constraints the solver named as mutually unsatisfiable, as [`id_key`]
179    /// strings. When the constraint rows are linearly dependent this is the group
180    /// a left null-space certificate implicates, NOT merely whichever constraint
181    /// relaxation happened to leave violated last; a contradiction whose rows stay
182    /// independent admits no such certificate, and the group is then every
183    /// constraint the solve came to rest still violating. Empty exactly when
184    /// [`conflicting`](Self::conflicting) is clear — the solver drops the flag
185    /// rather than raise it with nothing to point at.
186    #[serde(default, rename = "conflictingConstraints")]
187    pub conflicting_constraints: Vec<String>,
188    /// `id_key -> "movable" | "locked"` per point (`BTreeMap` for a deterministic,
189    /// solver-matching key order).
190    #[serde(default, rename = "pointMobility")]
191    pub point_mobility: std::collections::BTreeMap<String, String>,
192    #[serde(default, rename = "geometryMobility")]
193    pub geometry_mobility: std::collections::BTreeMap<String, String>,
194}
195
196impl SketchDiagnostics {
197    /// Whether `id` names a constraint in the conflicting group (matched via
198    /// [`id_key`], so a numeric `4` and a string `"4"` agree with the solver).
199    pub fn constraint_conflicting(&self, id: &Value) -> bool {
200        if self.conflicting_constraints.is_empty() {
201            return false;
202        }
203        let key = id_key(id);
204        self.conflicting_constraints.iter().any(|c| *c == key)
205    }
206}
207
208impl SketchDiagnostics {
209    /// `Some(true)` movable, `Some(false)` locked, `None` if the solver gave no
210    /// mobility for this point id.
211    pub fn point_movable(&self, id: &Value) -> Option<bool> {
212        self.point_mobility
213            .get(&id_key(id))
214            .map(|v| v == "movable")
215    }
216
217    /// `Some(true)` movable, `Some(false)` locked, `None` if unknown.
218    pub fn geometry_movable(&self, id: &Value) -> Option<bool> {
219        self.geometry_mobility
220            .get(&id_key(id))
221            .map(|v| v == "movable")
222    }
223}
224
225/// Shared solver key for sketch entity and mobility-map lookups.
226pub use brep_kernel::sketch_id_key as id_key;
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// BREP private tests: 53fbe23755599b7d