BREP_app 0.1.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
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
use super::*;

/// The RAW (unsmoothed) vertical wheel delta this frame, in UI points — summed
/// straight from the `MouseWheel` events instead of egui's `smooth_scroll_delta`.
/// egui smooths a wheel notch across ~6-10 frames (an ease-in/ease-out ramp that
/// reads as "dampening" at the start/end of a zoom); the raw events give one clean
/// discrete step per notch. Line/Page units are normalized to points the SAME way
/// egui's smoothing would (default `line_scroll_speed` = 40, private on
/// `InputState`, so mirrored here), so the calibrated `controls::wheel` step is
/// unchanged — only the ramp is gone.
fn raw_wheel_delta_y(ctx: &egui::Context) -> f32 {
    const LINE_POINTS: f32 = 40.0;
    ctx.input(|i| {
        i.events
            .iter()
            .filter_map(|event| match event {
                egui::Event::MouseWheel { unit, delta, .. } => Some(match unit {
                    egui::MouseWheelUnit::Point => delta.y,
                    egui::MouseWheelUnit::Line => delta.y * LINE_POINTS,
                    egui::MouseWheelUnit::Page => delta.y * LINE_POINTS * 20.0,
                }),
                _ => None,
            })
            .sum()
    })
}

impl Viewport {
    /// The last viewport rect as `{x, y, w, h}` in egui points (verification: the
    /// origin lets the verifier map engine viewport-local pick coords → page px).
    #[cfg(target_arch = "wasm32")]
    pub fn viewport_rect_json(&self) -> String {
        match self.last_rect {
            Some(r) => {
                serde_json::json!({ "x": r.min.x, "y": r.min.y, "w": r.width(), "h": r.height() })
                    .to_string()
            }
            None => "null".to_string(),
        }
    }

