BREP_render 0.1.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
use super::*;
use super::sketch_panel::{sketch_constraint_signature, sketch_perpendicular_should_swap};

// ===========================================================================
// Sketch interaction (S2) — plane-space picking: hover, selection, point drag.
//
// All operate on the active `self.sketch_edit` and no-op (false / 0) when not in
// sketch mode. Pixel→plane→uv goes through the SAME `camera.pick_ray` the modeling
// picker uses, intersected with the sketch plane (`crate::sketch::ray_plane_uv`).
// Hit-testing (`SketchSession::pick_entity` / `pick_draggable_point`) is pure uv
// math; points win over geometry within the ~8px grab radius. Every mutator
// re-pushes the overlay via `refresh_sketch_overlay` (which colors the live hover +
// selection) and marks the engine dirty. Kept in ONE appended block so concurrent
// edits to the primary impl land clean.
// ===========================================================================
impl EngineState {
    /// Re-push the sketch overlay reflecting the live hover + selection. Reads the
    /// active `sketch_edit`'s session; a no-op when not in sketch mode. Called at
    /// the end of every S2 mutator (the reusable counterpart of the initial
    /// [`set_sketch_overlay`](Self::set_sketch_overlay) push).
    pub(super) fn refresh_sketch_overlay(&mut self) {
        let world_per_pixel = self.camera.world_per_pixel();
        let (json, preview, leaders, glyphs) = match self.sketch_edit.as_ref() {
            Some(edit) => (
                edit.session.overlay_json_with_state(world_per_pixel),
                edit.session.preview_overlay_json(
                    world_per_pixel,
                    &edit.pending,
                    edit.hover_uv,
                    &edit.handdraw_stroke,
                ),
                edit.session.dim_leaders_overlay_json_with_state(world_per_pixel),
                edit.session
                    .constraint_glyphs_overlay_json_with_state(world_per_pixel),
            ),
            None => return,
        };
        let _ = self.set_overlay_json(&json);
        // The draw-tool rubber-band rides in its own `sketch-preview` group so it
        // upserts/clears independently of the solved geometry + point groups.
        let _ = self.set_overlay_json(&preview);
        // The dimension leaders + arrows ride in `sketch-dim-leaders`, refreshed
        // alongside everything else (S5).
        let _ = self.set_overlay_json(&leaders);
        // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
        let _ = self.set_overlay_json(&glyphs);
    }

    /// Map CSS-pixel `(x, y)` to the active sketch plane's `(u, v)` via the camera
    /// pick ray ∩ the sketch plane. `None` when not in sketch mode or the ray misses
    /// the plane (parallel / behind).
    pub fn sketch_uv_at(&self, x: f64, y: f64) -> Option<(f64, f64)> {
        let edit = self.sketch_edit.as_ref()?;
        let ray = self.camera.pick_ray(x, y);
        crate::sketch::ray_plane_uv(&edit.session.plane, ray.origin, ray.dir)
    }

    /// The world-space pick tolerance at the current zoom — the ONE radius that
    /// drives hover-highlight, click-select, drag-grab, draw-snap AND trim, for
    /// points AND geometry, so "what highlights" is exactly "what you can grab".
    /// Sized at 1.5× the visualized point (`POINT_SIZE_PX`) for forgiving clicking.
    pub(super) fn sketch_pick_radius(&self) -> f64 {
        f64::from(crate::sketch::tessellate::POINT_SIZE_PX) * 1.5 * self.camera.world_per_pixel()
    }

    /// The entity ref under CSS-pixel `(x, y)` within the grab radius, or `None`.
    /// Priority is points > geometry > constraint: [`pick_entity`] resolves the first
    /// two, and only when neither is in range do we consult [`pick_constraint`] (a glyph
    /// or dimension leader that overlaps a point/edge never shadows it).
    ///
    /// [`pick_entity`]: crate::sketch::SketchSession::pick_entity
    /// [`pick_constraint`]: crate::sketch::SketchSession::pick_constraint
    fn sketch_entity_at(&self, x: f64, y: f64) -> Option<serde_json::Value> {
        let (u, v) = self.sketch_uv_at(x, y)?;
        let radius = self.sketch_pick_radius();
        let wpp = self.camera.world_per_pixel();
        self.sketch_edit.as_ref().and_then(|edit| {
            edit.session
                .pick_entity(u, v, radius)
                .or_else(|| edit.session.pick_constraint(u, v, radius, wpp))
        })
    }

