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// Sketch picking maps camera rays to plane-space coordinates. Hit tests prefer
5// points over geometry within the grab radius. Mutators refresh the live overlay
6// and mark the engine dirty; calls outside sketch mode are no-ops.
7impl EngineState {
8    /// Re-push the sketch overlay reflecting the live hover + selection. Reads the
9    /// active `sketch_edit`'s session; a no-op when not in sketch mode. Called at
10    /// the end of every S2 mutator (the reusable counterpart of the initial
11    /// [`set_sketch_overlay`](Self::set_sketch_overlay) push).
12    pub(super) fn refresh_sketch_overlay(&mut self) {
13        let world_per_pixel = self.camera.world_per_pixel();
14        let (json, preview, leaders, glyphs) = match self.sketch_edit.as_ref() {
15            Some(edit) => (
16                edit.session.overlay_json_with_state(world_per_pixel),
17                edit.session.preview_overlay_json(
18                    world_per_pixel,
19                    &edit.pending,
20                    edit.hover_uv,
21                    &edit.handdraw_stroke,
22                ),
23                edit.session.dim_leaders_overlay_json_with_state(world_per_pixel),
24                edit.session
25                    .constraint_glyphs_overlay_json_with_state(world_per_pixel),
26            ),
27            None => return,
28        };
29        let _ = self.set_overlay_json(&json);
30        // The draw-tool rubber-band rides in its own `sketch-preview` group so it
31        // upserts/clears independently of the solved geometry + point groups.
32        let _ = self.set_overlay_json(&preview);
33        // The dimension leaders + arrows ride in `sketch-dim-leaders`, refreshed
34        // alongside everything else (S5).
35        let _ = self.set_overlay_json(&leaders);
36        // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
37        let _ = self.set_overlay_json(&glyphs);
38        // Remember the zoom these groups were baked at: their construction dashes,
39        // dimension arrowheads and constraint glyphs are all screen-constant, so
40        // `ensure_sketch_overlay_current` re-bakes them when it moves.
41        self.sketch_overlay_wpp = if world_per_pixel > 0.0 {
42            world_per_pixel
43        } else {
44            f64::MIN_POSITIVE
45        };
46    }
47
48    /// Per-frame upkeep for the live SKETCH overlay (driven by
49    /// [`Self::ensure_overlays_current`]) — the sketch-mode sibling of
50    /// [`Self::ensure_feature_dimension_overlay_current`]. The dimension leaders
51    /// (draggable), constraint glyphs and construction dashes are sized in PIXELS
52    /// against the camera at bake time, so a zoom leaves them stale until
53    /// something else mutates the sketch. Re-bakes on a material
54    /// `world_per_pixel` change only, so a quiet frame stays quiet.
55    pub(super) fn ensure_sketch_overlay_current(&mut self) {
56        if !self.sketch_mode() {
57            self.sketch_overlay_wpp = 0.0;
58            return;
59        }
60        let wpp = self.camera.world_per_pixel();
61        if super::overlay_wpp_stale(self.sketch_overlay_wpp, wpp) {
62            self.refresh_sketch_overlay();
63        }
64    }
65
66    /// Map CSS-pixel `(x, y)` to the active sketch plane's `(u, v)` via the camera
67    /// pick ray ∩ the sketch plane. `None` when not in sketch mode or the ray misses
68    /// the plane (parallel / behind).
69    pub fn sketch_uv_at(&self, x: f64, y: f64) -> Option<(f64, f64)> {
70        let edit = self.sketch_edit.as_ref()?;
71        let ray = self.camera.pick_ray(x, y);
72        crate::sketch::ray_plane_uv(&edit.session.plane, ray.origin, ray.dir)
73    }
74
75    /// The world-space pick tolerance at the current zoom — the ONE radius that
76    /// drives hover-highlight, click-select, drag-grab, draw-snap AND trim, for
77    /// points AND geometry, so "what highlights" is exactly "what you can grab".
78    /// Sized at 1.5× the visualized point (`POINT_SIZE_PX`) for forgiving clicking.
79    pub(super) fn sketch_pick_radius(&self) -> f64 {
80        f64::from(crate::sketch::tessellate::POINT_SIZE_PX) * 1.5 * self.camera.world_per_pixel()
81    }
82
83    /// The entity ref under CSS-pixel `(x, y)` within the grab radius, or `None`.
84    /// Priority is points > geometry > constraint: [`pick_entity`] resolves the first
85    /// two, and only when neither is in range do we consult [`pick_constraint`] (a glyph
86    /// or dimension leader that overlaps a point/edge never shadows it).
87    ///
88    /// [`pick_entity`]: crate::sketch::SketchSession::pick_entity
89    /// [`pick_constraint`]: crate::sketch::SketchSession::pick_constraint
90    fn sketch_entity_at(&self, x: f64, y: f64) -> Option<serde_json::Value> {
91        let (u, v) = self.sketch_uv_at(x, y)?;
92        let radius = self.sketch_pick_radius();
93        let wpp = self.camera.world_per_pixel();
94        self.sketch_edit.as_ref().and_then(|edit| {
95            edit.session
96                .pick_entity(u, v, radius)
97                .or_else(|| edit.session.pick_constraint(u, v, radius, wpp))
98        })
99    }
100
101    /// Set (or clear) the sketch hover, re-pushing the overlay + marking dirty only
102    /// when it actually changed. Returns whether the hover changed.
103    pub(super) fn set_sketch_hover(&mut self, new_hover: Option<serde_json::Value>) -> bool {
104        let changed = match self.sketch_edit.as_ref() {
105            Some(edit) => {
106                !crate::sketch::entity_ref_eq(edit.session.selection.hovered.as_ref(), new_hover.as_ref())
107            }
108            None => false,
109        };
110        if changed {
111            if let Some(edit) = self.sketch_edit.as_mut() {
112                edit.session.set_hover(new_hover);
113            }
114            self.refresh_sketch_overlay();
115            self.dirty = true;
116        }
117        changed
118    }
119
120    /// Update the sketch hover to the entity under CSS-pixel `(x, y)` (S2). Returns
121    /// whether the hover changed. A no-op returning `false` when not in sketch mode.
122    pub fn sketch_hover_at(&mut self, x: f64, y: f64) -> bool {
123        if self.sketch_edit.is_none() {
124            return false;
125        }
126        // Track the live cursor uv for the S3a rubber-band. In DRAW mode with pending
127        // clicks the preview follows the cursor even when the hovered ENTITY is
128        // unchanged, so force an overlay refresh there.
129        let uv = self.sketch_uv_at(x, y);
130        let preview_live = match self.sketch_edit.as_mut() {
131            Some(edit) => {
132                edit.hover_uv = uv;
133                edit.session.tool.is_some() && !edit.pending.is_empty()
134            }
135            None => false,
136        };
137        let new_hover = self.sketch_entity_at(x, y);
138        let changed = self.set_sketch_hover(new_hover);
139        if preview_live && !changed {
140            self.refresh_sketch_overlay();
141            self.dirty = true;
142        }
143        changed
144    }
145
146    /// Clear the sketch hover (pointer left the viewport / moved over the ViewCube).
147    /// Returns whether a hover was cleared.
148    pub fn sketch_clear_hover(&mut self) -> bool {
149        self.set_sketch_hover(None)
150    }
151
152    /// Click-select in sketch mode: pick the entity under `(x, y)`; nothing → clear
153    /// the selection. Otherwise honor the SAME "Multi-select" setting the 3D viewport
154    /// reads (`settings.multi_select`): under `ClickToggles` a plain click toggles the
155    /// hit in the set (no modifier needed for a multi-selection); under
156    /// `CtrlClick` a plain click replaces the set with just the hit and `additive`
157    /// (Ctrl/Cmd) toggles. Re-pushes the overlay + marks dirty.
158    pub fn sketch_click_at(&mut self, x: f64, y: f64, additive: bool) {
159        // Read the setting before the mutable `sketch_edit` borrow.
160        let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
161        let hit = self.sketch_entity_at(x, y);
162        let Some(edit) = self.sketch_edit.as_mut() else {
163            return;
164        };
165        match hit {
166            None => edit.session.clear_selection(),
167            Some(entity_ref) => {
168                if additive || toggles {
169                    edit.session.toggle_selection(entity_ref);
170                } else {
171                    edit.session.clear_selection();
172                    edit.session.toggle_selection(entity_ref);
173                }
174            }
175        }
176        self.refresh_sketch_overlay();
177        self.dirty = true;
178    }
179
180    /// Begin a point drag if a DRAGGABLE point is under `(x, y)` (S2): remember it
181    /// (id + original `fixed` flag). Returns `true` iff a point was grabbed (the
182    /// viewport routes the drag to the sketch; otherwise it orbits the camera). A
183    /// locked / fully-constrained point is not draggable, so an empty-space or
184    /// locked-point drag falls through to a camera orbit.
185    pub fn sketch_drag_begin(&mut self, x: f64, y: f64) -> bool {
186        let Some((u, v)) = self.sketch_uv_at(x, y) else {
187            return false;
188        };
189        let radius = self.sketch_pick_radius();
190        // Grab EXACTLY what's HIGHLIGHTED: the hovered entity was picked at the exact
191        // cursor position on the last move, so it is immune to egui reporting the
192        // drag-start ~6px into the gesture (the "highlighted but won't grab"
193        // intermittency — and it's what lets a whole geometry drag). A hovered LOCKED
194        // point yields `None` → the drag falls through to a camera gesture; it must
195        // NOT positional-fall-back there (that would grab a nearby UNhighlighted
196        // point). Only an EMPTY hover (a press with no prior move) falls back to a
197        // fresh positional pick.
198        let points = self.sketch_edit.as_ref().and_then(|edit| {
199            match edit.session.selection.hovered.as_ref() {
200                Some(entity_ref) => edit.session.drag_points_from_ref(entity_ref),
201                None => edit
202                    .session
203                    .pick_draggable_point(u, v, radius)
204                    .and_then(|(id, fixed)| {
205                        edit.session
206                            .doc
207                            .point(&id)
208                            .map(|p| vec![(id, p.x, p.y, fixed)])
209                    }),
210            }
211        });
212        let Some(points) = points else {
213            return false;
214        };
215        if let Some(edit) = self.sketch_edit.as_mut() {
216            // Snapshot ONCE at the gesture start so the whole drag is one undo step
217            // (S6a); `sketch_drag_to` never snapshots. A grab that moves nothing is
218            // discarded in `sketch_drag_end`.
219            edit.record_undo();
220            let infer_on_drop = edit.session.selection.hovered.as_ref()
221                .and_then(|entity| entity.get("kind"))
222                .and_then(serde_json::Value::as_str) != Some("geometry");
223            edit.drag = Some(SketchDrag { points, anchor: (u, v), infer_on_drop });
224        }
225        true
226    }
227
228    /// Drag the grabbed target to `(x, y)` (S2): pin every grabbed point at its
229    /// ORIGINAL position plus the cursor delta (`fixed = true`) so the solver anchors
230    /// the whole shape there, re-solve, then restore each point's ORIGINAL `fixed`
231    /// flag. Absolute-from-anchor (never incremental), so a rigid geometry translate
232    /// tracks the cursor 1:1 without drifting as the solver nudges points between
233    /// frames. A resolve error rolls every grabbed point back to its pre-drag coords
234    /// (the last good state). No-op when nothing is grabbed / not in sketch mode / the
235    /// ray misses the plane.
236    pub fn sketch_drag_to(&mut self, x: f64, y: f64) {
237        let Some((u, v)) = self.sketch_uv_at(x, y) else {
238            return;
239        };
240        let Some(edit) = self.sketch_edit.as_mut() else {
241            return;
242        };
243        let Some(drag) = edit.drag.clone() else {
244            return;
245        };
246        let (du, dv) = (u - drag.anchor.0, v - drag.anchor.1);
247        let session = &mut edit.session;
248        let before = session.doc.clone();
249        for (id, ox, oy, _) in &drag.points {
250            if let Some(p) = session.doc.point_mut(id) {
251                p.x = *ox + du;
252                p.y = *oy + dv;
253                p.fixed = true;
254            }
255        }
256        match session.resolve() {
257            Ok(()) => {
258                for (id, _, _, orig_fixed) in &drag.points {
259                    if let Some(p) = session.doc.point_mut(id) {
260                        p.fixed = *orig_fixed;
261                    }
262                }
263            }
264            Err(_) => {
265                // Unsolvable target: roll every grabbed point back to its pre-drag
266                // coords + flag (keep the last good state).
267                for (id, ox, oy, orig_fixed) in &drag.points {
268                    if let Some(p) = session.doc.point_mut(id) {
269                        p.x = *ox;
270                        p.y = *oy;
271                        p.fixed = *orig_fixed;
272                    }
273                }
274            }
275        }
276        // A drag can collapse a free radius without producing a solver error.
277        // Preserve the last valid geometry, while tolerating pre-existing broken
278        // circles in imported documents so they can still be edited/repaired.
279        if newly_collapsed_radial_geometry(&before, &session.doc) {
280            session.doc = before;
281            let _ = session.resolve();
282        }
283        self.refresh_sketch_overlay();
284        self.dirty = true;
285    }
286
287    /// End a point drag (S2): clear the grab, then one final re-solve + overlay
288    /// refresh. No-op when no drag is live.
289    pub fn sketch_drag_end(&mut self) {
290        let had_grab = self
291            .sketch_edit
292            .as_ref()
293            .map_or(false, |edit| edit.drag.is_some());
294        if !had_grab {
295            return;
296        }
297        // Drop radius for constraint inference == the point grab radius (12 px in
298        // world units) — read before the `&mut` borrow of `sketch_edit`.
299        let drop_tol = self.sketch_pick_radius();
300        if let Some(edit) = self.sketch_edit.as_mut() {
301            let before = edit.session.doc.clone();
302            // Drop-time inference (S6c): a SINGLE-point drop snaps to a coincident
303            // point / point-on-line at the release position, mirroring the previous
304            // coincident-on-drop / point-on-line-on-drop inference. A whole-
305            // geometry drag never infers, even with only one movable endpoint:
306            // inference could glue or collapse the curve in one solve.
307            let dragged_single = edit.drag.as_ref().and_then(|drag| {
308                (drag.infer_on_drop && drag.points.len() == 1).then(|| drag.points[0].0.clone())
309            });
310            edit.drag = None;
311            if let Some(point_id) = dragged_single {
312                crate::sketch::infer::infer_drop_constraint(
313                    &mut edit.session.doc,
314                    &point_id,
315                    drop_tol,
316                );
317            }
318            let _ = edit.session.resolve();
319            if newly_collapsed_radial_geometry(&before, &edit.session.doc) {
320                edit.session.doc = before;
321                let _ = edit.session.resolve();
322            }
323            // Discard the drag's undo snapshot when the doc is unchanged (a mere
324            // grab-and-release, no move and no inferred constraint), so it neither
325            // pollutes undo nor clobbers redo. An inferred constraint changes the
326            // doc, so the snapshot is kept — one Ctrl+Z then undoes move+constraint.
327            if edit
328                .undo_stack
329                .last()
330                .map_or(false, |snap| snap.doc == edit.session.doc)
331            {
332                edit.undo_stack.pop();
333            }
334        }
335        self.refresh_sketch_overlay();
336        self.dirty = true;
337    }
338
339    /// The number of selected sketch entities (0 when not in sketch mode) — the mode
340    /// bar / verifier readout.
341    pub fn sketch_selection_count(&self) -> usize {
342        self.sketch_edit
343            .as_ref()
344            .map_or(0, |edit| edit.session.selection.items.len())
345    }
346
347    /// The number of selected CONSTRAINTS (refs whose `kind` is `"constraint"`; 0 when
348    /// not in sketch mode) — the `__brepSketch` verifier readout for constraint
349    /// selection + delete.
350    pub fn sketch_selected_constraint_count(&self) -> usize {
351        self.sketch_edit.as_ref().map_or(0, |edit| {
352            edit.session
353                .selection.items
354                .iter()
355                .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("constraint"))
356                .count()
357        })
358    }
359}
360
361fn radial_distance(doc: &crate::sketch::SketchDoc, points: &[serde_json::Value]) -> f64 {
362    match (doc.point(&points[0]), doc.point(&points[1])) {
363        (Some(a), Some(b)) => (b.x - a.x).hypot(b.y - a.y),
364        _ => 0.0,
365    }
366}
367
368fn newly_collapsed_radial_geometry(
369    before: &crate::sketch::SketchDoc,
370    after: &crate::sketch::SketchDoc,
371) -> bool {
372    before.geometries.iter().any(|g| {
373        matches!(g.geom_type.as_str(), "circle" | "arc")
374            && g.points.len() >= 2
375            && radial_distance(before, &g.points) > 1e-5
376            && radial_distance(after, &g.points) <= 1e-5
377    })
378}
379
380// ===========================================================================
381// Sketch draw tools (S3a) — primitive placement: point / line / rect / circle / arc.
382//
383// A click-state machine over the active `self.sketch_edit`. The active tool lives
384// on `session.tool` ("select"/None = selection mode, S2); a DRAW tool routes clicks
385// to `sketch_tool_click_at` (pixel → plane uv via the same S2 `sketch_uv_at`, then
386// `sketch_tool_place_uv`). Points/geometries are minted through `SketchDoc`
387// (`next_point_id`/`next_geometry_id` + `snap_or_add_point`, so shared vertices
388// coincide). Each placement re-solves (swallowing solve errors), re-pushes the
389// overlay (incl. the rubber-band preview), and marks dirty. The bezier tool has a
390// second mode on the same click: idle, a click ON an existing spline REFINES it
391// rather than starting a new one (`crate::sketch::spline`). Kept in ONE appended
392// block so concurrent edits to the primary impl land clean.
393// ===========================================================================
394impl EngineState {
395    /// Set (or clear) the active draw tool: `"select"`/`None` → selection mode (S2);
396    /// `"point"|"line"|"rect"|"circle"|"arc"|"bezier"` arm the corresponding draw
397    /// tool; `"handdraw"` arms the freehand stroke tool (S6b-3); `"trim"` arms the
398    /// trim tool (S6b); `"pickEdges"` arms the external-edge link tool (S6b-2). Clears
399    /// any in-progress click buffer + preview and refreshes the overlay. No-op when not
400    /// in sketch mode.
401    pub fn sketch_set_tool(&mut self, tool: Option<&str>) {
402        let normalized = normalize_sketch_tool(tool);
403        if let Some(edit) = self.sketch_edit.as_mut() {
404            edit.session.tool = normalized;
405            edit.pending.clear();
406            edit.hover_uv = None;
407            edit.handdraw_stroke.clear();
408        } else {
409            return;
410        }
411        // A context edge lit for pickEdges is only that tool's to show.
412        if self.sketch_active_tool() != Some("pickEdges") {
413            self.hover_context(None);
414        }
415        self.refresh_sketch_overlay();
416        self.dirty = true;
417    }
418
419    /// The active draw tool (`"point"|"line"|"rect"|"circle"|"arc"`), or `None` in
420    /// selection mode / when not in sketch mode.
421    pub fn sketch_active_tool(&self) -> Option<&str> {
422        self.sketch_edit
423            .as_ref()
424            .and_then(|edit| edit.session.tool.as_deref())
425    }
426
427    /// The number of in-progress draw-tool clicks buffered (0 in selection mode /
428    /// when not in sketch mode) — the UI/preview + verifier readout.
429    pub fn sketch_pending_len(&self) -> usize {
430        self.sketch_edit.as_ref().map_or(0, |edit| edit.pending.len())
431    }
432
433    /// A draw-tool click at CSS-pixel `(x, y)`: map to plane uv (the S2 pixel→plane
434    /// math) and drive the tool state machine. No-op when not in sketch mode, in
435    /// selection mode, or the ray misses the plane.
436    pub fn sketch_tool_click_at(&mut self, x: f64, y: f64) {
437        // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor — it needs the
438        // PIXEL coords (a scene pick), not a plane uv, so short-circuit before the
439        // pixel→plane projection (which would drop clicks that miss the plane).
440        if self.sketch_active_tool() == Some("pickEdges") {
441            self.sketch_pick_edge_at(x, y);
442            return;
443        }
444        let Some((u, v)) = self.sketch_uv_at(x, y) else {
445            return;
446        };
447        self.sketch_tool_place_uv(u, v);
448    }
449
450    /// The per-tool placement logic, in plane `(u, v)` (the headless-testable core
451    /// `sketch_tool_click_at` delegates to). Snaps to existing points within the grab
452    /// radius so shared vertices coincide; appends geometry and re-solves when a
453    /// primitive completes; carries the line chain via `pending`.
454    pub fn sketch_tool_place_uv(&mut self, u: f64, v: f64) {
455        let radius = self.sketch_pick_radius();
456        // Selection mode (no tool / "select") never places. Read the tool without
457        // holding a borrow so the trim branch can call back into `self`.
458        let tool = match self.sketch_edit.as_ref() {
459            Some(edit) => match edit.session.tool.clone() {
460                Some(tool) => tool,
461                None => return,
462            },
463            None => return,
464        };
465        // Trim (S6b) is a click tool that acts IMMEDIATELY on the geometry under the
466        // cursor — it never buffers `pending` or places a point. It owns its own undo
467        // snapshot (and pops it on a no-op), so short-circuit before the draw path.
468        if tool == "trim" {
469            self.sketch_trim_uv(u, v);
470            return;
471        }
472        // pickEdges is NOT a uv-placement tool — it acts on a 3D scene edge (routed via
473        // pixel coords in `sketch_tool_click_at`), so a stray uv place is a no-op here.
474        if tool == "pickEdges" {
475            return;
476        }
477        // handdraw (S6b-3) captures a DRAG as a stroke (routed via `sketch_handdraw_*`),
478        // not a click-placed point — a plain click is a no-op (and never records a dead
479        // undo step here, since we return before `record_undo`).
480        if tool == "handdraw" {
481            return;
482        }
483        let Some(edit) = self.sketch_edit.as_mut() else {
484            return;
485        };
486        // Check the SNAP RESULT, not just the cursor: a nearby radius click can
487        // reuse the center id. Keep waiting for a valid radius/end without adding
488        // points or an undo step. Arc endpoints must also be off the center.
489        if matches!(tool.as_str(), "circle" | "arc") && !edit.pending.is_empty() {
490            let doc = &edit.session.doc;
491            let Some(center) = doc.point(&edit.pending[0]) else { return; };
492            let snapped = doc.points.iter()
493                .filter(|p| (p.x - u).hypot(p.y - v) <= radius)
494                .min_by(|a, b| (a.x - u).hypot(a.y - v).total_cmp(&(b.x - u).hypot(b.y - v)));
495            let (x, y) = snapped.map_or((u, v), |p| (p.x, p.y));
496            let distance = (x - center.x).hypot(y - center.y);
497            if !distance.is_finite() || distance <= 1e-5 {
498                return;
499            }
500        }
501        if tool == "text" && !edit.pending.is_empty() {
502            if let Some(a) = edit.session.doc.point(&edit.pending[0]) {
503                if (u-a.x).abs() < 1e-5 || (v-a.y).abs() < 1e-5 { return; }
504            }
505        }
506        // A draw tool is active → this click WILL mutate the doc (a point and/or a
507        // geometry); snapshot for undo before it does (S6a).
508        edit.record_undo();
509        let doc = &mut edit.session.doc;
510        match tool.as_str() {
511            "point" => {
512                doc.snap_or_add_point(u, v, radius);
513                edit.pending.clear();
514            }
515            "line" => {
516                if edit.pending.is_empty() {
517                    let a = doc.snap_or_add_point(u, v, radius);
518                    edit.pending.push(a);
519                } else {
520                    let start = edit.pending.last().cloned().expect("pending non-empty");
521                    let end = doc.snap_or_add_point(u, v, radius);
522                    push_sketch_geometry(doc, "line", vec![start, end.clone()]);
523                    // Continue the chain: the just-placed end is the next start.
524                    edit.pending = vec![end];
525                }
526            }
527            "rect" | "text" => {
528                let radius = if tool == "text" { -1.0 } else { radius };
529                if edit.pending.is_empty() {
530                    let a = doc.snap_or_add_point(u, v, radius);
531                    edit.pending.push(a);
532                } else {
533                    let a_id = edit.pending[0].clone();
534                    let Some((ax, ay)) = doc.point(&a_id).map(|p| (p.x, p.y)) else {
535                        edit.pending.clear();
536                        return;
537                    };
538                    let (ax, ay, bx, by) = if tool == "text" {
539                        (ax.min(u), ay.min(v), ax.max(u), ay.max(v))
540                    } else { (ax, ay, u, v) };
541                    if tool == "text" {
542                        if let Some(p) = doc.point_mut(&a_id) { p.x = ax; p.y = ay; }
543                    }
544                    // Corners A=(ax,ay), (bx,ay), (bx,by), (ax,by) → 4 closed lines.
545                    let b1 = doc.snap_or_add_point(bx, ay, radius);
546                    let b2 = doc.snap_or_add_point(bx, by, radius);
547                    let b3 = doc.snap_or_add_point(ax, by, radius);
548                    push_sketch_geometry(doc, "line", vec![a_id.clone(), b1.clone()]);
549                    push_sketch_geometry(doc, "line", vec![b1.clone(), b2.clone()]);
550                    push_sketch_geometry(doc, "line", vec![b2.clone(), b3.clone()]);
551                    push_sketch_geometry(doc, "line", vec![b3.clone(), a_id.clone()]);
552                    // Keep the rectangle rectangular under drag: three ⟂ constraints on
553                    // the adjacent-edge pairs (the 4th corner's right angle follows from
554                    // the closed loop). This is the minimal rigid set — it removes 3 DOF
555                    // from the 8-DOF four-corner quad, leaving position (2) + rotation (1)
556                    // + width + height = 5 DOF, so the sketch is neither over-constrained
557                    // nor conflicting.
558                    if tool == "text" {
559                        let corners = vec![a_id.clone(), b1.clone(), b2.clone(), b3.clone()];
560                        let text_id = doc.next_geometry_id();
561                        for edge in doc.geometries.iter_mut().rev().take(4) {
562                            edge.extra.insert("construction".into(), serde_json::json!(true));
563                            edge.extra.insert("textFrame".into(), text_id.clone());
564                        }
565                        for id in &corners { if let Some(p) = doc.point_mut(id) { p.construction = true; } }
566                        doc.geometries.push(crate::sketch::text::object(text_id.clone(), corners, "Text", "Ubuntu Light").expect("bundled default font"));
567                        edit.session.selection.items = vec![crate::sketch::session::geometry_ref(&text_id)];
568                        edit.session.tool = None;
569                    }
570                    push_rect_perpendicular_constraints(doc, [a_id, b1, b2, b3]);
571                    edit.pending.clear();
572                }
573            }
574            "circle" => {
575                if edit.pending.is_empty() {
576                    let c = doc.snap_or_add_point(u, v, radius);
577                    edit.pending.push(c);
578                } else {
579                    let center = edit.pending[0].clone();
580                    let r = doc.snap_or_add_point(u, v, radius);
581                    push_sketch_geometry(doc, "circle", vec![center, r]);
582                    edit.pending.clear();
583                }
584            }
585            "arc" => {
586                // Clicks: center, start, then end completes [center, start, end].
587                if edit.pending.len() < 2 {
588                    let p = doc.snap_or_add_point(u, v, radius);
589                    edit.pending.push(p);
590                } else {
591                    let center = edit.pending[0].clone();
592                    let start = edit.pending[1].clone();
593                    let end = doc.snap_or_add_point(u, v, radius);
594                    push_sketch_geometry(doc, "arc", vec![center, start, end]);
595                    edit.pending.clear();
596                }
597            }
598            "bezier" => {
599                // Cubic Bezier: 4 clicks place end0, ctrl0, ctrl1, end1 (in order).
600                // The 4th click commits the span [p0, p1, p2, p3] PLUS two dashed
601                // construction guide lines for the control handles (end0→ctrl0 and
602                // end1→ctrl1), matching the previous basic bezier tool. One INVOCATION
603                // authors one span; a chained multi-span polygon (3n+1 ids in ONE
604                // geometry — the model the solver, tessellator and profile builder all
605                // already read) is grown by insertion instead, below.
606                //
607                // IDLE + a click on an existing spline = refine it: subdivide the
608                // clicked span so a new anchor lands under the cursor without the curve
609                // moving (see `crate::sketch::spline`). Only while `pending` is empty —
610                // mid-draw the click still means "place the next control point", so a
611                // new spline can be drawn across an old one. A refusal (nothing under
612                // the cursor, a point winning the pick, a degenerate span) falls
613                // through to that placement, which mutates the doc too, so the
614                // `record_undo` above never leaves a dead step either way.
615                let refined = edit.pending.is_empty() && spline_insert_anchor(doc, u, v, radius);
616                if !refined {
617                    if edit.pending.len() < 3 {
618                        let p = doc.snap_or_add_point(u, v, radius);
619                        edit.pending.push(p);
620                    } else {
621                        let p0 = edit.pending[0].clone();
622                        let p1 = edit.pending[1].clone();
623                        let p2 = edit.pending[2].clone();
624                        let p3 = doc.snap_or_add_point(u, v, radius);
625                        push_sketch_geometry(
626                            doc,
627                            "bezier",
628                            vec![p0.clone(), p1.clone(), p2.clone(), p3.clone()],
629                        );
630                        // Construction guide lines (dashed, non-modeling) for the two
631                        // control handles — separate freshly minted geometry ids.
632                        push_sketch_construction_line(doc, vec![p0, p1]);
633                        push_sketch_construction_line(doc, vec![p3, p2]);
634                        edit.pending.clear();
635                    }
636                }
637            }
638            _ => return,
639        }
640        // Every draw click mutates the doc (a new point and/or geometry); re-solve so
641        // coordinates + mobility stay fresh, keeping the doc if the solve fails.
642        self.resolve_active_sketch("draw-tool");
643        self.refresh_sketch_overlay();
644        self.dirty = true;
645    }
646
647    /// Abort the in-progress draw geometry (Escape / right-click): clear the pending
648    /// clicks + preview and refresh. No-op when not in sketch mode.
649    pub fn sketch_tool_cancel(&mut self) {
650        if let Some(edit) = self.sketch_edit.as_mut() {
651            edit.pending.clear();
652        } else {
653            return;
654        }
655        self.refresh_sketch_overlay();
656        self.dirty = true;
657    }
658}
659
660/// Normalize a tool name to the stored form: `None`/`"select"`/`""` → selection mode
661/// (`None`), else the tool string (`"point"|"line"|"rect"|"circle"|"arc"|"bezier"|
662/// "trim"|"pickEdges"|"handdraw"`).
663fn normalize_sketch_tool(tool: Option<&str>) -> Option<String> {
664    match tool {
665        None | Some("select") | Some("") => None,
666        Some(t) => Some(t.to_string()),
667    }
668}
669
670/// Append a geometry to a sketch doc with a freshly minted id (the caller passes the
671/// solver `type` — `rect` corners are pushed as `line`s), carrying an explicit
672/// `construction: false` so it matches the authored shape and round-trips.
673fn push_sketch_geometry(
674    doc: &mut crate::sketch::SketchDoc,
675    geom_type: &str,
676    points: Vec<serde_json::Value>,
677) {
678    let id = doc.next_geometry_id();
679    let mut extra = serde_json::Map::new();
680    extra.insert("construction".to_string(), serde_json::Value::Bool(false));
681    doc.geometries.push(crate::sketch::SketchGeometry {
682        id,
683        geom_type: geom_type.to_string(),
684        points,
685        extra,
686    });
687}
688
689/// Append a CONSTRUCTION `line` geometry (dashed, non-modeling — `construction: true`)
690/// with a freshly minted id: the bezier tool's control-handle guide lines. Mirrors
691/// [`push_sketch_geometry`] but flips the construction flag so the line renders dashed
692/// and is excluded from profiles while still being constrainable.
693fn push_sketch_construction_line(
694    doc: &mut crate::sketch::SketchDoc,
695    points: Vec<serde_json::Value>,
696) {
697    let id = doc.next_geometry_id();
698    let mut extra = serde_json::Map::new();
699    extra.insert("construction".to_string(), serde_json::Value::Bool(true));
700    doc.geometries.push(crate::sketch::SketchGeometry {
701        id,
702        geom_type: "line".to_string(),
703        points,
704        extra,
705    });
706}
707
708/// Subdivide the spline under the plane click `(u, v)` — the bezier tool's "refine
709/// what I already drew" click. Delegates the pick + de Casteljau split to
710/// [`crate::sketch::spline::insert_anchor`] (which decides whether the click is a
711/// subdivision at all) and, when it lands, hangs the SAME two dashed construction
712/// guides on the new anchor that the 4-click path hangs on the two drawn ends:
713/// anchor→handle on each side. Consistency is the whole point — after a refine every
714/// anchor of the spline, drawn or inserted, has a guide to each of its neighbouring
715/// handles, so the handles stay visible and constrainable (a tangent on a guide is
716/// how a spline is made to meet a line smoothly) and a refined curve is
717/// indistinguishable from one drawn that way. The two ORIGINAL guides still read
718/// correctly without being touched, because the split reuses the end handles' ids and
719/// they remain the handles adjacent to those same two end anchors.
720///
721/// Returns whether the click was consumed as an insertion; `false` means the caller
722/// should treat it as an ordinary control-point placement.
723fn spline_insert_anchor(doc: &mut crate::sketch::SketchDoc, u: f64, v: f64, radius: f64) -> bool {
724    let Some(added) = crate::sketch::spline::insert_anchor(doc, u, v, radius) else {
725        return false;
726    };
727    push_sketch_construction_line(doc, vec![added.anchor.clone(), added.before]);
728    push_sketch_construction_line(doc, vec![added.anchor, added.after]);
729    true
730}
731
732/// Append the three perpendicular (`⟂`) constraints that keep a freshly drawn
733/// rectangle rectangular when a corner is dragged. `corners` are the rect's four
734/// points in loop order `[a, b1, b2, b3]` (edges a→b1, b1→b2, b2→b3, b3→a); the
735/// constraints go on the adjacent-edge pairs sharing corners b1 / b2 / b3. The fourth
736/// corner (a) is left implied — a closed quad with three right angles is a rectangle —
737/// so this is the MINIMAL rigid set (3 equations, no over-constraint / redundancy).
738///
739/// Each `⟂` stores the two edges' endpoint pairs `[l1a, l1b, l2a, l2b]`, swap-oriented
740/// exactly like a palette-added perpendicular ([`sketch_build_and_add_constraint`]), and
741/// is deduped on its signature. No-op unless the four corners are all distinct (a
742/// degenerate rect whose corners snapped together would otherwise carry a `⟂` on a
743/// zero-length edge, which is meaningless and can wedge the solver).
744fn push_rect_perpendicular_constraints(
745    doc: &mut crate::sketch::SketchDoc,
746    corners: [serde_json::Value; 4],
747) {
748    use crate::sketch::doc::id_key;
749    let [a, b1, b2, b3] = corners;
750    let keys = [id_key(&a), id_key(&b1), id_key(&b2), id_key(&b3)];
751    for i in 0..keys.len() {
752        for j in (i + 1)..keys.len() {
753            if keys[i] == keys[j] {
754                return; // two corners collapsed → skip (no zero-length-edge ⟂).
755            }
756        }
757    }
758    // Adjacent edge pairs sharing corner b1 / b2 / b3.
759    let pairs = [
760        [a.clone(), b1.clone(), b1.clone(), b2.clone()],
761        [b1.clone(), b2.clone(), b2.clone(), b3.clone()],
762        [b2.clone(), b3.clone(), b3.clone(), a.clone()],
763    ];
764    for pair in pairs {
765        let mut pts = pair.to_vec();
766        if sketch_perpendicular_should_swap(doc, &pts) {
767            pts.swap(0, 1);
768        }
769        push_geometric_constraint(doc, "⟂", pts);
770    }
771}
772
773/// Append a NON-dimensional geometric constraint (`type` + ordered `points`) with a
774/// freshly minted id and the same base fields a palette-added constraint carries
775/// (`labelX`/`labelY` = 0, `displayStyle` = "", `value` = null, `valueNeedsSetup` =
776/// true — see [`sketch_build_and_add_constraint`]). Deduped on `type + sorted-points`
777/// (the solver runs with `remove_implied_duplicates: false`, so this is the only
778/// dedup); a no-op on a duplicate.
779fn push_geometric_constraint(
780    doc: &mut crate::sketch::SketchDoc,
781    ctype: &str,
782    points: Vec<serde_json::Value>,
783) {
784    let sig = sketch_constraint_signature(ctype, &points);
785    let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
786        Some(t) => sketch_constraint_signature(t, c.points()) == sig,
787        None => false,
788    });
789    if duplicate {
790        return;
791    }
792    let id = doc.next_constraint_id();
793    let mut raw = serde_json::Map::new();
794    raw.insert("id".to_string(), id);
795    raw.insert("type".to_string(), serde_json::Value::String(ctype.to_string()));
796    raw.insert("points".to_string(), serde_json::Value::Array(points));
797    raw.insert("labelX".to_string(), serde_json::Value::from(0));
798    raw.insert("labelY".to_string(), serde_json::Value::from(0));
799    raw.insert(
800        "displayStyle".to_string(),
801        serde_json::Value::String(String::new()),
802    );
803    raw.insert("value".to_string(), serde_json::Value::Null);
804    raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
805    doc.constraints.push(crate::sketch::SketchConstraint { raw });
806}
807
808// ===========================================================================
809// Auto-constrain — infer the constraints implied by the rough-in geometry.
810//
811// A one-click "constrain what I drew": snap nearly-axis-aligned lines to `━`/`│` and
812// near-coincident points to `≡`, going through the same `push_geometric_constraint`
813// dedup the palette uses so the solver picks them up and a re-run is idempotent.
814// ===========================================================================
815
816/// Auto-constrain tolerances (v1 constants; a future pass could surface them as
817/// settings). A line whose direction is within [`AUTO_HV_ANGLE_TOL_DEG`] of an axis
818/// gets a horizontal/vertical constraint — loose enough to catch a rough-in, far
819/// below the slope a user clearly intended.
820const AUTO_HV_ANGLE_TOL_DEG: f64 = 3.0;
821/// Two points auto-snap to COINCIDENT when within this fraction of the sketch's
822/// bounding-box diagonal (scale-invariant, so it works at any sketch size)…
823const AUTO_COINCIDENT_FRAC: f64 = 0.01;
824/// …floored at this absolute distance for a tiny or single-cluster sketch.
825const AUTO_COINCIDENT_ABS: f64 = 1e-4;
826
827/// Infer + add the constraints implied by the current geometry: `━`/`│` on
828/// nearly-axis-aligned lines and `≡` between near-coincident points. Conservative —
829/// never adds an unsatisfiable constraint on two already-fixed points, never collapses
830/// a curve by coinciding its own endpoints, never re-adds a coincident already implied
831/// (directly or transitively), and dedups H/V via [`push_geometric_constraint`]. So a
832/// second pass adds nothing (idempotent). Returns the number of constraints added; the
833/// caller re-solves.
834pub(super) fn auto_constrain_doc(doc: &mut crate::sketch::SketchDoc) -> usize {
835    use crate::sketch::doc::id_key;
836    use serde_json::Value;
837    use std::collections::{HashMap, HashSet};
838
839    let before = doc.constraints.len();
840
841    // ---- Horizontal / vertical on nearly-axis-aligned lines. ----
842    // Threshold on |unit component off the axis| = |sin(angle deviation)|.
843    let hv_sin_tol = AUTO_HV_ANGLE_TOL_DEG.to_radians().sin();
844    let mut hv: Vec<(&str, Vec<Value>)> = Vec::new();
845    for g in &doc.geometries {
846        if g.geom_type != "line" || g.points.len() < 2 {
847            continue;
848        }
849        let (Some(a), Some(b)) = (doc.point(&g.points[0]), doc.point(&g.points[1])) else {
850            continue;
851        };
852        // Skip a line pinned at BOTH ends (no freedom → an H/V that isn't already
853        // exactly true is unsatisfiable). This also excludes linked reference lines,
854        // whose endpoints are all fixed.
855        if a.fixed && b.fixed {
856            continue;
857        }
858        let (dx, dy) = (b.x - a.x, b.y - a.y);
859        let len = dx.hypot(dy);
860        if len < 1e-9 {
861            continue;
862        }
863        // Skip a line that already carries an H or V constraint on these endpoints
864        // (never add the opposite one; keeps the pass idempotent).
865        let mut keys: Vec<String> = g.points[..2].iter().map(id_key).collect();
866        keys.sort();
867        let already_hv = doc.constraints.iter().any(|c| {
868            if !matches!(c.ctype(), Some("━") | Some("│")) {
869                return false;
870            }
871            let mut k: Vec<String> = c.points().iter().map(id_key).collect();
872            k.sort();
873            k == keys
874        });
875        if already_hv {
876            continue;
877        }
878        let (ux, uy) = (dx / len, dy / len);
879        if uy.abs() <= hv_sin_tol {
880            hv.push(("━", vec![g.points[0].clone(), g.points[1].clone()]));
881        } else if ux.abs() <= hv_sin_tol {
882            hv.push(("│", vec![g.points[0].clone(), g.points[1].clone()]));
883        }
884    }
885    for (ct, pts) in hv {
886        push_geometric_constraint(doc, ct, pts);
887    }
888
889    // ---- Coincident between near-coincident, mergeable point pairs. ----
890    let n = doc.points.len();
891    if n >= 2 {
892        fn find(parent: &mut [usize], mut x: usize) -> usize {
893            while parent[x] != x {
894                parent[x] = parent[parent[x]]; // path halving
895                x = parent[x];
896            }
897            x
898        }
899        fn union(parent: &mut [usize], a: usize, b: usize) {
900            let (ra, rb) = (find(parent, a), find(parent, b));
901            if ra != rb {
902                parent[ra] = rb;
903            }
904        }
905
906        // Union-find over point indices, SEEDED with the existing coincidents so we
907        // never re-add one (directly or transitively).
908        let index: HashMap<String, usize> = doc
909            .points
910            .iter()
911            .enumerate()
912            .map(|(i, p)| (id_key(&p.id), i))
913            .collect();
914        let mut parent: Vec<usize> = (0..n).collect();
915        for c in &doc.constraints {
916            if c.ctype() == Some("≡") {
917                let pts = c.points();
918                if let (Some(p0), Some(p1)) = (pts.first(), pts.get(1)) {
919                    if let (Some(&i), Some(&j)) = (index.get(&id_key(p0)), index.get(&id_key(p1))) {
920                        union(&mut parent, i, j);
921                    }
922                }
923            }
924        }
925
926        // Distance tolerance relative to the sketch extent — the user's points only:
927        // the part-origin reference may sit far from the drawing and must not widen it.
928        let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
929        for p in doc.points.iter().filter(|p| !brep_kernel::is_sketch_origin_point_id(&p.id)) {
930            lo[0] = lo[0].min(p.x);
931            lo[1] = lo[1].min(p.y);
932            hi[0] = hi[0].max(p.x);
933            hi[1] = hi[1].max(p.y);
934        }
935        let extent = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2)).sqrt();
936        let tol = (extent * AUTO_COINCIDENT_FRAC).max(AUTO_COINCIDENT_ABS);
937
938        // Point-key set per geometry — skip a pair that are the two ends of the SAME
939        // curve (coinciding them would collapse it).
940        let geo_sets: Vec<HashSet<String>> = doc
941            .geometries
942            .iter()
943            .map(|g| g.points.iter().map(id_key).collect())
944            .collect();
945
946        let mut coincidents: Vec<Vec<Value>> = Vec::new();
947        for i in 0..n {
948            for j in (i + 1)..n {
949                let (pi, pj) = (&doc.points[i], &doc.points[j]);
950                if pi.fixed && pj.fixed {
951                    continue; // both pinned → a coincident is unsatisfiable
952                }
953                let d = ((pi.x - pj.x).powi(2) + (pi.y - pj.y).powi(2)).sqrt();
954                if d > tol {
955                    continue;
956                }
957                if find(&mut parent, i) == find(&mut parent, j) {
958                    continue; // already coincident (directly or transitively)
959                }
960                let (ki, kj) = (id_key(&pi.id), id_key(&pj.id));
961                if geo_sets.iter().any(|s| s.contains(&ki) && s.contains(&kj)) {
962                    continue; // endpoints of one curve — do not collapse it
963                }
964                union(&mut parent, i, j);
965                coincidents.push(vec![pi.id.clone(), pj.id.clone()]);
966            }
967        }
968        for pts in coincidents {
969            push_geometric_constraint(doc, "≡", pts);
970        }
971    }
972
973    doc.constraints.len().saturating_sub(before)
974}
975
976impl EngineState {
977    /// Auto-constrain the active sketch (the toolbar's one-click "constrain what I
978    /// roughed in"): infer `━`/`│` on nearly-axis-aligned lines and `≡` between
979    /// near-coincident points, then re-solve. Records ONE undo step, popped when the
980    /// pass adds nothing so a dead click neither pollutes undo nor clobbers redo.
981    /// Returns the number of constraints added; a no-op (0) when not in sketch mode.
982    pub fn sketch_auto_constrain(&mut self) -> usize {
983        let Some(edit) = self.sketch_edit.as_mut() else {
984            return 0;
985        };
986        edit.record_undo();
987        let added = auto_constrain_doc(&mut edit.session.doc);
988        if added == 0 {
989            edit.undo_stack.pop();
990            return 0;
991        }
992        self.resolve_active_sketch("auto-constrain");
993        self.refresh_sketch_overlay();
994        self.dirty = true;
995        added
996    }
997}
998
999// ===========================================================================
1000// Sketch delete-selected (S3b) — remove the selected entities + orphan cleanup.
1001//
1002// Operates on the active `self.sketch_edit`. Rule (chosen so a remaining geometry
1003// NEVER references a missing point):
1004//   1. Partition the selection into selected geometry / point / constraint ids.
1005//   2. Drop every geometry that is SELECTED *or* references any selected point (the
1006//      remove-point cascade — deleting a vertex kills geometry that used it).
1007//   3. Drop the selected points.
1008//   4. Orphan cleanup: drop any remaining point NOT referenced by any surviving
1009//      geometry (a shared vertex — still referenced — stays; a deleted line's now
1010//      unshared endpoints vanish). Always on for this slice.
1011//   5. Drop any constraint that is SELECTED *or* references a removed point (selected ∪
1012//      orphaned) — done LAST, over the full removed-point set, so no constraint dangles
1013//      either. A selected constraint drops ONLY itself; the geometry/points it
1014//      referenced are untouched (deleting a constraint never deletes geometry).
1015// Then clear selection + hover, re-solve (swallowing errors), refresh, mark dirty.
1016// Kept in ONE appended block so concurrent edits to the primary impl land clean.
1017// ===========================================================================
1018impl EngineState {
1019    /// Delete the selected sketch entities (S3b): the selected geometries + points,
1020    /// plus any geometry orphaned by a deleted vertex, plus orphaned points and the
1021    /// constraints referencing any removed point. Re-solves + refreshes the overlay.
1022    /// Returns `true` when something was deleted; `false` when not in sketch mode or
1023    /// the selection is empty.
1024    pub fn sketch_delete_selection(&mut self) -> bool {
1025        use crate::sketch::doc::id_key;
1026        use std::collections::HashSet;
1027
1028        let Some(edit) = self.sketch_edit.as_mut() else {
1029            return false;
1030        };
1031        if edit.session.selection.items.is_empty() {
1032            return false;
1033        }
1034        // A non-empty selection always removes something → snapshot for undo (S6a).
1035        edit.record_undo();
1036
1037        // 1. Partition the selection into selected geometry / point / constraint ids
1038        //    (keyed via `id_key`, so 4 / 4.0 / "4" all match).
1039        let mut sel_geo: HashSet<String> = HashSet::new();
1040        let mut sel_pt: HashSet<String> = HashSet::new();
1041        let mut sel_constraint: HashSet<String> = HashSet::new();
1042        for r in &edit.session.selection.items {
1043            match (r.get("kind").and_then(|v| v.as_str()), r.get("id")) {
1044                (Some("geometry"), Some(id)) => {
1045                    sel_geo.insert(id_key(id));
1046                }
1047                (Some("point"), Some(id)) => {
1048                    sel_pt.insert(id_key(id));
1049                }
1050                (Some("constraint"), Some(id)) => {
1051                    sel_constraint.insert(id_key(id));
1052                }
1053                _ => {}
1054            }
1055        }
1056
1057        // The part-origin reference and its ground are permanent: never deleted,
1058        // selected or orphaned.
1059        let origin_key = id_key(&serde_json::Value::String(
1060            brep_kernel::SKETCH_ORIGIN_POINT_ID.to_string(),
1061        ));
1062        sel_pt.remove(&origin_key);
1063        sel_constraint.remove(&origin_key);
1064
1065        let doc = &mut edit.session.doc;
1066
1067        let deleted_texts: HashSet<String> = doc.geometries.iter().filter(|g| g.geom_type == "text")
1068            .filter(|g| sel_geo.contains(&id_key(&g.id)) || g.points.iter().any(|p| sel_pt.contains(&id_key(p))) || doc.geometries.iter().any(|edge| edge.extra.get("textFrame") == Some(&g.id) && sel_geo.contains(&id_key(&edge.id))))
1069            .map(|g| id_key(&g.id)).collect();
1070        for g in &doc.geometries {
1071            if deleted_texts.contains(&id_key(&g.id)) || g.extra.get("textFrame").is_some_and(|id| deleted_texts.contains(&id_key(id))) {
1072                sel_geo.insert(id_key(&g.id));
1073            }
1074        }
1075
1076        // 2. Drop geometries that are selected OR reference any selected point (so a
1077        //    deleted vertex never leaves a geometry dangling).
1078        doc.geometries.retain(|g| {
1079            if sel_geo.contains(&id_key(&g.id)) {
1080                return false;
1081            }
1082            !g.points.iter().any(|pid| sel_pt.contains(&id_key(pid)))
1083        });
1084
1085        // 3. Drop the explicitly-selected points.
1086        doc.points.retain(|p| !sel_pt.contains(&id_key(&p.id)));
1087
1088        // 4. Orphan cleanup: drop points no longer referenced by any surviving
1089        //    geometry. Accumulate every removed point id (selected ∪ orphaned).
1090        let referenced: HashSet<String> = doc
1091            .geometries
1092            .iter()
1093            .flat_map(|g| g.points.iter().map(id_key))
1094            .collect();
1095        let mut removed_pts = sel_pt;
1096        doc.points.retain(|p| {
1097            let key = id_key(&p.id);
1098            if referenced.contains(&key) || key == origin_key {
1099                true
1100            } else {
1101                removed_pts.insert(key);
1102                false
1103            }
1104        });
1105
1106        // 5. Drop constraints that are EXPLICITLY selected OR reference ANY removed
1107        //    point (done last, over the full removed set, so no constraint dangles onto
1108        //    a missing point). A selected constraint drops ONLY itself — the geometry /
1109        //    points it references are left intact (deleting a constraint never deletes
1110        //    geometry).
1111        doc.constraints.retain(|c| {
1112            if let Some(id) = c.raw.get("id") {
1113                if sel_constraint.contains(&id_key(id)) {
1114                    return false;
1115                }
1116            }
1117            !c.points().iter().any(|pid| removed_pts.contains(&id_key(pid)))
1118        });
1119
1120        // Drop any external-reference bookkeeping whose materialized entities this
1121        // delete removed — otherwise its stale entry (keyed by edge name, now holding
1122        // dangling point ids) permanently blocks RE-LINKING the same edge.
1123        crate::sketch::external_ref::prune_dead_refs(&edit.session.doc, &mut edit.external_refs);
1124
1125        // Clear the interaction state, re-solve (keep the doc on failure), refresh.
1126        edit.session.clear_selection();
1127        edit.session.set_hover(None);
1128        self.resolve_active_sketch("delete");
1129        self.refresh_sketch_overlay();
1130        self.dirty = true;
1131        true
1132    }
1133}