Skip to main content

brep_render/engine_state/
sketch_input.rs

1use super::*;
2use super::sketch_panel::{sketch_constraint_signature, sketch_perpendicular_should_swap};
3
4// ===========================================================================
5// Sketch interaction (S2) — plane-space picking: hover, selection, point drag.
6//
7// All operate on the active `self.sketch_edit` and no-op (false / 0) when not in
8// sketch mode. Pixel→plane→uv goes through the SAME `camera.pick_ray` the modeling
9// picker uses, intersected with the sketch plane (`crate::sketch::ray_plane_uv`).
10// Hit-testing (`SketchSession::pick_entity` / `pick_draggable_point`) is pure uv
11// math; points win over geometry within the ~8px grab radius. Every mutator
12// re-pushes the overlay via `refresh_sketch_overlay` (which colors the live hover +
13// selection) and marks the engine dirty. Kept in ONE appended block so concurrent
14// edits to the primary impl land clean.
15// ===========================================================================
16impl EngineState {
17    /// Re-push the sketch overlay reflecting the live hover + selection. Reads the
18    /// active `sketch_edit`'s session; a no-op when not in sketch mode. Called at
19    /// the end of every S2 mutator (the reusable counterpart of the initial
20    /// [`set_sketch_overlay`](Self::set_sketch_overlay) push).
21    pub(super) fn refresh_sketch_overlay(&mut self) {
22        let world_per_pixel = self.camera.world_per_pixel();
23        let (json, preview, leaders, glyphs) = match self.sketch_edit.as_ref() {
24            Some(edit) => (
25                edit.session.overlay_json_with_state(world_per_pixel),
26                edit.session.preview_overlay_json(
27                    world_per_pixel,
28                    &edit.pending,
29                    edit.hover_uv,
30                    &edit.handdraw_stroke,
31                ),
32                edit.session.dim_leaders_overlay_json_with_state(world_per_pixel),
33                edit.session
34                    .constraint_glyphs_overlay_json_with_state(world_per_pixel),
35            ),
36            None => return,
37        };
38        let _ = self.set_overlay_json(&json);
39        // The draw-tool rubber-band rides in its own `sketch-preview` group so it
40        // upserts/clears independently of the solved geometry + point groups.
41        let _ = self.set_overlay_json(&preview);
42        // The dimension leaders + arrows ride in `sketch-dim-leaders`, refreshed
43        // alongside everything else (S5).
44        let _ = self.set_overlay_json(&leaders);
45        // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
46        let _ = self.set_overlay_json(&glyphs);
47    }
48
49    /// Map CSS-pixel `(x, y)` to the active sketch plane's `(u, v)` via the camera
50    /// pick ray ∩ the sketch plane. `None` when not in sketch mode or the ray misses
51    /// the plane (parallel / behind).
52    pub fn sketch_uv_at(&self, x: f64, y: f64) -> Option<(f64, f64)> {
53        let edit = self.sketch_edit.as_ref()?;
54        let ray = self.camera.pick_ray(x, y);
55        crate::sketch::ray_plane_uv(&edit.session.plane, ray.origin, ray.dir)
56    }
57
58    /// The world-space pick tolerance at the current zoom — the ONE radius that
59    /// drives hover-highlight, click-select, drag-grab, draw-snap AND trim, for
60    /// points AND geometry, so "what highlights" is exactly "what you can grab".
61    /// Sized at 1.5× the visualized point (`POINT_SIZE_PX`) for forgiving clicking.
62    pub(super) fn sketch_pick_radius(&self) -> f64 {
63        f64::from(crate::sketch::tessellate::POINT_SIZE_PX) * 1.5 * self.camera.world_per_pixel()
64    }
65
66    /// The entity ref under CSS-pixel `(x, y)` within the grab radius, or `None`.
67    /// Priority is points > geometry > constraint: [`pick_entity`] resolves the first
68    /// two, and only when neither is in range do we consult [`pick_constraint`] (a glyph
69    /// or dimension leader that overlaps a point/edge never shadows it).
70    ///
71    /// [`pick_entity`]: crate::sketch::SketchSession::pick_entity
72    /// [`pick_constraint`]: crate::sketch::SketchSession::pick_constraint
73    fn sketch_entity_at(&self, x: f64, y: f64) -> Option<serde_json::Value> {
74        let (u, v) = self.sketch_uv_at(x, y)?;
75        let radius = self.sketch_pick_radius();
76        let wpp = self.camera.world_per_pixel();
77        self.sketch_edit.as_ref().and_then(|edit| {
78            edit.session
79                .pick_entity(u, v, radius)
80                .or_else(|| edit.session.pick_constraint(u, v, radius, wpp))
81        })
82    }
83
84    /// Set (or clear) the sketch hover, re-pushing the overlay + marking dirty only
85    /// when it actually changed. Returns whether the hover changed.
86    pub(super) fn set_sketch_hover(&mut self, new_hover: Option<serde_json::Value>) -> bool {
87        let changed = match self.sketch_edit.as_ref() {
88            Some(edit) => {
89                !crate::sketch::entity_ref_eq(edit.session.hovered.as_ref(), new_hover.as_ref())
90            }
91            None => false,
92        };
93        if changed {
94            if let Some(edit) = self.sketch_edit.as_mut() {
95                edit.session.set_hover(new_hover);
96            }
97            self.refresh_sketch_overlay();
98            self.dirty = true;
99        }
100        changed
101    }
102
103    /// Update the sketch hover to the entity under CSS-pixel `(x, y)` (S2). Returns
104    /// whether the hover changed. A no-op returning `false` when not in sketch mode.
105    pub fn sketch_hover_at(&mut self, x: f64, y: f64) -> bool {
106        if self.sketch_edit.is_none() {
107            return false;
108        }
109        // Track the live cursor uv for the S3a rubber-band. In DRAW mode with pending
110        // clicks the preview follows the cursor even when the hovered ENTITY is
111        // unchanged, so force an overlay refresh there.
112        let uv = self.sketch_uv_at(x, y);
113        let preview_live = match self.sketch_edit.as_mut() {
114            Some(edit) => {
115                edit.hover_uv = uv;
116                edit.session.tool.is_some() && !edit.pending.is_empty()
117            }
118            None => false,
119        };
120        let new_hover = self.sketch_entity_at(x, y);
121        let changed = self.set_sketch_hover(new_hover);
122        if preview_live && !changed {
123            self.refresh_sketch_overlay();
124            self.dirty = true;
125        }
126        changed
127    }
128
129    /// Clear the sketch hover (pointer left the viewport / moved over the ViewCube).
130    /// Returns whether a hover was cleared.
131    pub fn sketch_clear_hover(&mut self) -> bool {
132        self.set_sketch_hover(None)
133    }
134
135    /// Click-select in sketch mode: pick the entity under `(x, y)`; nothing → clear
136    /// the selection; else `additive` (Ctrl/Cmd) toggles it in the set, a plain
137    /// click replaces the set with just it. Re-pushes the overlay + marks dirty.
138    pub fn sketch_click_at(&mut self, x: f64, y: f64, additive: bool) {
139        let hit = self.sketch_entity_at(x, y);
140        let Some(edit) = self.sketch_edit.as_mut() else {
141            return;
142        };
143        match hit {
144            None => edit.session.clear_selection(),
145            Some(entity_ref) => {
146                if additive {
147                    edit.session.toggle_selection(entity_ref);
148                } else {
149                    edit.session.clear_selection();
150                    edit.session.toggle_selection(entity_ref);
151                }
152            }
153        }
154        self.refresh_sketch_overlay();
155        self.dirty = true;
156    }
157
158    /// Begin a point drag if a DRAGGABLE point is under `(x, y)` (S2): remember it
159    /// (id + original `fixed` flag). Returns `true` iff a point was grabbed (the
160    /// viewport routes the drag to the sketch; otherwise it orbits the camera). A
161    /// locked / fully-constrained point is not draggable, so an empty-space or
162    /// locked-point drag falls through to a camera orbit.
163    pub fn sketch_drag_begin(&mut self, x: f64, y: f64) -> bool {
164        let Some((u, v)) = self.sketch_uv_at(x, y) else {
165            return false;
166        };
167        let radius = self.sketch_pick_radius();
168        // Grab EXACTLY what's HIGHLIGHTED: the hovered entity was picked at the exact
169        // cursor position on the last move, so it is immune to egui reporting the
170        // drag-start ~6px into the gesture (the "highlighted but won't grab"
171        // intermittency — and it's what lets a whole geometry drag). A hovered LOCKED
172        // point yields `None` → the drag falls through to a camera gesture; it must
173        // NOT positional-fall-back there (that would grab a nearby UNhighlighted
174        // point). Only an EMPTY hover (a press with no prior move) falls back to a
175        // fresh positional pick.
176        let points = self.sketch_edit.as_ref().and_then(|edit| {
177            match edit.session.hovered.as_ref() {
178                Some(entity_ref) => edit.session.drag_points_from_ref(entity_ref),
179                None => edit
180                    .session
181                    .pick_draggable_point(u, v, radius)
182                    .and_then(|(id, fixed)| {
183                        edit.session
184                            .doc
185                            .point(&id)
186                            .map(|p| vec![(id, p.x, p.y, fixed)])
187                    }),
188            }
189        });
190        let Some(points) = points else {
191            return false;
192        };
193        if let Some(edit) = self.sketch_edit.as_mut() {
194            // Snapshot ONCE at the gesture start so the whole drag is one undo step
195            // (S6a); `sketch_drag_to` never snapshots. A grab that moves nothing is
196            // discarded in `sketch_drag_end`.
197            edit.record_undo();
198            edit.drag = Some(SketchDrag { points, anchor: (u, v) });
199        }
200        true
201    }
202
203    /// Drag the grabbed target to `(x, y)` (S2): pin every grabbed point at its
204    /// ORIGINAL position plus the cursor delta (`fixed = true`) so the solver anchors
205    /// the whole shape there, re-solve, then restore each point's ORIGINAL `fixed`
206    /// flag. Absolute-from-anchor (never incremental), so a rigid geometry translate
207    /// tracks the cursor 1:1 without drifting as the solver nudges points between
208    /// frames. A resolve error rolls every grabbed point back to its pre-drag coords
209    /// (the last good state). No-op when nothing is grabbed / not in sketch mode / the
210    /// ray misses the plane.
211    pub fn sketch_drag_to(&mut self, x: f64, y: f64) {
212        let Some((u, v)) = self.sketch_uv_at(x, y) else {
213            return;
214        };
215        let Some(edit) = self.sketch_edit.as_mut() else {
216            return;
217        };
218        let Some(drag) = edit.drag.clone() else {
219            return;
220        };
221        let (du, dv) = (u - drag.anchor.0, v - drag.anchor.1);
222        let session = &mut edit.session;
223        for (id, ox, oy, _) in &drag.points {
224            if let Some(p) = session.doc.point_mut(id) {
225                p.x = *ox + du;
226                p.y = *oy + dv;
227                p.fixed = true;
228            }
229        }
230        match session.resolve() {
231            Ok(()) => {
232                for (id, _, _, orig_fixed) in &drag.points {
233                    if let Some(p) = session.doc.point_mut(id) {
234                        p.fixed = *orig_fixed;
235                    }
236                }
237            }
238            Err(_) => {
239                // Unsolvable target: roll every grabbed point back to its pre-drag
240                // coords + flag (keep the last good state).
241                for (id, ox, oy, orig_fixed) in &drag.points {
242                    if let Some(p) = session.doc.point_mut(id) {
243                        p.x = *ox;
244                        p.y = *oy;
245                        p.fixed = *orig_fixed;
246                    }
247                }
248            }
249        }
250        self.refresh_sketch_overlay();
251        self.dirty = true;
252    }
253
254    /// End a point drag (S2): clear the grab, then one final re-solve + overlay
255    /// refresh. No-op when no drag is live.
256    pub fn sketch_drag_end(&mut self) {
257        let had_grab = self
258            .sketch_edit
259            .as_ref()
260            .map_or(false, |edit| edit.drag.is_some());
261        if !had_grab {
262            return;
263        }
264        // Drop radius for constraint inference == the point grab radius (12 px in
265        // world units) — read before the `&mut` borrow of `sketch_edit`.
266        let drop_tol = self.sketch_pick_radius();
267        if let Some(edit) = self.sketch_edit.as_mut() {
268            // Drop-time inference (S6c): a SINGLE-point drop snaps to a coincident
269            // point / point-on-line at the release position, mirroring the previous
270            // coincident-on-drop / point-on-line-on-drop inference. A whole-
271            // geometry drag (multiple points) never infers — running it per
272            // endpoint could glue or collapse the curve in one solve.
273            let dragged_single = edit.drag.as_ref().and_then(|drag| {
274                (drag.points.len() == 1).then(|| drag.points[0].0.clone())
275            });
276            edit.drag = None;
277            if let Some(point_id) = dragged_single {
278                crate::sketch::infer::infer_drop_constraint(
279                    &mut edit.session.doc,
280                    &point_id,
281                    drop_tol,
282                );
283            }
284            let _ = edit.session.resolve();
285            // Discard the drag's undo snapshot when the doc is unchanged (a mere
286            // grab-and-release, no move and no inferred constraint), so it neither
287            // pollutes undo nor clobbers redo. An inferred constraint changes the
288            // doc, so the snapshot is kept — one Ctrl+Z then undoes move+constraint.
289            if edit
290                .undo_stack
291                .last()
292                .map_or(false, |snap| snap.doc == edit.session.doc)
293            {
294                edit.undo_stack.pop();
295            }
296        }
297        self.refresh_sketch_overlay();
298        self.dirty = true;
299    }
300
301    /// The number of selected sketch entities (0 when not in sketch mode) — the mode
302    /// bar / verifier readout.
303    pub fn sketch_selection_count(&self) -> usize {
304        self.sketch_edit
305            .as_ref()
306            .map_or(0, |edit| edit.session.selection.len())
307    }
308
309    /// The number of selected CONSTRAINTS (refs whose `kind` is `"constraint"`; 0 when
310    /// not in sketch mode) — the `__brepSketch` verifier readout for constraint
311    /// selection + delete.
312    pub fn sketch_selected_constraint_count(&self) -> usize {
313        self.sketch_edit.as_ref().map_or(0, |edit| {
314            edit.session
315                .selection
316                .iter()
317                .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("constraint"))
318                .count()
319        })
320    }
321}
322
323// ===========================================================================
324// Sketch draw tools (S3a) — primitive placement: point / line / rect / circle / arc.
325//
326// A click-state machine over the active `self.sketch_edit`. The active tool lives
327// on `session.tool` ("select"/None = selection mode, S2); a DRAW tool routes clicks
328// to `sketch_tool_click_at` (pixel → plane uv via the same S2 `sketch_uv_at`, then
329// `sketch_tool_place_uv`). Points/geometries are minted through `SketchDoc`
330// (`next_point_id`/`next_geometry_id` + `snap_or_add_point`, so shared vertices
331// coincide). Each placement re-solves (swallowing solve errors), re-pushes the
332// overlay (incl. the rubber-band preview), and marks dirty. Kept in ONE appended
333// block so concurrent edits to the primary impl land clean.
334// ===========================================================================
335impl EngineState {
336    /// Set (or clear) the active draw tool: `"select"`/`None` → selection mode (S2);
337    /// `"point"|"line"|"rect"|"circle"|"arc"|"bezier"` arm the corresponding draw
338    /// tool; `"handdraw"` arms the freehand stroke tool (S6b-3); `"trim"` arms the
339    /// trim tool (S6b); `"pickEdges"` arms the external-edge link tool (S6b-2). Clears
340    /// any in-progress click buffer + preview and refreshes the overlay. No-op when not
341    /// in sketch mode.
342    pub fn sketch_set_tool(&mut self, tool: Option<&str>) {
343        let normalized = normalize_sketch_tool(tool);
344        if let Some(edit) = self.sketch_edit.as_mut() {
345            edit.session.tool = normalized;
346            edit.pending.clear();
347            edit.hover_uv = None;
348            edit.handdraw_stroke.clear();
349        } else {
350            return;
351        }
352        self.refresh_sketch_overlay();
353        self.dirty = true;
354    }
355
356    /// The active draw tool (`"point"|"line"|"rect"|"circle"|"arc"`), or `None` in
357    /// selection mode / when not in sketch mode.
358    pub fn sketch_active_tool(&self) -> Option<&str> {
359        self.sketch_edit
360            .as_ref()
361            .and_then(|edit| edit.session.tool.as_deref())
362    }
363
364    /// The number of in-progress draw-tool clicks buffered (0 in selection mode /
365    /// when not in sketch mode) — the UI/preview + verifier readout.
366    pub fn sketch_pending_len(&self) -> usize {
367        self.sketch_edit.as_ref().map_or(0, |edit| edit.pending.len())
368    }
369
370    /// A draw-tool click at CSS-pixel `(x, y)`: map to plane uv (the S2 pixel→plane
371    /// math) and drive the tool state machine. No-op when not in sketch mode, in
372    /// selection mode, or the ray misses the plane.
373    pub fn sketch_tool_click_at(&mut self, x: f64, y: f64) {
374        // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor — it needs the
375        // PIXEL coords (a scene pick), not a plane uv, so short-circuit before the
376        // pixel→plane projection (which would drop clicks that miss the plane).
377        if self.sketch_active_tool() == Some("pickEdges") {
378            self.sketch_pick_edge_at(x, y);
379            return;
380        }
381        let Some((u, v)) = self.sketch_uv_at(x, y) else {
382            return;
383        };
384        self.sketch_tool_place_uv(u, v);
385    }
386
387    /// The per-tool placement logic, in plane `(u, v)` (the headless-testable core
388    /// `sketch_tool_click_at` delegates to). Snaps to existing points within the grab
389    /// radius so shared vertices coincide; appends geometry and re-solves when a
390    /// primitive completes; carries the line chain via `pending`.
391    pub fn sketch_tool_place_uv(&mut self, u: f64, v: f64) {
392        let radius = self.sketch_pick_radius();
393        // Selection mode (no tool / "select") never places. Read the tool without
394        // holding a borrow so the trim branch can call back into `self`.
395        let tool = match self.sketch_edit.as_ref() {
396            Some(edit) => match edit.session.tool.clone() {
397                Some(tool) => tool,
398                None => return,
399            },
400            None => return,
401        };
402        // Trim (S6b) is a click tool that acts IMMEDIATELY on the geometry under the
403        // cursor — it never buffers `pending` or places a point. It owns its own undo
404        // snapshot (and pops it on a no-op), so short-circuit before the draw path.
405        if tool == "trim" {
406            self.sketch_trim_uv(u, v);
407            return;
408        }
409        // pickEdges is NOT a uv-placement tool — it acts on a 3D scene edge (routed via
410        // pixel coords in `sketch_tool_click_at`), so a stray uv place is a no-op here.
411        if tool == "pickEdges" {
412            return;
413        }
414        // handdraw (S6b-3) captures a DRAG as a stroke (routed via `sketch_handdraw_*`),
415        // not a click-placed point — a plain click is a no-op (and never records a dead
416        // undo step here, since we return before `record_undo`).
417        if tool == "handdraw" {
418            return;
419        }
420        let Some(edit) = self.sketch_edit.as_mut() else {
421            return;
422        };
423        // A draw tool is active → this click WILL mutate the doc (a point and/or a
424        // geometry); snapshot for undo before it does (S6a).
425        edit.record_undo();
426        let doc = &mut edit.session.doc;
427        match tool.as_str() {
428            "point" => {
429                doc.snap_or_add_point(u, v, radius);
430                edit.pending.clear();
431            }
432            "line" => {
433                if edit.pending.is_empty() {
434                    let a = doc.snap_or_add_point(u, v, radius);
435                    edit.pending.push(a);
436                } else {
437                    let start = edit.pending.last().cloned().expect("pending non-empty");
438                    let end = doc.snap_or_add_point(u, v, radius);
439                    push_sketch_geometry(doc, "line", vec![start, end.clone()]);
440                    // Continue the chain: the just-placed end is the next start.
441                    edit.pending = vec![end];
442                }
443            }
444            "rect" => {
445                if edit.pending.is_empty() {
446                    let a = doc.snap_or_add_point(u, v, radius);
447                    edit.pending.push(a);
448                } else {
449                    let a_id = edit.pending[0].clone();
450                    let Some((ax, ay)) = doc.point(&a_id).map(|p| (p.x, p.y)) else {
451                        edit.pending.clear();
452                        return;
453                    };
454                    let (bx, by) = (u, v);
455                    // Corners A=(ax,ay), (bx,ay), (bx,by), (ax,by) → 4 closed lines.
456                    let b1 = doc.snap_or_add_point(bx, ay, radius);
457                    let b2 = doc.snap_or_add_point(bx, by, radius);
458                    let b3 = doc.snap_or_add_point(ax, by, radius);
459                    push_sketch_geometry(doc, "line", vec![a_id.clone(), b1.clone()]);
460                    push_sketch_geometry(doc, "line", vec![b1.clone(), b2.clone()]);
461                    push_sketch_geometry(doc, "line", vec![b2.clone(), b3.clone()]);
462                    push_sketch_geometry(doc, "line", vec![b3.clone(), a_id.clone()]);
463                    // Keep the rectangle rectangular under drag: three ⟂ constraints on
464                    // the adjacent-edge pairs (the 4th corner's right angle follows from
465                    // the closed loop). This is the minimal rigid set — it removes 3 DOF
466                    // from the 8-DOF four-corner quad, leaving position (2) + rotation (1)
467                    // + width + height = 5 DOF, so the sketch is neither over-constrained
468                    // nor conflicting.
469                    push_rect_perpendicular_constraints(doc, [a_id, b1, b2, b3]);
470                    edit.pending.clear();
471                }
472            }
473            "circle" => {
474                if edit.pending.is_empty() {
475                    let c = doc.snap_or_add_point(u, v, radius);
476                    edit.pending.push(c);
477                } else {
478                    let center = edit.pending[0].clone();
479                    let r = doc.snap_or_add_point(u, v, radius);
480                    push_sketch_geometry(doc, "circle", vec![center, r]);
481                    edit.pending.clear();
482                }
483            }
484            "arc" => {
485                // Clicks: center, start, then end completes [center, start, end].
486                if edit.pending.len() < 2 {
487                    let p = doc.snap_or_add_point(u, v, radius);
488                    edit.pending.push(p);
489                } else {
490                    let center = edit.pending[0].clone();
491                    let start = edit.pending[1].clone();
492                    let end = doc.snap_or_add_point(u, v, radius);
493                    push_sketch_geometry(doc, "arc", vec![center, start, end]);
494                    edit.pending.clear();
495                }
496            }
497            "bezier" => {
498                // Cubic Bezier: 4 clicks place end0, ctrl0, ctrl1, end1 (in order).
499                // The 4th click commits the span [p0, p1, p2, p3] PLUS two dashed
500                // construction guide lines for the control handles (end0→ctrl0 and
501                // end1→ctrl1), matching the previous basic bezier tool.
502                // TODO(S3): chained multi-span bezier (3n+1 points, one geom per span).
503                if edit.pending.len() < 3 {
504                    let p = doc.snap_or_add_point(u, v, radius);
505                    edit.pending.push(p);
506                } else {
507                    let p0 = edit.pending[0].clone();
508                    let p1 = edit.pending[1].clone();
509                    let p2 = edit.pending[2].clone();
510                    let p3 = doc.snap_or_add_point(u, v, radius);
511                    push_sketch_geometry(
512                        doc,
513                        "bezier",
514                        vec![p0.clone(), p1.clone(), p2.clone(), p3.clone()],
515                    );
516                    // Construction guide lines (dashed, non-modeling) for the two
517                    // control handles — separate freshly minted geometry ids.
518                    push_sketch_construction_line(doc, vec![p0, p1]);
519                    push_sketch_construction_line(doc, vec![p3, p2]);
520                    edit.pending.clear();
521                }
522            }
523            _ => return,
524        }
525        // Every draw click mutates the doc (a new point and/or geometry); re-solve so
526        // coordinates + mobility stay fresh, keeping the doc if the solve fails.
527        self.resolve_active_sketch("draw-tool");
528        self.refresh_sketch_overlay();
529        self.dirty = true;
530    }
531
532    /// Abort the in-progress draw geometry (Escape / right-click): clear the pending
533    /// clicks + preview and refresh. No-op when not in sketch mode.
534    pub fn sketch_tool_cancel(&mut self) {
535        if let Some(edit) = self.sketch_edit.as_mut() {
536            edit.pending.clear();
537        } else {
538            return;
539        }
540        self.refresh_sketch_overlay();
541        self.dirty = true;
542    }
543}
544
545/// Normalize a tool name to the stored form: `None`/`"select"`/`""` → selection mode
546/// (`None`), else the tool string (`"point"|"line"|"rect"|"circle"|"arc"|"bezier"|
547/// "trim"|"pickEdges"|"handdraw"`).
548fn normalize_sketch_tool(tool: Option<&str>) -> Option<String> {
549    match tool {
550        None | Some("select") | Some("") => None,
551        Some(t) => Some(t.to_string()),
552    }
553}
554
555/// Append a geometry to a sketch doc with a freshly minted id (the caller passes the
556/// solver `type` — `rect` corners are pushed as `line`s), carrying an explicit
557/// `construction: false` so it matches the authored shape and round-trips.
558fn push_sketch_geometry(
559    doc: &mut crate::sketch::SketchDoc,
560    geom_type: &str,
561    points: Vec<serde_json::Value>,
562) {
563    let id = doc.next_geometry_id();
564    let mut extra = serde_json::Map::new();
565    extra.insert("construction".to_string(), serde_json::Value::Bool(false));
566    doc.geometries.push(crate::sketch::SketchGeometry {
567        id,
568        geom_type: geom_type.to_string(),
569        points,
570        extra,
571    });
572}
573
574/// Append a CONSTRUCTION `line` geometry (dashed, non-modeling — `construction: true`)
575/// with a freshly minted id: the bezier tool's control-handle guide lines. Mirrors
576/// [`push_sketch_geometry`] but flips the construction flag so the line renders dashed
577/// and is excluded from profiles while still being constrainable.
578fn push_sketch_construction_line(
579    doc: &mut crate::sketch::SketchDoc,
580    points: Vec<serde_json::Value>,
581) {
582    let id = doc.next_geometry_id();
583    let mut extra = serde_json::Map::new();
584    extra.insert("construction".to_string(), serde_json::Value::Bool(true));
585    doc.geometries.push(crate::sketch::SketchGeometry {
586        id,
587        geom_type: "line".to_string(),
588        points,
589        extra,
590    });
591}
592
593/// Append the three perpendicular (`⟂`) constraints that keep a freshly drawn
594/// rectangle rectangular when a corner is dragged. `corners` are the rect's four
595/// points in loop order `[a, b1, b2, b3]` (edges a→b1, b1→b2, b2→b3, b3→a); the
596/// constraints go on the adjacent-edge pairs sharing corners b1 / b2 / b3. The fourth
597/// corner (a) is left implied — a closed quad with three right angles is a rectangle —
598/// so this is the MINIMAL rigid set (3 equations, no over-constraint / redundancy).
599///
600/// Each `⟂` stores the two edges' endpoint pairs `[l1a, l1b, l2a, l2b]`, swap-oriented
601/// exactly like a palette-added perpendicular ([`sketch_build_and_add_constraint`]), and
602/// is deduped on its signature. No-op unless the four corners are all distinct (a
603/// degenerate rect whose corners snapped together would otherwise carry a `⟂` on a
604/// zero-length edge, which is meaningless and can wedge the solver).
605fn push_rect_perpendicular_constraints(
606    doc: &mut crate::sketch::SketchDoc,
607    corners: [serde_json::Value; 4],
608) {
609    use crate::sketch::doc::id_key;
610    let [a, b1, b2, b3] = corners;
611    let keys = [id_key(&a), id_key(&b1), id_key(&b2), id_key(&b3)];
612    for i in 0..keys.len() {
613        for j in (i + 1)..keys.len() {
614            if keys[i] == keys[j] {
615                return; // two corners collapsed → skip (no zero-length-edge ⟂).
616            }
617        }
618    }
619    // Adjacent edge pairs sharing corner b1 / b2 / b3.
620    let pairs = [
621        [a.clone(), b1.clone(), b1.clone(), b2.clone()],
622        [b1.clone(), b2.clone(), b2.clone(), b3.clone()],
623        [b2.clone(), b3.clone(), b3.clone(), a.clone()],
624    ];
625    for pair in pairs {
626        let mut pts = pair.to_vec();
627        if sketch_perpendicular_should_swap(doc, &pts) {
628            pts.swap(0, 1);
629        }
630        push_geometric_constraint(doc, "⟂", pts);
631    }
632}
633
634/// Append a NON-dimensional geometric constraint (`type` + ordered `points`) with a
635/// freshly minted id and the same base fields a palette-added constraint carries
636/// (`labelX`/`labelY` = 0, `displayStyle` = "", `value` = null, `valueNeedsSetup` =
637/// true — see [`sketch_build_and_add_constraint`]). Deduped on `type + sorted-points`
638/// (the solver runs with `remove_implied_duplicates: false`, so this is the only
639/// dedup); a no-op on a duplicate.
640fn push_geometric_constraint(
641    doc: &mut crate::sketch::SketchDoc,
642    ctype: &str,
643    points: Vec<serde_json::Value>,
644) {
645    let sig = sketch_constraint_signature(ctype, &points);
646    let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
647        Some(t) => sketch_constraint_signature(t, c.points()) == sig,
648        None => false,
649    });
650    if duplicate {
651        return;
652    }
653    let id = doc.next_constraint_id();
654    let mut raw = serde_json::Map::new();
655    raw.insert("id".to_string(), id);
656    raw.insert("type".to_string(), serde_json::Value::String(ctype.to_string()));
657    raw.insert("points".to_string(), serde_json::Value::Array(points));
658    raw.insert("labelX".to_string(), serde_json::Value::from(0));
659    raw.insert("labelY".to_string(), serde_json::Value::from(0));
660    raw.insert(
661        "displayStyle".to_string(),
662        serde_json::Value::String(String::new()),
663    );
664    raw.insert("value".to_string(), serde_json::Value::Null);
665    raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
666    doc.constraints.push(crate::sketch::SketchConstraint { raw });
667}
668
669// ===========================================================================
670// Sketch delete-selected (S3b) — remove the selected entities + orphan cleanup.
671//
672// Operates on the active `self.sketch_edit`. Rule (chosen so a remaining geometry
673// NEVER references a missing point):
674//   1. Partition the selection into selected geometry / point / constraint ids.
675//   2. Drop every geometry that is SELECTED *or* references any selected point (the
676//      remove-point cascade — deleting a vertex kills geometry that used it).
677//   3. Drop the selected points.
678//   4. Orphan cleanup: drop any remaining point NOT referenced by any surviving
679//      geometry (a shared vertex — still referenced — stays; a deleted line's now
680//      unshared endpoints vanish). Always on for this slice.
681//   5. Drop any constraint that is SELECTED *or* references a removed point (selected ∪
682//      orphaned) — done LAST, over the full removed-point set, so no constraint dangles
683//      either. A selected constraint drops ONLY itself; the geometry/points it
684//      referenced are untouched (deleting a constraint never deletes geometry).
685// Then clear selection + hover, re-solve (swallowing errors), refresh, mark dirty.
686// Kept in ONE appended block so concurrent edits to the primary impl land clean.
687// ===========================================================================
688impl EngineState {
689    /// Delete the selected sketch entities (S3b): the selected geometries + points,
690    /// plus any geometry orphaned by a deleted vertex, plus orphaned points and the
691    /// constraints referencing any removed point. Re-solves + refreshes the overlay.
692    /// Returns `true` when something was deleted; `false` when not in sketch mode or
693    /// the selection is empty.
694    pub fn sketch_delete_selection(&mut self) -> bool {
695        use crate::sketch::doc::id_key;
696        use std::collections::HashSet;
697
698        let Some(edit) = self.sketch_edit.as_mut() else {
699            return false;
700        };
701        if edit.session.selection.is_empty() {
702            return false;
703        }
704        // A non-empty selection always removes something → snapshot for undo (S6a).
705        edit.record_undo();
706
707        // 1. Partition the selection into selected geometry / point / constraint ids
708        //    (keyed via `id_key`, so 4 / 4.0 / "4" all match).
709        let mut sel_geo: HashSet<String> = HashSet::new();
710        let mut sel_pt: HashSet<String> = HashSet::new();
711        let mut sel_constraint: HashSet<String> = HashSet::new();
712        for r in &edit.session.selection {
713            match (r.get("kind").and_then(|v| v.as_str()), r.get("id")) {
714                (Some("geometry"), Some(id)) => {
715                    sel_geo.insert(id_key(id));
716                }
717                (Some("point"), Some(id)) => {
718                    sel_pt.insert(id_key(id));
719                }
720                (Some("constraint"), Some(id)) => {
721                    sel_constraint.insert(id_key(id));
722                }
723                _ => {}
724            }
725        }
726
727        let doc = &mut edit.session.doc;
728
729        // 2. Drop geometries that are selected OR reference any selected point (so a
730        //    deleted vertex never leaves a geometry dangling).
731        doc.geometries.retain(|g| {
732            if sel_geo.contains(&id_key(&g.id)) {
733                return false;
734            }
735            !g.points.iter().any(|pid| sel_pt.contains(&id_key(pid)))
736        });
737
738        // 3. Drop the explicitly-selected points.
739        doc.points.retain(|p| !sel_pt.contains(&id_key(&p.id)));
740
741        // 4. Orphan cleanup: drop points no longer referenced by any surviving
742        //    geometry. Accumulate every removed point id (selected ∪ orphaned).
743        let referenced: HashSet<String> = doc
744            .geometries
745            .iter()
746            .flat_map(|g| g.points.iter().map(id_key))
747            .collect();
748        let mut removed_pts = sel_pt;
749        doc.points.retain(|p| {
750            let key = id_key(&p.id);
751            if referenced.contains(&key) {
752                true
753            } else {
754                removed_pts.insert(key);
755                false
756            }
757        });
758
759        // 5. Drop constraints that are EXPLICITLY selected OR reference ANY removed
760        //    point (done last, over the full removed set, so no constraint dangles onto
761        //    a missing point). A selected constraint drops ONLY itself — the geometry /
762        //    points it references are left intact (deleting a constraint never deletes
763        //    geometry).
764        doc.constraints.retain(|c| {
765            if let Some(id) = c.raw.get("id") {
766                if sel_constraint.contains(&id_key(id)) {
767                    return false;
768                }
769            }
770            !c.points().iter().any(|pid| removed_pts.contains(&id_key(pid)))
771        });
772
773        // Clear the interaction state, re-solve (keep the doc on failure), refresh.
774        edit.session.clear_selection();
775        edit.session.set_hover(None);
776        self.resolve_active_sketch("delete");
777        self.refresh_sketch_overlay();
778        self.dirty = true;
779        true
780    }
781}
782
783// ===========================================================================
784// Sketch constraint palette (S4) — selection → applicable constraints + apply.
785//
786// A faithful port of the previous sketcher's TWO authoritative pieces, kept engine-side:
787//   * `SketchMode3D.#refreshContextBar` → [`sketch_applicable_constraints`] (which
788//     selection surfaces which palette buttons).
789//   * `ConstraintEngine.createConstraint` → [`sketch_add_constraint`] (selection →
790//     ordered point-id list per symbol, incl. the arc-pop, the geometry-role
791//     specials `◎/⊜/⌒/⋰/⋈`, the `⋯` point-first reverse, the `⟂` 4-point
792//     orientation swap, and the `⏛`-from-2-lines DOUBLE push).
793//
794// Dimensional constraints (`⟺ ↥ ∠ R ⌀`) are added with `value:null` +
795// `valueNeedsSetup:true` (matching the previous app) — the Rust solver seeds a NaN target to
796// the CURRENT measured value on the next solve (`c_distance`/`c_angle`), so S4 never
797// prompts for a value (S5 makes it editable). Every constraint carries the
798// base fields (`labelX/labelY/displayStyle`) so a load/save round-trips unchanged.
799//
800// Adds are DEDUP'd on `type + sorted-point-ids` (the solver runs with
801// `remove_implied_duplicates:false`, so this is the only dedup). Kept in ONE
802// appended block so concurrent edits to the primary impl land clean.
803// ===========================================================================
804