    /// Set (or clear) the sketch hover, re-pushing the overlay + marking dirty only
    /// when it actually changed. Returns whether the hover changed.
    pub(super) fn set_sketch_hover(&mut self, new_hover: Option<serde_json::Value>) -> bool {
        let changed = match self.sketch_edit.as_ref() {
            Some(edit) => {
                !crate::sketch::entity_ref_eq(edit.session.hovered.as_ref(), new_hover.as_ref())
            }
            None => false,
        };
        if changed {
            if let Some(edit) = self.sketch_edit.as_mut() {
                edit.session.set_hover(new_hover);
            }
            self.refresh_sketch_overlay();
            self.dirty = true;
        }
        changed
    }

    /// Update the sketch hover to the entity under CSS-pixel `(x, y)` (S2). Returns
    /// whether the hover changed. A no-op returning `false` when not in sketch mode.
    pub fn sketch_hover_at(&mut self, x: f64, y: f64) -> bool {
        if self.sketch_edit.is_none() {
            return false;
        }
        // Track the live cursor uv for the S3a rubber-band. In DRAW mode with pending
        // clicks the preview follows the cursor even when the hovered ENTITY is
        // unchanged, so force an overlay refresh there.
        let uv = self.sketch_uv_at(x, y);
        let preview_live = match self.sketch_edit.as_mut() {
            Some(edit) => {
                edit.hover_uv = uv;
                edit.session.tool.is_some() && !edit.pending.is_empty()
            }
            None => false,
        };
        let new_hover = self.sketch_entity_at(x, y);
        let changed = self.set_sketch_hover(new_hover);
        if preview_live && !changed {
            self.refresh_sketch_overlay();
            self.dirty = true;
        }
        changed
    }

    /// Clear the sketch hover (pointer left the viewport / moved over the ViewCube).
    /// Returns whether a hover was cleared.
    pub fn sketch_clear_hover(&mut self) -> bool {
        self.set_sketch_hover(None)
    }

    /// Click-select in sketch mode: pick the entity under `(x, y)`; nothing → clear
    /// the selection; else `additive` (Ctrl/Cmd) toggles it in the set, a plain
    /// click replaces the set with just it. Re-pushes the overlay + marks dirty.
    pub fn sketch_click_at(&mut self, x: f64, y: f64, additive: bool) {
        let hit = self.sketch_entity_at(x, y);
        let Some(edit) = self.sketch_edit.as_mut() else {
            return;
        };
        match hit {
            None => edit.session.clear_selection(),
            Some(entity_ref) => {
                if additive {
                    edit.session.toggle_selection(entity_ref);
                } else {
                    edit.session.clear_selection();
                    edit.session.toggle_selection(entity_ref);
                }
            }
        }
        self.refresh_sketch_overlay();
        self.dirty = true;
    }

    /// Begin a point drag if a DRAGGABLE point is under `(x, y)` (S2): remember it
    /// (id + original `fixed` flag). Returns `true` iff a point was grabbed (the
    /// viewport routes the drag to the sketch; otherwise it orbits the camera). A
    /// locked / fully-constrained point is not draggable, so an empty-space or
    /// locked-point drag falls through to a camera orbit.
    pub fn sketch_drag_begin(&mut self, x: f64, y: f64) -> bool {
        let Some((u, v)) = self.sketch_uv_at(x, y) else {
            return false;
        };
        let radius = self.sketch_pick_radius();
        // Grab EXACTLY what's HIGHLIGHTED: the hovered entity was picked at the exact
        // cursor position on the last move, so it is immune to egui reporting the
        // drag-start ~6px into the gesture (the "highlighted but won't grab"
        // intermittency — and it's what lets a whole geometry drag). A hovered LOCKED
        // point yields `None` → the drag falls through to a camera gesture; it must
        // NOT positional-fall-back there (that would grab a nearby UNhighlighted
        // point). Only an EMPTY hover (a press with no prior move) falls back to a
        // fresh positional pick.
        let points = self.sketch_edit.as_ref().and_then(|edit| {
            match edit.session.hovered.as_ref() {
                Some(entity_ref) => edit.session.drag_points_from_ref(entity_ref),
                None => edit
                    .session
                    .pick_draggable_point(u, v, radius)
                    .and_then(|(id, fixed)| {
                        edit.session
                            .doc
                            .point(&id)
                            .map(|p| vec![(id, p.x, p.y, fixed)])
                    }),
            }
        });
        let Some(points) = points else {
            return false;
        };
        if let Some(edit) = self.sketch_edit.as_mut() {
            // Snapshot ONCE at the gesture start so the whole drag is one undo step
            // (S6a); `sketch_drag_to` never snapshots. A grab that moves nothing is
            // discarded in `sketch_drag_end`.
            edit.record_undo();
            edit.drag = Some(SketchDrag { points, anchor: (u, v) });
        }
        true
    }

