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