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