    /// Drag the grabbed target to `(x, y)` (S2): pin every grabbed point at its
    /// ORIGINAL position plus the cursor delta (`fixed = true`) so the solver anchors
    /// the whole shape there, re-solve, then restore each point's ORIGINAL `fixed`
    /// flag. Absolute-from-anchor (never incremental), so a rigid geometry translate
    /// tracks the cursor 1:1 without drifting as the solver nudges points between
    /// frames. A resolve error rolls every grabbed point back to its pre-drag coords
    /// (the last good state). No-op when nothing is grabbed / not in sketch mode / the
    /// ray misses the plane.
    pub fn sketch_drag_to(&mut self, x: f64, y: f64) {
        let Some((u, v)) = self.sketch_uv_at(x, y) else {
            return;
        };
        let Some(edit) = self.sketch_edit.as_mut() else {
            return;
        };
        let Some(drag) = edit.drag.clone() else {
            return;
        };
        let (du, dv) = (u - drag.anchor.0, v - drag.anchor.1);
        let session = &mut edit.session;
        for (id, ox, oy, _) in &drag.points {
            if let Some(p) = session.doc.point_mut(id) {
                p.x = *ox + du;
                p.y = *oy + dv;
                p.fixed = true;
            }
        }
        match session.resolve() {
            Ok(()) => {
                for (id, _, _, orig_fixed) in &drag.points {
                    if let Some(p) = session.doc.point_mut(id) {
                        p.fixed = *orig_fixed;
                    }
                }
            }
            Err(_) => {
                // Unsolvable target: roll every grabbed point back to its pre-drag
                // coords + flag (keep the last good state).
                for (id, ox, oy, orig_fixed) in &drag.points {
                    if let Some(p) = session.doc.point_mut(id) {
                        p.x = *ox;
                        p.y = *oy;
                        p.fixed = *orig_fixed;
                    }
                }
            }
        }
        self.refresh_sketch_overlay();
        self.dirty = true;
    }

    /// End a point drag (S2): clear the grab, then one final re-solve + overlay
    /// refresh. No-op when no drag is live.
    pub fn sketch_drag_end(&mut self) {
        let had_grab = self
            .sketch_edit
            .as_ref()
            .map_or(false, |edit| edit.drag.is_some());
        if !had_grab {
            return;
        }
        // Drop radius for constraint inference == the point grab radius (12 px in
        // world units) — read before the `&mut` borrow of `sketch_edit`.
        let drop_tol = self.sketch_pick_radius();
        if let Some(edit) = self.sketch_edit.as_mut() {
            // Drop-time inference (S6c): a SINGLE-point drop snaps to a coincident
            // point / point-on-line at the release position, mirroring the previous
            // coincident-on-drop / point-on-line-on-drop inference. A whole-
            // geometry drag (multiple points) never infers — running it per
            // endpoint could glue or collapse the curve in one solve.
            let dragged_single = edit.drag.as_ref().and_then(|drag| {
                (drag.points.len() == 1).then(|| drag.points[0].0.clone())
            });
            edit.drag = None;
            if let Some(point_id) = dragged_single {
                crate::sketch::infer::infer_drop_constraint(
                    &mut edit.session.doc,
                    &point_id,
                    drop_tol,
                );
            }
            let _ = edit.session.resolve();
            // Discard the drag's undo snapshot when the doc is unchanged (a mere
            // grab-and-release, no move and no inferred constraint), so it neither
            // pollutes undo nor clobbers redo. An inferred constraint changes the
            // doc, so the snapshot is kept — one Ctrl+Z then undoes move+constraint.
            if edit
                .undo_stack
                .last()
                .map_or(false, |snap| snap.doc == edit.session.doc)
            {
                edit.undo_stack.pop();
            }
        }
        self.refresh_sketch_overlay();
        self.dirty = true;
    }

    /// The number of selected sketch entities (0 when not in sketch mode) — the mode
    /// bar / verifier readout.
    pub fn sketch_selection_count(&self) -> usize {
        self.sketch_edit
            .as_ref()
            .map_or(0, |edit| edit.session.selection.len())
    }

