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}