brep_render/sketch/session.rs
1//! A sketch's solved document, plane, diagnostics, and interaction state.
2
3use crate::geometry2d::point_segment_distance;
4
5use serde_json::{json, Value};
6
7use super::doc::{id_key, SketchDiagnostics, SketchDoc};
8use super::tessellate::{self, SketchTessellation};
9use super::{solve, PlaneFrame};
10use crate::style::SketchColors;
11
12/// State for one active sketch.
13pub struct SketchSession {
14 /// The solved sketch (points carry solved coordinates).
15 pub doc: SketchDoc,
16 /// The plane the sketch's `(u, v)` coordinates are embedded in.
17 pub plane: PlaneFrame,
18 /// The last solve's read-only diagnostics (DOF, over/under status, mobility).
19 pub diagnostics: SketchDiagnostics,
20
21 // --- interaction state ---------------------------------------------------
22 /// Selected entities — a set of entity refs
23 /// (`{"kind":"point"|"geometry","id":<id>}`; see [`point_ref`]/[`geometry_ref`]).
24 pub selection: Vec<Value>,
25 /// The entity ref currently under the cursor, or `None`.
26 pub hovered: Option<Value>,
27 /// The active drawing tool.
28 pub tool: Option<String>,
29 /// Persisted dimension label offsets.
30 pub dim_offsets: serde_json::Map<String, Value>,
31 /// The overlay color palette — a live [`SketchColors`] view of the display
32 /// settings (`RenderSettings::sketch_colors`). The engine sets this on entry and
33 /// re-syncs it when the settings change; every overlay builder reads from here so
34 /// the sketch colors are managed in the settings like the rest of the display.
35 /// Defaults to [`SketchColors::default`] so a session built in a test (or before
36 /// the engine syncs) renders the standard theme.
37 pub colors: SketchColors,
38 /// User-tunable solver knobs (the Solver Settings panel). Read by every
39 /// [`resolve`](Self::resolve); default reproduces the historical solve.
40 pub solver_settings: solve::SketchSolverSettings,
41}
42
43impl SketchSession {
44 /// Build a session from a (possibly unsolved) doc + plane, solving it once.
45 pub fn new(doc: SketchDoc, plane: PlaneFrame) -> Result<Self, String> {
46 let (solved, diagnostics) = solve::solve(&doc)?;
47 Ok(Self {
48 doc: solved,
49 plane,
50 diagnostics,
51 selection: Vec::new(),
52 hovered: None,
53 tool: None,
54 dim_offsets: serde_json::Map::new(),
55 colors: SketchColors::default(),
56 solver_settings: solve::SketchSolverSettings::default(),
57 })
58 }
59
60 /// Re-solve the current doc, refreshing coordinates + diagnostics in place,
61 /// honoring the session's [`solver_settings`](Self::solver_settings).
62 pub fn resolve(&mut self) -> Result<(), String> {
63 let (solved, diagnostics) = solve::solve_with(&self.doc, &self.solver_settings)?;
64 self.doc = solved;
65 self.diagnostics = diagnostics;
66 Ok(())
67 }
68
69 /// The `set_overlay` JSON for this solved sketch (see
70 /// [`tessellate::overlay_json`]). `world_per_pixel` sizes construction dashes.
71 pub fn overlay_json(&self, world_per_pixel: f64) -> String {
72 tessellate::overlay_json(
73 &self.doc,
74 &self.diagnostics,
75 &self.plane,
76 world_per_pixel,
77 &self.colors,
78 )
79 }
80
81 /// The flat overlay buffers (for tests / verification stats).
82 pub fn tessellation(&self, world_per_pixel: f64) -> SketchTessellation {
83 tessellate::tessellate(
84 &self.doc,
85 &self.diagnostics,
86 &self.plane,
87 world_per_pixel,
88 &self.colors,
89 )
90 }
91
92 /// The `set_overlay` JSON with the live hover + selection colored in (S2). Empty
93 /// hover + selection reproduce [`overlay_json`](Self::overlay_json) exactly.
94 pub fn overlay_json_with_state(&self, world_per_pixel: f64) -> String {
95 tessellate::overlay_json_with_state(
96 &self.doc,
97 &self.diagnostics,
98 &self.plane,
99 world_per_pixel,
100 &self.colors,
101 self.hovered.as_ref(),
102 &self.selection,
103 )
104 }
105
106 /// The `set_overlay` JSON for the `sketch-dim-leaders` group (S5): the per-type
107 /// leader/arrow segments for every dimensional constraint, offset by its stored
108 /// `{du, dv}` (see [`dimensions`](super::dimensions)). Always emitted (empty when
109 /// the sketch has no dimensions) so a stale group clears on the next refresh.
110 pub fn dim_leaders_overlay_json(&self, world_per_pixel: f64) -> String {
111 super::dimensions::dimension_leaders_overlay_json(
112 &self.doc,
113 &self.diagnostics,
114 &self.plane,
115 &self.dim_offsets,
116 world_per_pixel,
117 &self.colors,
118 )
119 }
120
121 /// Like [`dim_leaders_overlay_json`](Self::dim_leaders_overlay_json) but emphasizes
122 /// the live hovered / selected dimensional constraint (amber selected, light-blue
123 /// hovered — matching points/geometry). Used by the interactive overlay refresh.
124 pub fn dim_leaders_overlay_json_with_state(&self, world_per_pixel: f64) -> String {
125 super::dimensions::dimension_leaders_overlay_json_with_state(
126 &self.doc,
127 &self.diagnostics,
128 &self.plane,
129 &self.dim_offsets,
130 world_per_pixel,
131 &self.colors,
132 self.hovered.as_ref(),
133 &self.selection,
134 )
135 }
136
137 /// The `set_overlay` JSON for the `sketch-constraint-glyphs` group (S6c): the small
138 /// screen-constant line-art marks for every GEOMETRIC (non-dimensional) constraint
139 /// — perpendicular, parallel, horizontal/vertical, coincident, equal, … (see
140 /// [`constraint_glyphs`](super::constraint_glyphs)). Painted in the shared
141 /// constraint green. Always emitted (empty when the sketch has no geometric
142 /// constraints) so a stale group clears on the next refresh.
143 pub fn constraint_glyphs_overlay_json(&self, world_per_pixel: f64) -> String {
144 super::constraint_glyphs::constraint_glyphs_overlay_json(
145 &self.doc,
146 &self.diagnostics,
147 &self.plane,
148 world_per_pixel,
149 &self.colors,
150 )
151 }
152
153 /// Like [`constraint_glyphs_overlay_json`](Self::constraint_glyphs_overlay_json) but
154 /// emphasizes the live hovered / selected geometric constraint (amber selected,
155 /// light-blue hovered — matching points/geometry). Used by the interactive refresh.
156 pub fn constraint_glyphs_overlay_json_with_state(&self, world_per_pixel: f64) -> String {
157 super::constraint_glyphs::constraint_glyphs_overlay_json_with_state(
158 &self.doc,
159 &self.diagnostics,
160 &self.plane,
161 world_per_pixel,
162 &self.colors,
163 self.hovered.as_ref(),
164 &self.selection,
165 )
166 }
167
168 /// The per-constraint dimension labels (S5): one [`DimLabel`](super::dimensions::DimLabel)
169 /// per dimensional constraint, each anchored in world space (`plane.to_world` of
170 /// the label uv + its stored offset). brep-app projects `world` → screen and
171 /// draws the editable value text there.
172 pub fn dimension_labels(&self, world_per_pixel: f64) -> Vec<super::dimensions::DimLabel> {
173 super::dimensions::dimension_labels(
174 &self.doc,
175 &self.diagnostics,
176 &self.plane,
177 &self.dim_offsets,
178 world_per_pixel,
179 )
180 }
181
182 /// The `set_overlay` JSON for the active draw tool's in-progress rubber-band
183 /// preview (S3a): the dim dashed geometry from the `pending` anchor points (their
184 /// ids, resolved to uv against the live doc) toward `hover_uv`, plus the raw
185 /// freehand `stroke` (S6b-3) as a dim solid polyline while a handdraw drag is live.
186 /// Always a `sketch-preview` group (empty when there is nothing to preview yet).
187 pub fn preview_overlay_json(
188 &self,
189 world_per_pixel: f64,
190 pending: &[Value],
191 hover_uv: Option<(f64, f64)>,
192 stroke: &[(f64, f64)],
193 ) -> String {
194 let pending_uv: Vec<[f64; 2]> = pending
195 .iter()
196 .filter_map(|id| self.doc.point(id).map(|p| [p.x, p.y]))
197 .collect();
198 let stroke_uv: Vec<[f64; 2]> = stroke.iter().map(|&(u, v)| [u, v]).collect();
199 tessellate::preview_overlay_json(
200 self.tool.as_deref(),
201 &pending_uv,
202 hover_uv,
203 &stroke_uv,
204 &self.plane,
205 world_per_pixel,
206 &self.colors,
207 )
208 }
209
210 // --- S2 hover / selection state ------------------------------------------
211
212 /// Whether `entity_ref` is in the selection set (matched via [`refs_equal`]).
213 pub fn is_selected(&self, entity_ref: &Value) -> bool {
214 self.selection.iter().any(|r| refs_equal(r, entity_ref))
215 }
216
217 /// Toggle `entity_ref` in the selection set: remove it if present, else add it.
218 pub fn toggle_selection(&mut self, entity_ref: Value) {
219 if let Some(pos) = self.selection.iter().position(|r| refs_equal(r, &entity_ref)) {
220 self.selection.remove(pos);
221 } else {
222 self.selection.push(entity_ref);
223 }
224 }
225
226 /// Clear the selection set.
227 pub fn clear_selection(&mut self) {
228 self.selection.clear();
229 }
230
231 /// Set (or clear) the hovered entity ref.
232 pub fn set_hover(&mut self, entity_ref: Option<Value>) {
233 self.hovered = entity_ref;
234 }
235
236 // --- S2 hit-testing ------------------------------------------------------
237
238 /// The entity ref nearest to plane coordinate `(u, v)` within `radius` — a
239 /// POINT wins over geometry when one is in range (points take priority), else
240 /// the nearest geometry whose polyline passes within `radius`. Construction
241 /// geometry is included (it is pickable). `None` when nothing is in range.
242 pub fn pick_entity(&self, u: f64, v: f64, radius: f64) -> Option<Value> {
243 // Points first (priority over geometry under the cursor).
244 let mut best_pt: Option<(f64, &Value)> = None;
245 for p in &self.doc.points {
246 let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
247 if d <= radius && best_pt.map_or(true, |(bd, _)| d < bd) {
248 best_pt = Some((d, &p.id));
249 }
250 }
251 if let Some((_, id)) = best_pt {
252 return Some(point_ref(id));
253 }
254
255 // Else the nearest geometry polyline within radius.
256 let mut best_geo: Option<(f64, &Value)> = None;
257 for g in &self.doc.geometries {
258 let poly = tessellate::geometry_polyline_uv(g, &self.doc);
259 if poly.len() < 2 {
260 continue;
261 }
262 let mut dmin = f64::INFINITY;
263 for seg in poly.windows(2) {
264 let d = point_segment_distance((u, v), seg[0].into(), seg[1].into()).0;
265 if d < dmin {
266 dmin = d;
267 }
268 }
269 if dmin <= radius && best_geo.map_or(true, |(bd, _)| dmin < bd) {
270 best_geo = Some((dmin, &g.id));
271 }
272 }
273 best_geo.map(|(_, id)| geometry_ref(id))
274 }
275
276 /// The CONSTRAINT nearest to `(u, v)` within `radius` — a `constraint` entity ref
277 /// (`{"kind":"constraint","id":<id>}`), or `None`. A GEOMETRIC constraint is picked
278 /// near its glyph line-art (the exact segments [`constraint_glyphs`] draws); a
279 /// DIMENSIONAL one near its leader/arrow lines (NOT its value label — the label is
280 /// the egui value-editor affordance). Solver-internal `temporary` helpers are never
281 /// pickable. `world_per_pixel` sizes the screen-constant glyph/leader placement (so
282 /// picking tracks what is drawn). The CALLER enforces priority: points > geometry >
283 /// constraint (see `EngineState::sketch_entity_at`), so a glyph over a point never
284 /// shadows the point.
285 ///
286 /// [`constraint_glyphs`]: super::constraint_glyphs
287 pub fn pick_constraint(&self, u: f64, v: f64, radius: f64, world_per_pixel: f64) -> Option<Value> {
288 let mut best: Option<(f64, &Value)> = None;
289 for c in &self.doc.constraints {
290 if c.temporary() {
291 continue;
292 }
293 let Some(id) = c.raw.get("id") else { continue };
294 // A constraint contributes glyph segments (geometric) XOR leader segments
295 // (dimensional); measure the cursor against whichever it draws.
296 let mut dmin = f64::INFINITY;
297 for (a, b) in
298 super::constraint_glyphs::constraint_glyph_segments(c, &self.doc, world_per_pixel)
299 {
300 let d = point_segment_distance((u, v), a.into(), b.into()).0;
301 if d < dmin {
302 dmin = d;
303 }
304 }
305 if let Some(segments) = super::dimensions::constraint_dim_segments(
306 c,
307 &self.doc,
308 &self.dim_offsets,
309 world_per_pixel,
310 ) {
311 for (a, b) in segments {
312 let d = point_segment_distance((u, v), a.into(), b.into()).0;
313 if d < dmin {
314 dmin = d;
315 }
316 }
317 }
318 if dmin <= radius && best.map_or(true, |(bd, _)| dmin < bd) {
319 best = Some((dmin, id));
320 }
321 }
322 best.map(|(_, id)| constraint_ref(id))
323 }
324
325 /// The nearest DRAGGABLE point to `(u, v)` within `radius` (its id + authored
326 /// `fixed` flag), or `None`. A point is draggable only if the solver reports it
327 /// movable (a locked / grounded point has no free coordinates to drag), so a
328 /// drag on a fully-constrained point falls through to a camera orbit.
329 pub fn pick_draggable_point(&self, u: f64, v: f64, radius: f64) -> Option<(Value, bool)> {
330 let mut best: Option<(f64, &super::doc::SketchPoint)> = None;
331 for p in &self.doc.points {
332 let movable = self.diagnostics.point_movable(&p.id).unwrap_or(!p.fixed);
333 if !movable {
334 continue;
335 }
336 let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
337 if d <= radius && best.map_or(true, |(bd, _)| d < bd) {
338 best = Some((d, p));
339 }
340 }
341 best.map(|(_, p)| (p.id.clone(), p.fixed))
342 }
343
344 /// The point-set to DRAG for a hovered entity ref (`{"kind","id"}`) — the grab
345 /// path grabs exactly what's highlighted rather than re-picking at egui's
346 /// offset drag-start position. A `"point"` ref → that point iff it is MOVABLE;
347 /// a `"geometry"` ref → ALL of its (deduped) points when at least one is movable
348 /// (a rigid translate). Each entry is `(id, orig_x, orig_y, orig_fixed)`. `None`
349 /// for a locked point, a fully-locked geometry, or a stale/unknown ref (the
350 /// caller then falls through to a camera gesture — it never drags something the
351 /// user is not pointing at).
352 pub fn drag_points_from_ref(&self, entity_ref: &Value) -> Option<Vec<(Value, f64, f64, bool)>> {
353 let kind = entity_ref.get("kind").and_then(Value::as_str)?;
354 let id = entity_ref.get("id")?;
355 match kind {
356 "point" => {
357 let p = self.doc.point(id)?;
358 let movable = self.diagnostics.point_movable(&p.id).unwrap_or(!p.fixed);
359 movable.then(|| vec![(p.id.clone(), p.x, p.y, p.fixed)])
360 }
361 "geometry" => {
362 // Raw doc ids compare via `id_key` (numeric identity) — NOT
363 // `refs_equal`, which is for `{"kind","id"}` entity refs and treats
364 // every bare id as equal.
365 let g = self
366 .doc
367 .geometries
368 .iter()
369 .find(|g| id_key(&g.id) == id_key(id))?;
370 let mut out: Vec<(Value, f64, f64, bool)> = Vec::new();
371 let mut any_movable = false;
372 for pid in &g.points {
373 // A closed polyline repeats its start id — dedupe so a point is
374 // pinned once.
375 if out.iter().any(|(pt_id, ..)| id_key(pt_id) == id_key(pid)) {
376 continue;
377 }
378 if let Some(p) = self.doc.point(pid) {
379 any_movable |= self.diagnostics.point_movable(&p.id).unwrap_or(!p.fixed);
380 out.push((p.id.clone(), p.x, p.y, p.fixed));
381 }
382 }
383 (any_movable && !out.is_empty()).then_some(out)
384 }
385 _ => None,
386 }
387 }
388
389 /// Seed a standalone demo sketch (S0 milestone): a fully-constrained 20×12
390 /// rectangle grounded at the origin PLUS a free (unconstrained) circle beside
391 /// it, both on the XY plane. The mix proves the pipeline end to end — the
392 /// rectangle solves to `locked` (white), the circle stays `movable` (blue),
393 /// and the sketch reports `dof = 4` (the circle's four free coordinates).
394 pub fn seed_rectangle_circle() -> Result<Self, String> {
395 Self::new(seed_doc(), PlaneFrame::xy())
396 }
397}
398
399/// The seed document consumed by [`SketchSession::seed_rectangle_circle`].
400fn seed_doc() -> SketchDoc {
401 let value = json!({
402 "points": [
403 // rectangle corners (p0 grounded at the origin)
404 { "id": 0, "x": 0.0, "y": 0.0, "fixed": false, "construction": false, "externalReference": false },
405 { "id": 1, "x": 20.0, "y": 0.0, "fixed": false, "construction": false, "externalReference": false },
406 { "id": 2, "x": 20.0, "y": 12.0, "fixed": false, "construction": false, "externalReference": false },
407 { "id": 3, "x": 0.0, "y": 12.0, "fixed": false, "construction": false, "externalReference": false },
408 // free circle (center + radius point), unconstrained -> 4 DOF, movable
409 { "id": 4, "x": 34.0, "y": 6.0, "fixed": false, "construction": false, "externalReference": false },
410 { "id": 5, "x": 40.0, "y": 6.0, "fixed": false, "construction": false, "externalReference": false }
411 ],
412 "geometries": [
413 { "id": 10, "type": "line", "points": [0, 1], "construction": false },
414 { "id": 11, "type": "line", "points": [1, 2], "construction": false },
415 { "id": 12, "type": "line", "points": [2, 3], "construction": false },
416 { "id": 13, "type": "line", "points": [3, 0], "construction": false },
417 { "id": 20, "type": "circle", "points": [4, 5], "construction": false }
418 ],
419 "constraints": [
420 { "id": 0, "type": "⏚", "points": [0] },
421 { "id": 1, "type": "━", "points": [0, 1] },
422 { "id": 2, "type": "⟺", "points": [0, 1], "value": 20.0 },
423 { "id": 3, "type": "│", "points": [1, 2] },
424 { "id": 4, "type": "⟺", "points": [1, 2], "value": 12.0 },
425 { "id": 5, "type": "━", "points": [2, 3] },
426 { "id": 6, "type": "│", "points": [3, 0] }
427 ]
428 });
429 serde_json::from_value(value).expect("seed sketch doc is valid")
430}
431
432// --- S2 entity-ref convention --------------------------------------------------
433//
434// Hover + selection both use ONE ref shape: `{"kind":"point"|"geometry","id":<id>}`
435// where `<id>` is the raw doc id `Value` (a number in practice). Two refs are equal
436// when their kinds match and their ids agree under the solver's [`id_key`] identity
437// (so numeric `4` and string `"4"` are the same entity).
438
439/// Build a `{"kind":"point","id":<id>}` entity ref.
440pub fn point_ref(id: &Value) -> Value {
441 json!({ "kind": "point", "id": id.clone() })
442}
443
444/// Build a `{"kind":"geometry","id":<id>}` entity ref.
445pub fn geometry_ref(id: &Value) -> Value {
446 json!({ "kind": "geometry", "id": id.clone() })
447}
448
449/// Build a `{"kind":"constraint","id":<id>}` entity ref — a CONSTRAINT is selectable
450/// like a point/geometry (picked near its glyph or dimension leader) so it can be
451/// emphasized + deleted; deleting it drops only the constraint, never its geometry.
452pub fn constraint_ref(id: &Value) -> Value {
453 json!({ "kind": "constraint", "id": id.clone() })
454}
455
456/// Whether two entity refs name the same entity (same `kind`, same `id` under
457/// [`id_key`]).
458pub fn refs_equal(a: &Value, b: &Value) -> bool {
459 a.get("kind") == b.get("kind") && a.get("id").map(id_key) == b.get("id").map(id_key)
460}
461
462/// Whether two optional entity refs are equal (both absent, or both present and
463/// [`refs_equal`]) — the hover-changed test.
464pub fn entity_ref_eq(a: Option<&Value>, b: Option<&Value>) -> bool {
465 match (a, b) {
466 (None, None) => true,
467 (Some(x), Some(y)) => refs_equal(x, y),
468 _ => false,
469 }
470}
471
472// BREP private tests: e0c3ed55ff1d8a1e