    /// The number of selected CONSTRAINTS (refs whose `kind` is `"constraint"`; 0 when
    /// not in sketch mode) — the `__brepSketch` verifier readout for constraint
    /// selection + delete.
    pub fn sketch_selected_constraint_count(&self) -> usize {
        self.sketch_edit.as_ref().map_or(0, |edit| {
            edit.session
                .selection
                .iter()
                .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("constraint"))
                .count()
        })
    }
}

// ===========================================================================
// Sketch draw tools (S3a) — primitive placement: point / line / rect / circle / arc.
//
// A click-state machine over the active `self.sketch_edit`. The active tool lives
// on `session.tool` ("select"/None = selection mode, S2); a DRAW tool routes clicks
// to `sketch_tool_click_at` (pixel → plane uv via the same S2 `sketch_uv_at`, then
// `sketch_tool_place_uv`). Points/geometries are minted through `SketchDoc`
// (`next_point_id`/`next_geometry_id` + `snap_or_add_point`, so shared vertices
// coincide). Each placement re-solves (swallowing solve errors), re-pushes the
// overlay (incl. the rubber-band preview), and marks dirty. Kept in ONE appended
// block so concurrent edits to the primary impl land clean.
// ===========================================================================
impl EngineState {
    /// Set (or clear) the active draw tool: `"select"`/`None` → selection mode (S2);
    /// `"point"|"line"|"rect"|"circle"|"arc"|"bezier"` arm the corresponding draw
    /// tool; `"handdraw"` arms the freehand stroke tool (S6b-3); `"trim"` arms the
    /// trim tool (S6b); `"pickEdges"` arms the external-edge link tool (S6b-2). Clears
    /// any in-progress click buffer + preview and refreshes the overlay. No-op when not
    /// in sketch mode.
    pub fn sketch_set_tool(&mut self, tool: Option<&str>) {
        let normalized = normalize_sketch_tool(tool);
        if let Some(edit) = self.sketch_edit.as_mut() {
            edit.session.tool = normalized;
            edit.pending.clear();
            edit.hover_uv = None;
            edit.handdraw_stroke.clear();
        } else {
            return;
        }
        self.refresh_sketch_overlay();
        self.dirty = true;
    }

    /// The active draw tool (`"point"|"line"|"rect"|"circle"|"arc"`), or `None` in
    /// selection mode / when not in sketch mode.
    pub fn sketch_active_tool(&self) -> Option<&str> {
        self.sketch_edit
            .as_ref()
            .and_then(|edit| edit.session.tool.as_deref())
    }

    /// The number of in-progress draw-tool clicks buffered (0 in selection mode /
    /// when not in sketch mode) — the UI/preview + verifier readout.
    pub fn sketch_pending_len(&self) -> usize {
        self.sketch_edit.as_ref().map_or(0, |edit| edit.pending.len())
    }

    /// A draw-tool click at CSS-pixel `(x, y)`: map to plane uv (the S2 pixel→plane
    /// math) and drive the tool state machine. No-op when not in sketch mode, in
    /// selection mode, or the ray misses the plane.
    pub fn sketch_tool_click_at(&mut self, x: f64, y: f64) {
        // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor — it needs the
        // PIXEL coords (a scene pick), not a plane uv, so short-circuit before the
        // pixel→plane projection (which would drop clicks that miss the plane).
        if self.sketch_active_tool() == Some("pickEdges") {
            self.sketch_pick_edge_at(x, y);
            return;
        }
        let Some((u, v)) = self.sketch_uv_at(x, y) else {
            return;
        };
        self.sketch_tool_place_uv(u, v);
    }