    /// The clean entry the shell calls: fill the central panel with the 3D
    /// viewport — size tracking, input routing, on-demand render, and the blit
    /// composite. Borrows the engine brain to draw + drive.
    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        let ppp = ui.ctx().pixels_per_point();
        // Empty frame → NO inner margin/padding: the 3D view fills the central
        // area edge-to-edge (the viewport paints the whole rect anyway).
        egui::containers::panel::CentralPanel::default()
            .frame(egui::Frame::NONE)
            .show(ui, |ui| {
                let rect = ui.available_rect_before_wrap();
            self.last_rect = Some(rect);
            let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());

            // Track viewport size in the engine (logical px) + offscreen (physical).
            let phys_w = (rect.width() * ppp).round().max(1.0) as u32;
            let phys_h = (rect.height() * ppp).round().max(1.0) as u32;
            self.ensure_offscreen(phys_w, phys_h);
            state.resize(rect.width() as f64, rect.height() as f64);

            // Feed input BEFORE rendering so a change is reflected this frame.
            self.handle_viewport_input(ui, rect, &response, state);

            if state.dirty {
                self.render_viewport(phys_w, phys_h, ppp, state);
                // Keep animating while a drag is live / more input pending.
                ui.ctx().request_repaint();
            }

            // Composite the offscreen 3D texture into egui's frame via the
            // wgpu paint callback.
            ui.painter().add(egui_wgpu::Callback::new_paint_callback(
                rect,
                ViewportCallback,
            ));
        });

        // The "candidates under the cursor" disambiguation popup (Alt+click)
        // floats over the viewport at ctx level, like the file dialog / palette.
        let ctx = ui.ctx().clone();
        self.show_candidate_popup(&ctx, state);

        // Editable dimension labels (S5): value text drawn at each dimension's
        // screen-projected anchor, click-to-edit + drag-to-reposition. Drawn at ctx
        // level (foreground of the viewport) so it floats over the 3D like the popup.
        if let Some(rect) = self.last_rect {
            self.draw_dimension_labels(&ctx, rect, state);
            // Feature-dimension labels (FD-1): the ◎ dimension-gizmo mode draws the
            // primitive's param dims here, click-to-edit + drag-to-resize.
            self.draw_feature_dimension_labels(&ctx, rect, state);
            // Transform-gizmo axis labels (XC/YC/ZC): the colored cone-tip labels.
            self.draw_transform_axis_labels(&ctx, rect, state);
        }
        if self.candidate_popup.is_some() || state.dirty {
            // Keep animating while the popup is open (its entry hover / a fresh
            // highlight is applied AFTER this frame's render).
            ctx.request_repaint();
        }

        // Verification hooks (wasm only): the selection-UX globals the headed
        // verifier reads. Published from HERE because viewport.rs owns hover + the
        // candidate popup (keeps app.rs untouched). Purely additive.
        #[cfg(target_arch = "wasm32")]
        {
            publish_to_js("__brepHover", &state.hovered_json());
            publish_to_js("__brepCandidates", &self.candidates_json());
            publish_to_js("__brepCandidateHit", &self.candidate_hits_json());
            // Re-publish the selection with the POST-input value: the app shell
            // publishes `__brepSelection` before this viewport draws (so its copy
            // lags a viewport click by a frame); overwrite it here with the value
            // that reflects this frame's click so the verifier reads it live.
            publish_to_js("__brepSelection", &state.selection_json());
            // The ◎ dimension-gizmo state (mode + annotations) for the verifier.
            publish_to_js("__brepFeatureDim", &state.feature_dimension_state_json());
        }
    }

    /// The ViewCube corner rect (logical px, viewport-local), if enabled.
    fn viewcube_local(&self, state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
        let v: serde_json::Value = serde_json::from_str(&state.viewcube_rect_json()).ok()?;
        let (rx, ry, rw, rh) = (
            v["x"].as_f64()?,
            v["y"].as_f64()?,
            v["w"].as_f64()?,
            v["h"].as_f64()?,
        );
        if rw > 0.0 && rh > 0.0 && x >= rx && x <= rx + rw && y >= ry && y <= ry + rh {
            Some((x - rx, y - ry))
        } else {
            None
        }
    }

    /// Route pointer/wheel over the viewport into the engine's SKETCH interaction
    /// (S2), while [`EngineState::sketch_mode`]. Point drags move sketch points;
    /// empty-space drags still orbit/pan the camera; clicks select (Ctrl/Cmd adds);
    /// hover lights the entity under the cursor. The ViewCube corner + wheel zoom
    /// keep working. NONE of the modeling select/candidate/transform/ref-select
    /// branches run here.
    fn handle_sketch_input(
        &mut self,
        ui: &egui::Ui,
        rect: egui::Rect,
        response: &egui::Response,
        state: &mut EngineState,
    ) {
        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);

        // A DRAW tool (point/line/rect/circle/arc) is active vs S2 selection mode.
        // In draw mode clicks PLACE geometry and points are NOT grabbed for dragging
        // (so an empty drag still orbits the camera); hover still runs for snap + the
        // rubber-band preview.
        let draw_mode = state.sketch_active_tool().is_some();

        // Right-click aborts the in-progress draw geometry. (Escape → drop back to
        // the Select/drag tool is handled globally in `BrepApp::handle_shortcuts`,
        // the only reliable capture point: it `consume_key`s Escape before the
        // viewport runs, and it fires for EVERY armed tool, not just draw mode.)
        if draw_mode && response.secondary_clicked() {
            state.sketch_tool_cancel();
        }

        // Delete / Backspace removes the current sketch selection (S3b) — geometries +
        // points + constraints + orphan cleanup, driven by the engine. Gated on sketch
        // mode AND on no egui TEXT edit being focused, so a Backspace typed into an open
        // dimension value editor edits the number rather than deleting the selection
        // (same guard `handle_shortcuts` uses for the global keys).
        let delete = !ui.ctx().text_edit_focused()
            && ui
                .ctx()
                .input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace));
        if delete {
            state.sketch_delete_selection();
        }

        if response.drag_started() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                // ViewCube first (a view snap); then the freehand handdraw stroke
                // capture (S6b-3 — a drag IS the stroke, never a camera orbit); then a
                // sketch point grab (SELECT mode only — other draw modes never grab, so
                // an empty drag orbits); else an empty-space camera orbit/pan so
                // navigation still works.
                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
                    state.viewcube_click(cx, cy);
                } else if state.sketch_active_tool() == Some("handdraw") {
                    state.sketch_handdraw_begin(lx, ly);
                    self.sketch_handdrawing = true;
                } else if !draw_mode && state.sketch_drag_begin(lx, ly) {
                    self.sketch_dragging = true;
                } else {
                    let btn = if response.dragged_by(egui::PointerButton::Secondary) {
                        BUTTON_RIGHT
                    } else if response.dragged_by(egui::PointerButton::Middle) {
                        BUTTON_MIDDLE
                    } else {
                        BUTTON_LEFT
                    };
                    state.pointer_down(lx, ly, btn);
                    self.dragging = true;
                }
            }
        }
        if response.dragged() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if self.sketch_handdrawing {
                    state.sketch_handdraw_move(lx, ly);
                } else if self.sketch_dragging {
                    state.sketch_drag_to(lx, ly);
                } else if self.dragging {
                    state.pointer_move(lx, ly);
                }
            }
        }
        if response.drag_stopped() {
            if self.sketch_handdrawing {
                state.sketch_handdraw_end();
                self.sketch_handdrawing = false;
            }
            if self.sketch_dragging {
                state.sketch_drag_end();
                self.sketch_dragging = false;
            }
            if self.dragging {
                state.pointer_up();
                self.dragging = false;
            }
        }

        // A plain click either PLACES draw-tool geometry (draw mode) or selects the
        // entity under the cursor (SELECT mode: Ctrl/Cmd adds/toggles, empty clears).
        // A click over the ViewCube corner is a view snap (handled on drag-start),
        // never a sketch pick/place.
        if response.clicked() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if self.viewcube_local(state, lx, ly).is_some() {
                    // ViewCube snap handled on drag-start; swallow the click.
                } else if state.sketch_active_tool() == Some("handdraw") {
                    // handdraw (S6b-3) captures a DRAG as a stroke; a plain click (no
                    // drag) is a deliberate no-op.
                } else if state.sketch_active_tool() == Some("pickEdges") {
                    // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor
                    // (pixel coords), never a plane place/select.
                    state.sketch_pick_edge_at(lx, ly);
                } else if draw_mode {
                    state.sketch_tool_click_at(lx, ly);
                } else {
                    let mods = ui.ctx().input(|i| i.modifiers);
                    state.sketch_click_at(lx, ly, mods.command || mods.ctrl);
                }
            }
        }

        // Hover: the entity under the pointer (skip while dragging / over the cube).
        // Also FREEZE the entity hover while the primary button is held in SELECT
        // mode: between the press and egui's drag-start (~6px of movement later) the
        // pointer keeps moving, and re-hovering there would slide the highlight off
        // the point the user pressed — so the grab (which takes the hovered entity)
        // would miss. Draw mode keeps updating (its rubber-band preview rides the
        // live hover).
        let primary_held = ui.input(|i| i.pointer.primary_down());
        if !self.sketch_dragging
            && !self.dragging
            && !self.sketch_handdrawing
            && !(primary_held && !draw_mode)
        {
            match response.hover_pos() {
                Some(pos) => {
                    let (lx, ly) = local(pos);
                    match self.viewcube_local(state, lx, ly) {
                        Some((cx, cy)) => {
                            state.viewcube_hover(cx, cy);
                            state.sketch_clear_hover(); // over the cube, not the sketch
                        }
                        None => {
                            state.viewcube_clear_hover();
                            state.sketch_hover_at(lx, ly);
                            // pickEdges (S6b-2) targets a 3D scene edge, so also
                            // hover-highlight the edge under the cursor (modeling
                            // emphasis) as a link affordance.
                            if state.sketch_active_tool() == Some("pickEdges") {
                                state.hover_at(lx, ly);
                            }
                        }
                    }
                }
                None => {
                    state.viewcube_clear_hover();
                    // Pointer is off the viewport entirely. Don't clobber a hover the
                    // entity-LIST panel set THIS frame (it drew before us) — that is
                    // the list→canvas highlight. When the panel didn't set one, clear
                    // as usual so a stale highlight doesn't linger.
                    if !state.take_sketch_list_hover() {
                        state.sketch_clear_hover();
                    }
                }
            }
        }

        // Wheel zoom toward the cursor (same as modeling).
        if response.hovered() {
            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
            // scroll). See [`raw_wheel_delta_y`].
            let scroll_y = raw_wheel_delta_y(ui.ctx());
            if scroll_y != 0.0 {
                let cursor = response.hover_pos().map(|p| {
                    let (lx, ly) = local(p);
                    [lx, ly]
                });
                state.wheel(-(scroll_y as f64), cursor);
            }
        }
    }

    /// Route pointer/wheel over the viewport into `EngineState` (mirrors
    /// `desktop.rs`). `rect` is the viewport in egui points; coords fed to the
    /// engine are viewport-local logical px, the space `state.camera` lives in.
    fn handle_viewport_input(
        &mut self,
        ui: &egui::Ui,
        rect: egui::Rect,
        response: &egui::Response,
        state: &mut EngineState,
    ) {
        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);

        // Sketch mode (S2) owns the pointer: hover/select/point-drag in plane space,
        // never the modeling select/candidate/transform/ref-select branches. Routed
        // BEFORE the modeling path, which stays byte-for-byte for `!sketch_mode()`.
        if state.sketch_mode() {
            self.handle_sketch_input(ui, rect, response, state);
            return;
        }

        if response.drag_started() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                // Every drag-start ends the hover: the pointer is now steering
                // the camera / the cube / a gizmo handle / a dimension arrow,
                // not hovering the model — a frozen highlight riding a camera
                // snap, an orbit, or geometry that a gizmo/dim drag is reshaping
                // live reads as a stale pick. Hover re-resolves at the new pose
                // as soon as the interaction ends (the per-frame hover branch).
                state.clear_hover();
                // A press over the ViewCube corner snaps/orbits via the cube; a
                // press on an armed transform-gizmo HANDLE drives the gizmo;
                // anywhere else starts a camera drag through the controls.
                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
                    state.viewcube_click(cx, cy);
                } else if state.transform_press(lx, ly) {
                    // Grabbed a gizmo handle → route the drag to the transform.
                    self.gizmo_dragging = true;
                } else if let Some(field) = state.dimension_arrow_pick(lx, ly) {
                    // Grabbed a ◎ dimension ARROWHEAD (Fix 4) → route the drag to
                    // that param's live edit instead of orbiting the camera.
                    self.dim_dragging = Some(field);
                } else {
                    let btn = if response.dragged_by(egui::PointerButton::Secondary) {
                        BUTTON_RIGHT
                    } else if response.dragged_by(egui::PointerButton::Middle) {
                        BUTTON_MIDDLE
                    } else {
                        BUTTON_LEFT
                    };
                    state.pointer_down(lx, ly, btn);
                    self.dragging = true;
                }
            }
        }
        if response.dragged() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if self.gizmo_dragging {
                    // Drive the transform gizmo: updates the feature's transform +
                    // re-runs the history (the model moves live).
                    state.transform_drag_to(lx, ly);
                } else if let Some(field) = self.dim_dragging.clone() {
                    // Drive the dimension arrow (Fix 4): edit the param + re-run the
                    // history live, so the geometry AND its arrow follow the pointer.
                    let feature = state.dimension_armed_feature();
                    if !feature.is_empty() {
                        state.feature_dimension_drag(&feature, &field, lx, ly);
                    }
                } else if self.dragging {
                    state.pointer_move(lx, ly);
                }
            }
        }
        if response.drag_stopped() {
            if self.gizmo_dragging {
                state.transform_release();
                self.gizmo_dragging = false;
            }
            if self.dim_dragging.take().is_some() {
                // The overlay is already glued to the final value from the last drag
                // frame; just drop the flag so hover/select resume.
            }
            if self.dragging {
                state.pointer_up();
                self.dragging = false;
            }
        }

        // A plain click (press+release, no drag) over the viewport: in
        // reference-selection mode it type-constrained-picks a reference under the
        // cursor (engine drives the highlight); otherwise the modeling selection
        // UX — a PLAIN click REPLACES the selection with the top filter-admitted
        // pick, Ctrl/Cmd+click ADDS/TOGGLES it (multi-select), and Alt+click opens
        // the "candidates under the cursor" disambiguation list. ViewCube clicks
        // are handled above (as a snap), never a selection/pick.
        if response.clicked() {
            if let Some(pos) = response.interact_pointer_pos() {
                let on_popup = self.candidate_popup.is_some()
                    && self
                        .candidate_popup_rect
                        .map(|r| r.contains(pos))
                        .unwrap_or(false);
                let (lx, ly) = local(pos);
                let mods = ui.ctx().input(|i| i.modifiers);
                if on_popup {
                    // A click inside the OPEN popup belongs to the popup — its
                    // entry buttons handle it in `show_candidate_popup`. Don't let
                    // the viewport close it or pick the geometry behind it.
                } else if state.ref_select_active() {
                    state.ref_select_click(lx, ly);
                } else if state.transform_center_pick(lx, ly) {
                    // The orange CENTER sphere in transform mode toggles to the
                    // DIMENSION arrows (the old app's center-handle ◎ toggle).
                    // Must precede the generic handle-swallow below so a center
                    // click flips modes instead of being swallowed; a center
                    // DRAG still free-moves (handled on drag-start, not here).
                    state.toggle_to_dimension();
                } else if state.dimension_origin_pick(lx, ly) {
                    // The orange ORIGIN sphere in dimension mode toggles back to
                    // the TRANSFORM controls (the reverse ◎ toggle).
                    state.toggle_to_transform();
                } else if state.dimension_arrow_pick(lx, ly).is_some() {
                    // A bare click on a dimension ARROWHEAD is a no-op — only a DRAG
                    // on it edits the value (Fix 4). Swallow it so the solid behind
                    // the arrow isn't selected.
                } else if state.transform_armed() && state.transform_pick(lx, ly) != 0 {
                    // A bare click on an armed (non-center) gizmo handle — swallow
                    // it so the solid behind the gizmo is not selected.
                } else if self.viewcube_local(state, lx, ly).is_some() {
                    // A click over the ViewCube corner is a view snap (handled on
                    // drag-start), never a scene selection.
                } else if mods.alt {
                    // Alt+click → open the disambiguation list of everything the
                    // filter admits under the cursor (plain / Ctrl click below
                    // still select directly, so "plain click still replaces").
                    let cands = state.candidates_filtered_at(lx, ly);
                    self.candidate_popup = (!cands.is_empty())
                        .then(|| CandidatePopup { anchor: pos, candidates: cands });
                    self.candidate_popup_fresh = self.candidate_popup.is_some();
                } else if mods.command || mods.ctrl {
                    state.select_toggle_at(lx, ly);
                    self.candidate_popup = None;
                } else {
                    // A plain click REPLACES the selection with the top scene entity;
                    // when nothing is hit, fall back to a construction datum/plane
                    // under the cursor (datums are large, so they yield to any real
                    // entity — the old-app precedence). `select_top_at` clears on a
                    // miss, and `datum_pick` returns "" when no plane is hit.
                    if !state.select_top_at(lx, ly) {
                        let datum = state.datum_pick(lx, ly);
                        if !datum.is_empty() {
                            state.select_datum(&datum);
                        }
                    }
                    self.candidate_popup = None;
                }
            }
        }

        // Hover: the ViewCube corner, an armed transform-gizmo handle, OR the
        // top filter-admitted scene entity under the pointer (the modeling
        // hover-highlight). Suppressed while dragging / over a gizmo handle / mid
        // dimension-arrow drag, and while the candidate popup owns the highlight.
        if !self.dragging && !self.gizmo_dragging && self.dim_dragging.is_none() {
            match response.hover_pos() {
                Some(pos) => {
                    let (lx, ly) = local(pos);
                    match self.viewcube_local(state, lx, ly) {
                        Some((cx, cy)) => {
                            state.viewcube_hover(cx, cy);
                            state.clear_hover(); // over the cube, not the scene
                        }
                        None => {
                            state.viewcube_clear_hover();
                            // Over an armed gizmo handle → highlight the handle, not
                            // the solid behind it (the previous app's "skip scene hover over
                            // the gizmo" rule). Else hover-highlight the top pick.
                            let over_handle =
                                state.transform_armed() && state.transform_hover(lx, ly) != 0;
                            if over_handle {
                                state.clear_hover();
                            } else if self.candidate_popup.is_none() {
                                state.hover_at(lx, ly);
                            }
                        }
                    }
                }
                None => {
                    state.viewcube_clear_hover();
                    // Pointer left the viewport (or moved onto the popup, which
                    // drives its own entry-hover) → drop the scene hover.
                    if self.candidate_popup.is_none() {
                        state.clear_hover();
                    }
                }
            }
        }

        // Wheel zoom toward the cursor when hovering the viewport.
        if response.hovered() {
            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
            // scroll). See [`raw_wheel_delta_y`].
            let scroll_y = raw_wheel_delta_y(ui.ctx());
            if scroll_y != 0.0 {
                let cursor = response.hover_pos().map(|p| {
                    let (lx, ly) = local(p);
                    [lx, ly]
                });
                // egui scroll: +y = wheel up = zoom in; the controls treat
                // negative delta_y as zoom-in (see desktop.rs), so negate.
                state.wheel(-(scroll_y as f64), cursor);
            }
        }
    }

    /// Draw the "candidates under the cursor" disambiguation popup (Alt+click) —
    /// the egui port of the old app's selection picker (`_showSelectionOverlay`):
    /// a small floating list of the ranked, filter-admitted candidates at the
    /// cursor, in the recovered sort order (kind priority VERTEX > EDGE > FACE,
    /// SOLID last, then depth). HOVERING an entry pre-highlights that entity in
    /// the scene (and hover-out clears it, the old per-row mouseleave); CLICKING
    /// it selects exactly it (plain = replace + close, Ctrl/Cmd = add/toggle and
    /// keep the list open); the header's "Clear Selection" clears + closes (the
    /// old picker's header action). Closes on Escape, a click outside, or an
    /// entry click. NOTE the trigger is the explicit Alt+click, NOT plain-click-
    /// on-multiple: the old app dropped that trigger (11fdbd336) because the
    /// engine reports front+back faces + the owning solid at every pixel, which
    /// would open the picker on every click. Rebuilds `candidate_hits`
    /// (per-entry screen rects) each frame for the headed verifier. Engine
    /// mutations are applied AFTER the draw closure (the codebase's "no engine
    /// mutation inside the draw" rule).
    fn show_candidate_popup(&mut self, ctx: &egui::Context, state: &mut EngineState) {
        self.candidate_hits.clear();
        let Some(popup) = self.candidate_popup.as_ref() else {
            self.candidate_popup_rect = None;
            return;
        };
        let candidates = popup.candidates.clone();
        let anchor = popup.anchor;
        let mods = ctx.input(|i| i.modifiers);

        let mut hits: Vec<egui::Rect> = Vec::with_capacity(candidates.len());
        let mut hovered_index: Option<usize> = None;
        let mut clicked_index: Option<usize> = None;
        let mut clear_clicked = false;

        let area = egui::Area::new(egui::Id::new("brep-candidate-popup"))
            .order(egui::Order::Foreground)
            .fixed_pos(anchor)
            .show(ctx, |ui| {
                egui::Frame::popup(ui.style()).show(ui, |ui| {
                    ui.set_max_width(280.0);
                    // Header row — the old app's selection picker: its title +
                    // a "Clear Selection" action.
                    ui.horizontal(|ui| {
                        ui.label(egui::RichText::new("Select an object").weak().small());
                        ui.with_layout(
                            egui::Layout::right_to_left(egui::Align::Center),
                            |ui| {
                                if ui.small_button("Clear Selection").clicked() {
                                    clear_clicked = true;
                                }
                            },
                        );
                    });
                    for (i, candidate) in candidates.iter().enumerate() {
                        let label =
                            format!("{}  {}", candidate.kind.as_str(), candidate_label(candidate));
                        let resp = ui.add(
                            egui::Button::new(label).min_size(egui::vec2(220.0, 0.0)),
                        );
                        hits.push(resp.rect);
                        if resp.hovered() {
                            hovered_index = Some(i);
                        }
                        if resp.clicked() {
                            clicked_index = Some(i);
                        }
                    }
                });
            });

        self.candidate_hits = hits;
        self.candidate_popup_rect = Some(area.response.rect);

        // Apply engine mutations outside the draw closure.
        if let Some(i) = hovered_index {
            state.hover_candidate(&candidates[i]);
        } else {
            // No entry under the pointer → drop the pre-highlight (the old
            // app's per-row mouseleave), so the last-hovered row's entity
            // doesn't stay lit while the pointer roams elsewhere.
            state.clear_hover();
        }
        let mut close = false;
        if clear_clicked {
            state.clear_selection();
            close = true;
        }
        if let Some(i) = clicked_index {
            if mods.command || mods.ctrl {
                // Ctrl/Cmd+click TOGGLES the entry and KEEPS the list open, so the
                // user can build a multi-selection from the overlapping entities.
                state.toggle_candidate(&candidates[i]);
            } else {
                // A plain click picks exactly that entity and closes the list.
                state.select_candidate(&candidates[i]);
                close = true;
            }
        }
        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
            close = true;
        }
        // Ignore the OPENING Alt+click on the frame it opened; honor click-outside
        // from the next frame on.
        if self.candidate_popup_fresh {
            self.candidate_popup_fresh = false;
        } else if area.response.clicked_elsewhere() {
            close = true;
        }
        if close {
            state.clear_hover();
            self.candidate_popup = None;
            self.candidate_popup_rect = None;
        }
    }

    /// The OPEN popup's candidate list as JSON `[{index,kind,name,solid,depth}]`
    /// (empty when closed) — the headed verifier asserts the sorted list.
    #[cfg(target_arch = "wasm32")]
    fn candidates_json(&self) -> String {
        match self.candidate_popup.as_ref() {
            Some(popup) => {
                let out: Vec<serde_json::Value> = popup
                    .candidates
                    .iter()
                    .enumerate()
                    .map(|(i, c)| {
                        serde_json::json!({
                            "index": i,
                            "kind": c.kind.as_str(),
                            "name": c.name,
                            "solid": c.solid,
                            "depth": c.depth,
                        })
                    })
                    .collect();
                serde_json::Value::Array(out).to_string()
            }
            None => "[]".to_string(),
        }
    }

    /// The OPEN popup's per-entry screen rects as JSON `[{index,x,y,w,h}]` (egui
    /// points) so the verifier can click a specific candidate entry.
    #[cfg(target_arch = "wasm32")]
    fn candidate_hits_json(&self) -> String {
        let out: Vec<serde_json::Value> = self
            .candidate_hits
            .iter()
            .enumerate()
            .map(|(i, r)| {
                serde_json::json!({
                    "index": i,
                    "x": r.min.x,
                    "y": r.min.y,
                    "w": r.width(),
                    "h": r.height(),
                })
            })
            .collect();
        serde_json::Value::Array(out).to_string()
    }
}

/// A human label for a pick candidate: its kernel name, or a positional tag for
/// unnamed vertices.
fn candidate_label(candidate: &PickCandidate) -> String {
    if candidate.name.trim().is_empty() {
        let p = candidate.position;
        format!("({:.2}, {:.2}, {:.2})", p[0], p[1], p[2])
    } else {
        candidate.name.clone()
    }
}

/// Mirror an engine JSON string to `window.<name>` (wasm/verification only) — the
/// viewport's own copy so it can publish its hover/candidate globals without
/// reaching into the app shell.
#[cfg(target_arch = "wasm32")]
fn publish_to_js(name: &str, json: &str) {
    if let Some(win) = web_sys::window() {
        let _ = js_sys::Reflect::set(
            &win,
            &wasm_bindgen::JsValue::from_str(name),
            &wasm_bindgen::JsValue::from_str(json),
        );
    }
}