Skip to main content

brep_render/sketch/
session.rs

1//! `SketchSession` — the in-flight editing/display state for one sketch.
2//!
3//! S0 holds the essentials the read-only overlay needs — the solved
4//! [`SketchDoc`], the plane [`PlaneFrame`] it lives on, and the last
5//! [`SketchDiagnostics`] (DOF + mobility) — plus stub fields the interactive
6//! slices (S2 picking, S3 tools, S5 dimensions) will fill in. Constructing a
7//! session solves the doc once; [`resolve`](Self::resolve) re-solves after edits.
8
9use serde_json::{json, Value};
10
11use super::doc::{id_key, SketchDiagnostics, SketchDoc};
12use super::tessellate::{self, SketchTessellation};
13use super::{solve, PlaneFrame};
14use crate::style::SketchColors;
15
16/// One sketch's live session: solved document + plane + diagnostics, with stubs
17/// for the interaction state later slices own.
18pub struct SketchSession {
19    /// The solved sketch (points carry solved coordinates).
20    pub doc: SketchDoc,
21    /// The plane the sketch's `(u, v)` coordinates are embedded in.
22    pub plane: PlaneFrame,
23    /// The last solve's read-only diagnostics (DOF, over/under status, mobility).
24    pub diagnostics: SketchDiagnostics,
25
26    // --- interaction state ---------------------------------------------------
27    /// Selected entities (S2) — a set of entity refs
28    /// (`{"kind":"point"|"geometry","id":<id>}`; see [`point_ref`]/[`geometry_ref`]).
29    pub selection: Vec<Value>,
30    /// The entity ref currently under the cursor (S2), or `None`.
31    pub hovered: Option<Value>,
32    /// The active drawing tool (S3). Unused in S0.
33    pub tool: Option<String>,
34    /// Persisted dimension label offsets (S5). Unused in S0.
35    pub dim_offsets: serde_json::Map<String, Value>,
36    /// The overlay color palette — a live [`SketchColors`] view of the display
37    /// settings (`RenderSettings::sketch_colors`). The engine sets this on entry and
38    /// re-syncs it when the settings change; every overlay builder reads from here so
39    /// the sketch colors are managed in the settings like the rest of the display.
40    /// Defaults to [`SketchColors::default`] so a session built in a test (or before
41    /// the engine syncs) renders the standard theme.
42    pub colors: SketchColors,
43    /// User-tunable solver knobs (the Solver Settings panel). Read by every
44    /// [`resolve`](Self::resolve); default reproduces the historical solve.
45    pub solver_settings: solve::SketchSolverSettings,
46}
47
48impl SketchSession {
49    /// Build a session from a (possibly unsolved) doc + plane, solving it once.
50    pub fn new(doc: SketchDoc, plane: PlaneFrame) -> Result<Self, String> {
51        let (solved, diagnostics) = solve::solve(&doc)?;
52        Ok(Self {
53            doc: solved,
54            plane,
55            diagnostics,
56            selection: Vec::new(),
57            hovered: None,
58            tool: None,
59            dim_offsets: serde_json::Map::new(),
60            colors: SketchColors::default(),
61            solver_settings: solve::SketchSolverSettings::default(),
62        })
63    }
64
65    /// Re-solve the current doc, refreshing coordinates + diagnostics in place,
66    /// honoring the session's [`solver_settings`](Self::solver_settings).
67    pub fn resolve(&mut self) -> Result<(), String> {
68        let (solved, diagnostics) = solve::solve_with(&self.doc, &self.solver_settings)?;
69        self.doc = solved;
70        self.diagnostics = diagnostics;
71        Ok(())
72    }
73
74    /// The `set_overlay` JSON for this solved sketch (see
75    /// [`tessellate::overlay_json`]). `world_per_pixel` sizes construction dashes.
76    pub fn overlay_json(&self, world_per_pixel: f64) -> String {
77        tessellate::overlay_json(
78            &self.doc,
79            &self.diagnostics,
80            &self.plane,
81            world_per_pixel,
82            &self.colors,
83        )
84    }
85
86    /// The flat overlay buffers (for tests / verification stats).
87    pub fn tessellation(&self, world_per_pixel: f64) -> SketchTessellation {
88        tessellate::tessellate(
89            &self.doc,
90            &self.diagnostics,
91            &self.plane,
92            world_per_pixel,
93            &self.colors,
94        )
95    }
96
97    /// The `set_overlay` JSON with the live hover + selection colored in (S2). Empty
98    /// hover + selection reproduce [`overlay_json`](Self::overlay_json) exactly.
99    pub fn overlay_json_with_state(&self, world_per_pixel: f64) -> String {
100        tessellate::overlay_json_with_state(
101            &self.doc,
102            &self.diagnostics,
103            &self.plane,
104            world_per_pixel,
105            &self.colors,
106            self.hovered.as_ref(),
107            &self.selection,
108        )
109    }
110
111    /// The `set_overlay` JSON for the `sketch-dim-leaders` group (S5): the per-type
112    /// leader/arrow segments for every dimensional constraint, offset by its stored
113    /// `{du, dv}` (see [`dimensions`](super::dimensions)). Always emitted (empty when
114    /// the sketch has no dimensions) so a stale group clears on the next refresh.
115    pub fn dim_leaders_overlay_json(&self, world_per_pixel: f64) -> String {
116        super::dimensions::dimension_leaders_overlay_json(
117            &self.doc,
118            &self.plane,
119            &self.dim_offsets,
120            world_per_pixel,
121            &self.colors,
122        )
123    }
124
125    /// Like [`dim_leaders_overlay_json`](Self::dim_leaders_overlay_json) but emphasizes
126    /// the live hovered / selected dimensional constraint (amber selected, light-blue
127    /// hovered — matching points/geometry). Used by the interactive overlay refresh.
128    pub fn dim_leaders_overlay_json_with_state(&self, world_per_pixel: f64) -> String {
129        super::dimensions::dimension_leaders_overlay_json_with_state(
130            &self.doc,
131            &self.plane,
132            &self.dim_offsets,
133            world_per_pixel,
134            &self.colors,
135            self.hovered.as_ref(),
136            &self.selection,
137        )
138    }
139
140    /// The `set_overlay` JSON for the `sketch-constraint-glyphs` group (S6c): the small
141    /// screen-constant line-art marks for every GEOMETRIC (non-dimensional) constraint
142    /// — perpendicular, parallel, horizontal/vertical, coincident, equal, … (see
143    /// [`constraint_glyphs`](super::constraint_glyphs)). Painted in the shared
144    /// constraint green. Always emitted (empty when the sketch has no geometric
145    /// constraints) so a stale group clears on the next refresh.
146    pub fn constraint_glyphs_overlay_json(&self, world_per_pixel: f64) -> String {
147        super::constraint_glyphs::constraint_glyphs_overlay_json(
148            &self.doc,
149            &self.plane,
150            world_per_pixel,
151            &self.colors,
152        )
153    }
154
155    /// Like [`constraint_glyphs_overlay_json`](Self::constraint_glyphs_overlay_json) but
156    /// emphasizes the live hovered / selected geometric constraint (amber selected,
157    /// light-blue hovered — matching points/geometry). Used by the interactive refresh.
158    pub fn constraint_glyphs_overlay_json_with_state(&self, world_per_pixel: f64) -> String {
159        super::constraint_glyphs::constraint_glyphs_overlay_json_with_state(
160            &self.doc,
161            &self.plane,
162            world_per_pixel,
163            &self.colors,
164            self.hovered.as_ref(),
165            &self.selection,
166        )
167    }
168
169    /// The per-constraint dimension labels (S5): one [`DimLabel`](super::dimensions::DimLabel)
170    /// per dimensional constraint, each anchored in world space (`plane.to_world` of
171    /// the label uv + its stored offset). brep-app projects `world` → screen and
172    /// draws the editable value text there.
173    pub fn dimension_labels(&self, world_per_pixel: f64) -> Vec<super::dimensions::DimLabel> {
174        super::dimensions::dimension_labels(
175            &self.doc,
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], seg[1]);
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, b);
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, b);
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/// Euclidean distance from point `(px, py)` to the segment `a`–`b` (all in plane
473/// `(u, v)` coordinates) — the geometry hit-test metric.
474fn point_segment_distance(px: f64, py: f64, a: [f64; 2], b: [f64; 2]) -> f64 {
475    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
476    let len2 = dx * dx + dy * dy;
477    let t = if len2 <= 1e-18 {
478        0.0
479    } else {
480        (((px - a[0]) * dx + (py - a[1]) * dy) / len2).clamp(0.0, 1.0)
481    };
482    let (cx, cy) = (a[0] + t * dx, a[1] + t * dy);
483    ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use serde_json::json;
490
491    #[test]
492    fn refs_equal_matches_kind_and_id_under_id_key() {
493        assert!(refs_equal(&point_ref(&json!(4)), &point_ref(&json!(4.0))));
494        assert!(!refs_equal(&point_ref(&json!(4)), &geometry_ref(&json!(4))));
495        assert!(!refs_equal(&point_ref(&json!(4)), &point_ref(&json!(5))));
496        assert!(entity_ref_eq(None, None));
497        assert!(!entity_ref_eq(Some(&point_ref(&json!(4))), None));
498        assert!(entity_ref_eq(
499            Some(&point_ref(&json!(4))),
500            Some(&point_ref(&json!(4)))
501        ));
502    }
503
504    #[test]
505    fn toggle_and_is_selected_add_then_remove() {
506        let mut s = SketchSession::seed_rectangle_circle().expect("seed");
507        let r = point_ref(&json!(4));
508        assert!(!s.is_selected(&r));
509        s.toggle_selection(r.clone());
510        assert!(s.is_selected(&r));
511        assert_eq!(s.selection.len(), 1);
512        s.toggle_selection(r.clone());
513        assert!(!s.is_selected(&r));
514        assert_eq!(s.selection.len(), 0);
515    }
516
517    #[test]
518    fn pick_entity_prefers_the_nearest_point_within_radius() {
519        let s = SketchSession::seed_rectangle_circle().expect("seed");
520        // Right on the free circle center (id 4 at (34, 6)).
521        let hit = s.pick_entity(34.1, 6.05, 0.5).expect("point hit");
522        assert!(refs_equal(&hit, &point_ref(&json!(4))), "hit = {hit}");
523    }
524
525    #[test]
526    fn pick_entity_returns_none_outside_radius() {
527        let s = SketchSession::seed_rectangle_circle().expect("seed");
528        // Far from every point AND every edge.
529        assert!(s.pick_entity(100.0, 100.0, 0.5).is_none());
530    }
531
532    #[test]
533    fn drag_points_from_ref_resolves_point_and_geometry() {
534        let s = SketchSession::seed_rectangle_circle().expect("seed");
535        // A MOVABLE point (the free circle center p4) → just itself.
536        let pt = s
537            .drag_points_from_ref(&point_ref(&json!(4)))
538            .expect("movable point grabs");
539        assert_eq!(pt.len(), 1);
540        assert!(refs_equal(&point_ref(&pt[0].0), &point_ref(&json!(4))));
541        assert!(!pt[0].3, "p4 is not fixed");
542        // A LOCKED point (grounded corner p0) → None: the drag must fall to the camera,
543        // never steal a nearby unhighlighted point.
544        assert!(s.drag_points_from_ref(&point_ref(&json!(0))).is_none());
545        // A geometry with movable points (the free circle 20 → points 4 + 5) → both,
546        // for a rigid translate.
547        let circle = s
548            .drag_points_from_ref(&geometry_ref(&json!(20)))
549            .expect("movable geometry grabs");
550        assert_eq!(circle.len(), 2, "the circle translates both its points");
551        // A fully-locked geometry (rectangle edge 10 → grounded points 0,1) → None.
552        assert!(s.drag_points_from_ref(&geometry_ref(&json!(10))).is_none());
553        // A stale / unknown ref → None (a hover left dangling after delete/undo).
554        assert!(s.drag_points_from_ref(&point_ref(&json!(999))).is_none());
555        assert!(s.drag_points_from_ref(&geometry_ref(&json!(999))).is_none());
556    }
557
558    #[test]
559    fn pick_entity_falls_through_to_geometry_mid_segment() {
560        let s = SketchSession::seed_rectangle_circle().expect("seed");
561        // Mid the bottom edge (line id 10, (0,0)->(20,0)); nearest point is a
562        // corner 10 units away, so with a tight radius geometry wins.
563        let hit = s.pick_entity(10.0, 0.05, 0.2).expect("geometry hit");
564        assert!(refs_equal(&hit, &geometry_ref(&json!(10))), "hit = {hit}");
565    }
566
567    #[test]
568    fn pick_draggable_point_skips_locked_points() {
569        let s = SketchSession::seed_rectangle_circle().expect("seed");
570        // The grounded corner p0 (locked) is not draggable.
571        assert!(s.pick_draggable_point(0.0, 0.0, 0.5).is_none());
572        // The free circle center p4 is.
573        let (id, fixed) = s.pick_draggable_point(34.0, 6.0, 0.5).expect("draggable");
574        assert!(id == json!(4) && !fixed, "id = {id}, fixed = {fixed}");
575    }
576
577    #[test]
578    fn pick_constraint_finds_glyph_and_leader_but_not_far() {
579        // A single horizontal line (0,0)-(10,0) with a horizontal geometric constraint
580        // (id 0, glyph pushed off the midpoint) + a distance dim (id 1, leader above).
581        let doc: crate::sketch::SketchDoc = serde_json::from_value(json!({
582            "points": [
583                { "id": 0, "x": 0.0, "y": 0.0, "fixed": true },
584                { "id": 1, "x": 10.0, "y": 0.0 }
585            ],
586            "geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
587            "constraints": [
588                { "id": 0, "type": "━", "points": [0, 1] },
589                { "id": 1, "type": "⟺", "points": [0, 1], "value": 10.0 }
590            ]
591        }))
592        .expect("doc");
593        let s = SketchSession::new(doc, PlaneFrame::xy()).expect("session");
594        let (wpp, radius) = (0.05, 0.3);
595        // Near the horizontal glyph (midpoint (5,0) pushed off +v ~0.77) → a constraint.
596        let hit = s.pick_constraint(5.0, 0.77, radius, wpp).expect("glyph hit");
597        assert_eq!(hit["kind"], "constraint", "glyph pick is a constraint ref");
598        // Near the distance-dim leader line (default offset ~+v 1.0 at the midpoint).
599        let hit2 = s.pick_constraint(5.0, 1.0, radius, wpp).expect("leader hit");
600        assert_eq!(hit2["kind"], "constraint", "leader pick is a constraint ref");
601        // Far from every glyph + leader → None.
602        assert!(s.pick_constraint(5.0, 20.0, radius, wpp).is_none(), "nothing far off");
603    }
604
605    #[test]
606    fn custom_settings_color_reaches_the_tessellation() {
607        // A non-default sketch color set on the session (as the engine does from
608        // `RenderSettings::sketch_colors`) actually paints the overlay buffer: a
609        // custom movable color lands on the free (movable) circle center point.
610        let mut s = SketchSession::seed_rectangle_circle().expect("seed");
611        s.colors = crate::style::RenderSettings::default().sketch_colors();
612        s.colors.movable = 0x123456;
613        let tess = s.tessellation(0.05);
614        // The movable circle center (id 4) carries the custom movable color.
615        let p4 = s.doc.point(&json!(4)).unwrap();
616        let idx = tess
617            .point_positions
618            .chunks(3)
619            .position(|c| (c[0] - p4.x as f32).abs() < 1e-4 && (c[1] - p4.y as f32).abs() < 1e-4)
620            .expect("circle center among overlay points");
621        let col = &tess.point_colors[idx * 3..idx * 3 + 3];
622        assert!((col[0] - 0x12 as f32 / 255.0).abs() < 1e-3, "r wrong: {col:?}");
623        assert!((col[1] - 0x34 as f32 / 255.0).abs() < 1e-3, "g wrong: {col:?}");
624        assert!((col[2] - 0x56 as f32 / 255.0).abs() < 1e-3, "b wrong: {col:?}");
625    }
626
627    #[test]
628    fn overlay_state_colors_selected_amber_over_mobility() {
629        let mut s = SketchSession::seed_rectangle_circle().expect("seed");
630        s.toggle_selection(point_ref(&json!(4)));
631        s.set_hover(Some(point_ref(&json!(5))));
632        let tess = tessellate::tessellate_with_state(
633            &s.doc,
634            &s.diagnostics,
635            &s.plane,
636            0.05,
637            &s.colors,
638            s.hovered.as_ref(),
639            &s.selection,
640        );
641        // Point 4 (selected) is amber (0xffa500); point 5 (hovered) is light blue.
642        let p4 = s.doc.point(&json!(4)).unwrap();
643        let idx4 = tess
644            .point_positions
645            .chunks(3)
646            .position(|c| (c[0] - p4.x as f32).abs() < 1e-4 && (c[1] - p4.y as f32).abs() < 1e-4)
647            .expect("p4 among points");
648        let c4 = &tess.point_colors[idx4 * 3..idx4 * 3 + 3];
649        assert!((c4[0] - 0xff as f32 / 255.0).abs() < 1e-3, "selected not amber: {c4:?}");
650        assert!((c4[1] - 0xa5 as f32 / 255.0).abs() < 1e-3, "selected not amber: {c4:?}");
651    }
652}