    /// The per-tool placement logic, in plane `(u, v)` (the headless-testable core
    /// `sketch_tool_click_at` delegates to). Snaps to existing points within the grab
    /// radius so shared vertices coincide; appends geometry and re-solves when a
    /// primitive completes; carries the line chain via `pending`.
    pub fn sketch_tool_place_uv(&mut self, u: f64, v: f64) {
        let radius = self.sketch_pick_radius();
        // Selection mode (no tool / "select") never places. Read the tool without
        // holding a borrow so the trim branch can call back into `self`.
        let tool = match self.sketch_edit.as_ref() {
            Some(edit) => match edit.session.tool.clone() {
                Some(tool) => tool,
                None => return,
            },
            None => return,
        };
        // Trim (S6b) is a click tool that acts IMMEDIATELY on the geometry under the
        // cursor — it never buffers `pending` or places a point. It owns its own undo
        // snapshot (and pops it on a no-op), so short-circuit before the draw path.
        if tool == "trim" {
            self.sketch_trim_uv(u, v);
            return;
        }
        // pickEdges is NOT a uv-placement tool — it acts on a 3D scene edge (routed via
        // pixel coords in `sketch_tool_click_at`), so a stray uv place is a no-op here.
        if tool == "pickEdges" {
            return;
        }
        // handdraw (S6b-3) captures a DRAG as a stroke (routed via `sketch_handdraw_*`),
        // not a click-placed point — a plain click is a no-op (and never records a dead
        // undo step here, since we return before `record_undo`).
        if tool == "handdraw" {
            return;
        }
        let Some(edit) = self.sketch_edit.as_mut() else {
            return;
        };
        // A draw tool is active → this click WILL mutate the doc (a point and/or a
        // geometry); snapshot for undo before it does (S6a).
        edit.record_undo();
        let doc = &mut edit.session.doc;
        match tool.as_str() {
            "point" => {
                doc.snap_or_add_point(u, v, radius);
                edit.pending.clear();
            }
            "line" => {
                if edit.pending.is_empty() {
                    let a = doc.snap_or_add_point(u, v, radius);
                    edit.pending.push(a);
                } else {
                    let start = edit.pending.last().cloned().expect("pending non-empty");
                    let end = doc.snap_or_add_point(u, v, radius);
                    push_sketch_geometry(doc, "line", vec![start, end.clone()]);
                    // Continue the chain: the just-placed end is the next start.
                    edit.pending = vec![end];
                }
            }
            "rect" => {
                if edit.pending.is_empty() {
                    let a = doc.snap_or_add_point(u, v, radius);
                    edit.pending.push(a);
                } else {
                    let a_id = edit.pending[0].clone();
                    let Some((ax, ay)) = doc.point(&a_id).map(|p| (p.x, p.y)) else {
                        edit.pending.clear();
                        return;
                    };
                    let (bx, by) = (u, v);
                    // Corners A=(ax,ay), (bx,ay), (bx,by), (ax,by) → 4 closed lines.
                    let b1 = doc.snap_or_add_point(bx, ay, radius);
                    let b2 = doc.snap_or_add_point(bx, by, radius);
                    let b3 = doc.snap_or_add_point(ax, by, radius);
                    push_sketch_geometry(doc, "line", vec![a_id.clone(), b1.clone()]);
                    push_sketch_geometry(doc, "line", vec![b1.clone(), b2.clone()]);
                    push_sketch_geometry(doc, "line", vec![b2.clone(), b3.clone()]);
                    push_sketch_geometry(doc, "line", vec![b3.clone(), a_id.clone()]);
                    // Keep the rectangle rectangular under drag: three ⟂ constraints on
                    // the adjacent-edge pairs (the 4th corner's right angle follows from
                    // the closed loop). This is the minimal rigid set — it removes 3 DOF
                    // from the 8-DOF four-corner quad, leaving position (2) + rotation (1)
                    // + width + height = 5 DOF, so the sketch is neither over-constrained
                    // nor conflicting.
                    push_rect_perpendicular_constraints(doc, [a_id, b1, b2, b3]);
                    edit.pending.clear();
                }
            }
            "circle" => {
                if edit.pending.is_empty() {
                    let c = doc.snap_or_add_point(u, v, radius);
                    edit.pending.push(c);
                } else {
                    let center = edit.pending[0].clone();
                    let r = doc.snap_or_add_point(u, v, radius);
                    push_sketch_geometry(doc, "circle", vec![center, r]);
                    edit.pending.clear();
                }
            }
            "arc" => {
                // Clicks: center, start, then end completes [center, start, end].
                if edit.pending.len() < 2 {
                    let p = doc.snap_or_add_point(u, v, radius);
                    edit.pending.push(p);
                } else {
                    let center = edit.pending[0].clone();
                    let start = edit.pending[1].clone();
                    let end = doc.snap_or_add_point(u, v, radius);
                    push_sketch_geometry(doc, "arc", vec![center, start, end]);
                    edit.pending.clear();
                }
            }
            "bezier" => {
                // Cubic Bezier: 4 clicks place end0, ctrl0, ctrl1, end1 (in order).
                // The 4th click commits the span [p0, p1, p2, p3] PLUS two dashed
                // construction guide lines for the control handles (end0→ctrl0 and
                // end1→ctrl1), matching the previous basic bezier tool.
                // TODO(S3): chained multi-span bezier (3n+1 points, one geom per span).
                if edit.pending.len() < 3 {
                    let p = doc.snap_or_add_point(u, v, radius);
                    edit.pending.push(p);
                } else {
                    let p0 = edit.pending[0].clone();
                    let p1 = edit.pending[1].clone();
                    let p2 = edit.pending[2].clone();
                    let p3 = doc.snap_or_add_point(u, v, radius);
                    push_sketch_geometry(
                        doc,
                        "bezier",
                        vec![p0.clone(), p1.clone(), p2.clone(), p3.clone()],
                    );
                    // Construction guide lines (dashed, non-modeling) for the two
                    // control handles — separate freshly minted geometry ids.
                    push_sketch_construction_line(doc, vec![p0, p1]);
                    push_sketch_construction_line(doc, vec![p3, p2]);
                    edit.pending.clear();
                }
            }
            _ => return,
        }
        // Every draw click mutates the doc (a new point and/or geometry); re-solve so
        // coordinates + mobility stay fresh, keeping the doc if the solve fails.
        self.resolve_active_sketch("draw-tool");
        self.refresh_sketch_overlay();
        self.dirty = true;
    }

