Skip to main content

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