    /// Abort the in-progress draw geometry (Escape / right-click): clear the pending
    /// clicks + preview and refresh. No-op when not in sketch mode.
    pub fn sketch_tool_cancel(&mut self) {
        if let Some(edit) = self.sketch_edit.as_mut() {
            edit.pending.clear();
        } else {
            return;
        }
        self.refresh_sketch_overlay();
        self.dirty = true;
    }
}

/// Normalize a tool name to the stored form: `None`/`"select"`/`""` → selection mode
/// (`None`), else the tool string (`"point"|"line"|"rect"|"circle"|"arc"|"bezier"|
/// "trim"|"pickEdges"|"handdraw"`).
fn normalize_sketch_tool(tool: Option<&str>) -> Option<String> {
    match tool {
        None | Some("select") | Some("") => None,
        Some(t) => Some(t.to_string()),
    }
}

/// Append a geometry to a sketch doc with a freshly minted id (the caller passes the
/// solver `type` — `rect` corners are pushed as `line`s), carrying an explicit
/// `construction: false` so it matches the authored shape and round-trips.
fn push_sketch_geometry(
    doc: &mut crate::sketch::SketchDoc,
    geom_type: &str,
    points: Vec<serde_json::Value>,
) {
    let id = doc.next_geometry_id();
    let mut extra = serde_json::Map::new();
    extra.insert("construction".to_string(), serde_json::Value::Bool(false));
    doc.geometries.push(crate::sketch::SketchGeometry {
        id,
        geom_type: geom_type.to_string(),
        points,
        extra,
    });
}

/// Append a CONSTRUCTION `line` geometry (dashed, non-modeling — `construction: true`)
/// with a freshly minted id: the bezier tool's control-handle guide lines. Mirrors
/// [`push_sketch_geometry`] but flips the construction flag so the line renders dashed
/// and is excluded from profiles while still being constrainable.
fn push_sketch_construction_line(
    doc: &mut crate::sketch::SketchDoc,
    points: Vec<serde_json::Value>,
) {
    let id = doc.next_geometry_id();
    let mut extra = serde_json::Map::new();
    extra.insert("construction".to_string(), serde_json::Value::Bool(true));
    doc.geometries.push(crate::sketch::SketchGeometry {
        id,
        geom_type: "line".to_string(),
        points,
        extra,
    });
}

/// Append the three perpendicular (`⟂`) constraints that keep a freshly drawn
/// rectangle rectangular when a corner is dragged. `corners` are the rect's four
/// points in loop order `[a, b1, b2, b3]` (edges a→b1, b1→b2, b2→b3, b3→a); the
/// constraints go on the adjacent-edge pairs sharing corners b1 / b2 / b3. The fourth
/// corner (a) is left implied — a closed quad with three right angles is a rectangle —
/// so this is the MINIMAL rigid set (3 equations, no over-constraint / redundancy).
///
/// Each `⟂` stores the two edges' endpoint pairs `[l1a, l1b, l2a, l2b]`, swap-oriented
/// exactly like a palette-added perpendicular ([`sketch_build_and_add_constraint`]), and
/// is deduped on its signature. No-op unless the four corners are all distinct (a
/// degenerate rect whose corners snapped together would otherwise carry a `⟂` on a
/// zero-length edge, which is meaningless and can wedge the solver).
fn push_rect_perpendicular_constraints(
    doc: &mut crate::sketch::SketchDoc,
    corners: [serde_json::Value; 4],
) {
    use crate::sketch::doc::id_key;
    let [a, b1, b2, b3] = corners;
    let keys = [id_key(&a), id_key(&b1), id_key(&b2), id_key(&b3)];
    for i in 0..keys.len() {
        for j in (i + 1)..keys.len() {
            if keys[i] == keys[j] {
                return; // two corners collapsed → skip (no zero-length-edge ⟂).
            }
        }
    }
    // Adjacent edge pairs sharing corner b1 / b2 / b3.
    let pairs = [
        [a.clone(), b1.clone(), b1.clone(), b2.clone()],
        [b1.clone(), b2.clone(), b2.clone(), b3.clone()],
        [b2.clone(), b3.clone(), b3.clone(), a.clone()],
    ];
    for pair in pairs {
        let mut pts = pair.to_vec();
        if sketch_perpendicular_should_swap(doc, &pts) {
            pts.swap(0, 1);
        }
        push_geometric_constraint(doc, "", pts);
    }
}

/// Append a NON-dimensional geometric constraint (`type` + ordered `points`) with a
/// freshly minted id and the same base fields a palette-added constraint carries
/// (`labelX`/`labelY` = 0, `displayStyle` = "", `value` = null, `valueNeedsSetup` =
/// true — see [`sketch_build_and_add_constraint`]). Deduped on `type + sorted-points`
/// (the solver runs with `remove_implied_duplicates: false`, so this is the only
/// dedup); a no-op on a duplicate.
fn push_geometric_constraint(
    doc: &mut crate::sketch::SketchDoc,
    ctype: &str,
    points: Vec<serde_json::Value>,
) {
    let sig = sketch_constraint_signature(ctype, &points);
    let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
        Some(t) => sketch_constraint_signature(t, c.points()) == sig,
        None => false,
    });
    if duplicate {
        return;
    }
    let id = doc.next_constraint_id();
    let mut raw = serde_json::Map::new();
    raw.insert("id".to_string(), id);
    raw.insert("type".to_string(), serde_json::Value::String(ctype.to_string()));
    raw.insert("points".to_string(), serde_json::Value::Array(points));
    raw.insert("labelX".to_string(), serde_json::Value::from(0));
    raw.insert("labelY".to_string(), serde_json::Value::from(0));
    raw.insert(
        "displayStyle".to_string(),
        serde_json::Value::String(String::new()),
    );
    raw.insert("value".to_string(), serde_json::Value::Null);
    raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
    doc.constraints.push(crate::sketch::SketchConstraint { raw });
}

// ===========================================================================
// Sketch delete-selected (S3b) — remove the selected entities + orphan cleanup.
//
// Operates on the active `self.sketch_edit`. Rule (chosen so a remaining geometry
// NEVER references a missing point):
//   1. Partition the selection into selected geometry / point / constraint ids.
//   2. Drop every geometry that is SELECTED *or* references any selected point (the
//      remove-point cascade — deleting a vertex kills geometry that used it).
//   3. Drop the selected points.
//   4. Orphan cleanup: drop any remaining point NOT referenced by any surviving
//      geometry (a shared vertex — still referenced — stays; a deleted line's now
//      unshared endpoints vanish). Always on for this slice.
//   5. Drop any constraint that is SELECTED *or* references a removed point (selected ∪
//      orphaned) — done LAST, over the full removed-point set, so no constraint dangles
//      either. A selected constraint drops ONLY itself; the geometry/points it
//      referenced are untouched (deleting a constraint never deletes geometry).
// Then clear selection + hover, re-solve (swallowing errors), refresh, mark dirty.
// Kept in ONE appended block so concurrent edits to the primary impl land clean.
// ===========================================================================
impl EngineState {
    /// Delete the selected sketch entities (S3b): the selected geometries + points,
    /// plus any geometry orphaned by a deleted vertex, plus orphaned points and the
    /// constraints referencing any removed point. Re-solves + refreshes the overlay.
    /// Returns `true` when something was deleted; `false` when not in sketch mode or
    /// the selection is empty.
    pub fn sketch_delete_selection(&mut self) -> bool {
        use crate::sketch::doc::id_key;
        use std::collections::HashSet;

        let Some(edit) = self.sketch_edit.as_mut() else {
            return false;
        };
        if edit.session.selection.is_empty() {
            return false;
        }
        // A non-empty selection always removes something → snapshot for undo (S6a).
        edit.record_undo();

        // 1. Partition the selection into selected geometry / point / constraint ids
        //    (keyed via `id_key`, so 4 / 4.0 / "4" all match).
        let mut sel_geo: HashSet<String> = HashSet::new();
        let mut sel_pt: HashSet<String> = HashSet::new();
        let mut sel_constraint: HashSet<String> = HashSet::new();
        for r in &edit.session.selection {
            match (r.get("kind").and_then(|v| v.as_str()), r.get("id")) {
                (Some("geometry"), Some(id)) => {
                    sel_geo.insert(id_key(id));
                }
                (Some("point"), Some(id)) => {
                    sel_pt.insert(id_key(id));
                }
                (Some("constraint"), Some(id)) => {
                    sel_constraint.insert(id_key(id));
                }
                _ => {}
            }
        }

        let doc = &mut edit.session.doc;

        // 2. Drop geometries that are selected OR reference any selected point (so a
        //    deleted vertex never leaves a geometry dangling).
        doc.geometries.retain(|g| {
            if sel_geo.contains(&id_key(&g.id)) {
                return false;
            }
            !g.points.iter().any(|pid| sel_pt.contains(&id_key(pid)))
        });

        // 3. Drop the explicitly-selected points.
        doc.points.retain(|p| !sel_pt.contains(&id_key(&p.id)));

        // 4. Orphan cleanup: drop points no longer referenced by any surviving
        //    geometry. Accumulate every removed point id (selected ∪ orphaned).
        let referenced: HashSet<String> = doc
            .geometries
            .iter()
            .flat_map(|g| g.points.iter().map(id_key))
            .collect();
        let mut removed_pts = sel_pt;
        doc.points.retain(|p| {
            let key = id_key(&p.id);
            if referenced.contains(&key) {
                true
            } else {
                removed_pts.insert(key);
                false
            }
        });

        // 5. Drop constraints that are EXPLICITLY selected OR reference ANY removed
        //    point (done last, over the full removed set, so no constraint dangles onto
        //    a missing point). A selected constraint drops ONLY itself — the geometry /
        //    points it references are left intact (deleting a constraint never deletes
        //    geometry).
        doc.constraints.retain(|c| {
            if let Some(id) = c.raw.get("id") {
                if sel_constraint.contains(&id_key(id)) {
                    return false;
                }
            }
            !c.points().iter().any(|pid| removed_pts.contains(&id_key(pid)))
        });

        // Clear the interaction state, re-solve (keep the doc on failure), refresh.
        edit.session.clear_selection();
        edit.session.set_hover(None);
        self.resolve_active_sketch("delete");
        self.refresh_sketch_overlay();
        self.dirty = true;
        true
    }
}

// ===========================================================================
// Sketch constraint palette (S4) — selection → applicable constraints + apply.
//
// A faithful port of the previous sketcher's TWO authoritative pieces, kept engine-side:
//   * `SketchMode3D.#refreshContextBar` → [`sketch_applicable_constraints`] (which
//     selection surfaces which palette buttons).
//   * `ConstraintEngine.createConstraint` → [`sketch_add_constraint`] (selection →
//     ordered point-id list per symbol, incl. the arc-pop, the geometry-role
//     specials `◎/⊜/⌒/⋰/⋈`, the `⋯` point-first reverse, the `⟂` 4-point
//     orientation swap, and the `⏛`-from-2-lines DOUBLE push).
//
// Dimensional constraints (`⟺ ↥ ∠ R ⌀`) are added with `value:null` +
// `valueNeedsSetup:true` (matching the previous app) — the Rust solver seeds a NaN target to
// the CURRENT measured value on the next solve (`c_distance`/`c_angle`), so S4 never
// prompts for a value (S5 makes it editable). Every constraint carries the
// base fields (`labelX/labelY/displayStyle`) so a load/save round-trips unchanged.
//
// Adds are DEDUP'd on `type + sorted-point-ids` (the solver runs with
// `remove_implied_duplicates:false`, so this is the only dedup). Kept in ONE
// appended block so concurrent edits to the primary impl land clean.
// ===========================================================================