Skip to main content

brep_app/viewport/
interaction.rs

1use super::*;
2
3/// The RAW (unsmoothed) vertical wheel delta this frame, in UI points — summed
4/// straight from the `MouseWheel` events instead of egui's `smooth_scroll_delta`.
5/// egui smooths a wheel notch across ~6-10 frames (an ease-in/ease-out ramp that
6/// reads as "dampening" at the start/end of a zoom); the raw events give one clean
7/// discrete step per notch. Line/Page units are normalized to points the SAME way
8/// egui's smoothing would (default `line_scroll_speed` = 40, private on
9/// `InputState`, so mirrored here), so the calibrated `controls::wheel` step is
10/// unchanged — only the ramp is gone.
11fn raw_wheel_delta_y(ctx: &egui::Context) -> f32 {
12    const LINE_POINTS: f32 = 40.0;
13    ctx.input(|i| {
14        i.events
15            .iter()
16            .filter_map(|event| match event {
17                egui::Event::MouseWheel { unit, delta, .. } => Some(match unit {
18                    egui::MouseWheelUnit::Point => delta.y,
19                    egui::MouseWheelUnit::Line => delta.y * LINE_POINTS,
20                    egui::MouseWheelUnit::Page => delta.y * LINE_POINTS * 20.0,
21                }),
22                _ => None,
23            })
24            .sum()
25    })
26}
27
28impl Viewport {
29    /// The rect (egui points) the viewport last drew into. Now that the viewport
30    /// is a dock PANE (its rect moves as the user re-frames it), the shell anchors
31    /// the floating context / Finish-Cancel overlays to THIS rect's right edge so
32    /// they stay glued to the 3D view — see the top-right overlay in `app.rs`.
33    /// `None` before the first draw (shell falls back to window-right until then).
34    pub fn last_rect(&self) -> Option<egui::Rect> {
35        self.last_rect
36    }
37
38    /// The last viewport rect as `{x, y, w, h}` in egui points (verification: the
39    /// origin lets the verifier map engine viewport-local pick coords → page px).
40    pub fn viewport_rect_json(&self) -> String {
41        match self.last_rect {
42            Some(r) => {
43                serde_json::json!({ "x": r.min.x, "y": r.min.y, "w": r.width(), "h": r.height() })
44                    .to_string()
45            }
46            None => "null".to_string(),
47        }
48    }
49
50    /// The clean entry the shell calls: fill the central panel with the 3D
51    /// viewport — size tracking, input routing, on-demand render, and the blit
52    /// composite. Borrows the engine brain to draw + drive.
53    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
54        let ppp = ui.ctx().pixels_per_point();
55        // Empty frame → NO inner margin/padding: the 3D view fills the central
56        // area edge-to-edge (the viewport paints the whole rect anyway).
57        egui::containers::panel::CentralPanel::default()
58            .frame(egui::Frame::NONE)
59            .show(ui, |ui| {
60                let rect = ui.available_rect_before_wrap();
61            self.last_rect = Some(rect);
62            let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
63
64            // Track viewport size in the engine (logical px) + offscreen (physical).
65            let phys_w = (rect.width() * ppp).round().max(1.0) as u32;
66            let phys_h = (rect.height() * ppp).round().max(1.0) as u32;
67            self.ensure_offscreen(phys_w, phys_h);
68            state.resize(rect.width() as f64, rect.height() as f64);
69
70            // Feed input BEFORE rendering so a change is reflected this frame.
71            self.handle_viewport_input(ui, rect, &response, state);
72
73            // Per-frame overlay upkeep, AFTER the input + resize above so a zoom
74            // is reflected in the SAME frame it happened. Re-bakes the
75            // screen-constant sizing of every draggable gizmo that rides the
76            // pre-expanded `set_overlay` channel — assembly-constraint handles
77            // (§8.4), the ◎ feature-dimension gizmo, the live sketch overlay —
78            // on a material world-per-pixel change, and hides/restores the
79            // constraint graphics with their toggle + sketch mode.
80            state.ensure_overlays_current();
81
82            if state.dirty {
83                self.render_viewport(phys_w, phys_h, ppp, state);
84                // Keep animating while a drag is live / more input pending.
85                ui.ctx().request_repaint();
86            }
87
88            // Composite the offscreen 3D texture into egui's frame via the
89            // wgpu paint callback.
90            ui.painter().add(egui_wgpu::Callback::new_paint_callback(
91                rect,
92                ViewportCallback,
93            ));
94        });
95
96        // The "candidates under the cursor" disambiguation popup (Alt+click)
97        // floats over the viewport at ctx level, like the file dialog / palette.
98        let ctx = ui.ctx().clone();
99        self.show_candidate_popup(&ctx, state);
100
101        // Editable dimension labels (S5): value text drawn at each dimension's
102        // screen-projected anchor, click-to-edit + drag-to-reposition. Drawn at ctx
103        // level (foreground of the viewport) so it floats over the 3D like the popup.
104        if let Some(rect) = self.last_rect {
105            self.draw_dimension_labels(&ctx, rect, state);
106            // Feature-dimension labels (FD-1): the ◎ dimension-gizmo mode draws the
107            // primitive's param dims here, click-to-edit + drag-to-resize.
108            self.draw_feature_dimension_labels(&ctx, rect, state);
109            // Transform-gizmo axis labels (XC/YC/ZC): the colored cone-tip labels.
110            self.draw_transform_axis_labels(&ctx, rect, state);
111            // Assembly-constraint labels (§8.4): status-colored chips at each
112            // constraint's anchor — hover highlights the referenced geometry,
113            // click expands the row in the Assembly Constraints panel.
114            self.draw_constraint_labels(&ctx, rect, state);
115            // PMI labels: the active view's annotation chips — drag to move
116            // the label, click to open the annotation, hover to highlight its
117            // geometry.
118            self.draw_pmi_labels(&ctx, rect, state);
119            // DEBUG: 1px RED outline of the EXACT gizmo-arrow hit regions (axis
120            // capsules + grab circles of the transform widget — feature transform
121            // mode AND the component Move gizmo — or the dimension arrowheads).
122            // Drawn LAST so the outlines overlay everything.
123            self.draw_gizmo_hit_areas(&ctx, rect, state);
124        }
125        if self.candidate_popup.is_some() || state.dirty {
126            // Keep animating while the popup is open (its entry hover / a fresh
127            // highlight is applied AFTER this frame's render).
128            ctx.request_repaint();
129        }
130
131        // Verification hooks (wasm only): the selection-UX globals the headed
132        // verifier reads. Published from HERE because viewport.rs owns hover + the
133        // candidate popup (keeps app.rs untouched). Purely additive.
134        if crate::automation::registry::enabled() {
135            crate::automation::registry::publish("__brepHover", "hovered entity", &state.hovered_json());
136            crate::automation::registry::publish("__brepCandidates", "open pick-candidate popup entries [{index,kind,name,solid,depth}]", &self.candidates_json(state));
137            crate::automation::registry::publish("__brepCandidateHit", "pick-candidate popup entry rects", &self.candidate_hits_json());
138            // Re-publish the selection with the POST-input value: the app shell
139            // publishes `__brepSelection` before this viewport draws (so its copy
140            // lags a viewport click by a frame); overwrite it here with the value
141            // that reflects this frame's click so the verifier reads it live.
142            crate::automation::registry::publish("__brepSelection", "selection {solids, faces, edges, datums, vertices}", &state.selection_json());
143            // The ◎ dimension-gizmo state (mode + annotations) for the verifier.
144            crate::automation::registry::publish("__brepFeatureDim", "dimension gizmo state (mode, annotations)", &state.feature_dimension_state_json());
145            // The assembly-constraint overlay labels (id/text/status/color/world/
146            // draggable) so the verifier can locate + drag a constraint handle.
147            crate::automation::registry::publish("__brepConstraints", "assembly constraint overlay labels", &state.constraint_labels_json());
148            // The active PMI view's label chips (id/type/text/status/world) so
149            // the verifier can locate, drag and click an annotation.
150            crate::automation::registry::publish("__brepPmiLabels", "PMI label chips of the active view", &state.pmi_labels_json());
151            crate::automation::registry::publish("__brepPmiLabelHit", "PMI label chip rects {id: [x,y,w,h]}", &self.pmi_label_hits_json());
152            // The ViewCube corner rect (viewport-local `{x,y,w,h}`) so the verifier
153            // can click a cube face/edge/corner by hit-rect (offset by `__brepView`).
154            crate::automation::registry::publish("__brepViewCube", "ViewCube rect (viewport-local)", &state.viewcube_rect_json());
155        }
156    }
157
158    /// The PMI chips' screen rects (`{id: [x, y, w, h]}`, egui points) —
159    /// the `__brepPmiLabelHit` verifier global.
160    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
161    fn pmi_label_hits_json(&self) -> String {
162        let map: serde_json::Map<String, serde_json::Value> = self
163            .pmi_label_hits
164            .iter()
165            .map(|(id, rect)| {
166                (
167                    id.clone(),
168                    serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
169                )
170            })
171            .collect();
172        serde_json::Value::Object(map).to_string()
173    }
174
175    /// The ViewCube corner rect (logical px, viewport-local), if enabled.
176    fn viewcube_local(&self, state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
177        viewcube_local(state, x, y)
178    }
179
180    /// Route pointer/wheel over the viewport into the engine's SKETCH interaction
181    /// (S2), while [`EngineState::sketch_mode`]. Point drags move sketch points;
182    /// empty-space drags still orbit/pan the camera; clicks select (Ctrl/Cmd adds);
183    /// hover lights the entity under the cursor. The ViewCube corner + wheel zoom
184    /// keep working. NONE of the modeling select/candidate/transform/ref-select
185    /// branches run here.
186    fn handle_sketch_input(
187        &mut self,
188        ui: &egui::Ui,
189        rect: egui::Rect,
190        response: &egui::Response,
191        state: &mut EngineState,
192    ) {
193        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);
194
195        // A DRAW tool (point/line/rect/circle/arc) is active vs S2 selection mode.
196        // In draw mode clicks PLACE geometry and points are NOT grabbed for dragging
197        // (so an empty drag still orbits the camera); hover still runs for snap + the
198        // rubber-band preview.
199        let draw_mode = state.sketch_active_tool().is_some();
200
201        // Right-click aborts the in-progress draw geometry. (Escape → drop back to
202        // the Select/drag tool is handled globally in `BrepApp::handle_shortcuts`,
203        // the only reliable capture point: it `consume_key`s Escape before the
204        // viewport runs, and it fires for EVERY armed tool, not just draw mode.)
205        if draw_mode && response.secondary_clicked() {
206            state.sketch_tool_cancel();
207        }
208
209        // Delete / Backspace removes the current sketch selection (S3b) — geometries +
210        // points + constraints + orphan cleanup, driven by the engine. Gated on sketch
211        // mode AND on no egui TEXT edit being focused, so a Backspace typed into an open
212        // dimension value editor edits the number rather than deleting the selection
213        // (same guard `handle_shortcuts` uses for the global keys).
214        let delete = !ui.ctx().text_edit_focused()
215            && ui
216                .ctx()
217                .input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace));
218        if delete {
219            state.sketch_delete_selection();
220        }
221
222        if response.drag_started() {
223            if let Some(pos) = response.interact_pointer_pos() {
224                let (lx, ly) = local(pos);
225                // ViewCube first (a view snap); then the freehand handdraw stroke
226                // capture (S6b-3 — a drag IS the stroke, never a camera orbit); then a
227                // sketch point grab (SELECT mode only — other draw modes never grab, so
228                // an empty drag orbits); else an empty-space camera orbit/pan so
229                // navigation still works.
230                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
231                    state.viewcube_click(cx, cy);
232                } else if state.sketch_active_tool() == Some("handdraw") {
233                    state.sketch_handdraw_begin(lx, ly);
234                    self.sketch_handdrawing = true;
235                } else if !draw_mode && state.sketch_drag_begin(lx, ly) {
236                    self.sketch_dragging = true;
237                } else {
238                    let btn = if response.dragged_by(egui::PointerButton::Secondary) {
239                        BUTTON_RIGHT
240                    } else if response.dragged_by(egui::PointerButton::Middle) {
241                        BUTTON_MIDDLE
242                    } else {
243                        BUTTON_LEFT
244                    };
245                    state.pointer_down(lx, ly, btn);
246                    self.dragging = true;
247                }
248            }
249        }
250        if response.dragged() {
251            if let Some(pos) = response.interact_pointer_pos() {
252                let (lx, ly) = local(pos);
253                if self.sketch_handdrawing {
254                    state.sketch_handdraw_move(lx, ly);
255                } else if self.sketch_dragging {
256                    state.sketch_drag_to(lx, ly);
257                } else if self.dragging {
258                    state.pointer_move(lx, ly);
259                }
260            }
261        }
262        if response.drag_stopped() {
263            if self.sketch_handdrawing {
264                state.sketch_handdraw_end();
265                self.sketch_handdrawing = false;
266            }
267            if self.sketch_dragging {
268                state.sketch_drag_end();
269                self.sketch_dragging = false;
270            }
271            if self.dragging {
272                state.pointer_up();
273                self.dragging = false;
274            }
275        }
276
277        // A plain click either PLACES draw-tool geometry (draw mode) or selects the
278        // entity under the cursor (SELECT mode: Ctrl/Cmd adds/toggles, empty clears).
279        // A click over the ViewCube corner snaps the camera, never a sketch pick/place.
280        if response.clicked() {
281            if let Some(pos) = response.interact_pointer_pos() {
282                let (lx, ly) = local(pos);
283                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
284                    // A plain click on the ViewCube corner snaps the camera. A plain
285                    // click never fires drag_started (see the modeling path), so the
286                    // snap must run here, not only on the drag-start branch above.
287                    state.viewcube_click(cx, cy);
288                } else if state.sketch_active_tool() == Some("handdraw") {
289                    // handdraw (S6b-3) captures a DRAG as a stroke; a plain click (no
290                    // drag) is a deliberate no-op.
291                } else if state.sketch_active_tool() == Some("pickEdges") {
292                    // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor
293                    // (pixel coords), never a plane place/select.
294                    state.sketch_pick_edge_at(lx, ly);
295                } else if draw_mode {
296                    state.sketch_tool_click_at(lx, ly);
297                } else {
298                    let mods = ui.ctx().input(|i| i.modifiers);
299                    state.sketch_click_at(lx, ly, mods.command || mods.ctrl);
300                }
301            }
302        }
303
304        // Hover: the entity under the pointer (skip while dragging / over the cube).
305        // Also FREEZE the entity hover while the primary button is held in SELECT
306        // mode: between the press and egui's drag-start (~6px of movement later) the
307        // pointer keeps moving, and re-hovering there would slide the highlight off
308        // the point the user pressed — so the grab (which takes the hovered entity)
309        // would miss. Draw mode keeps updating (its rubber-band preview rides the
310        // live hover).
311        let primary_held = ui.input(|i| i.pointer.primary_down());
312        if !self.sketch_dragging
313            && !self.dragging
314            && !self.sketch_handdrawing
315            && !(primary_held && !draw_mode)
316        {
317            match response.hover_pos() {
318                Some(pos) => {
319                    let (lx, ly) = local(pos);
320                    match self.viewcube_local(state, lx, ly) {
321                        Some((cx, cy)) => {
322                            state.viewcube_hover(cx, cy);
323                            state.sketch_clear_hover(); // over the cube, not the sketch
324                        }
325                        None => {
326                            state.viewcube_clear_hover();
327                            state.sketch_hover_at(lx, ly);
328                            // pickEdges (S6b-2) targets a 3D scene edge, so also
329                            // hover-highlight the edge under the cursor (modeling
330                            // emphasis) as a link affordance.
331                            if state.sketch_active_tool() == Some("pickEdges") {
332                                state.hover_at(lx, ly);
333                            }
334                        }
335                    }
336                }
337                None => {
338                    state.viewcube_clear_hover();
339                    // Pointer is off the viewport entirely. Don't clobber a hover the
340                    // entity-LIST panel set THIS frame (it drew before us) — that is
341                    // the list→canvas highlight. When the panel didn't set one, clear
342                    // as usual so a stale highlight doesn't linger.
343                    if !state.take_sketch_list_hover() {
344                        state.sketch_clear_hover();
345                    }
346                }
347            }
348        }
349
350        // Wheel zoom toward the cursor (same as modeling).
351        if response.hovered() {
352            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
353            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
354            // scroll). See [`raw_wheel_delta_y`].
355            let scroll_y = raw_wheel_delta_y(ui.ctx());
356            if scroll_y != 0.0 {
357                let cursor = response.hover_pos().map(|p| {
358                    let (lx, ly) = local(p);
359                    [lx, ly]
360                });
361                state.wheel(-(scroll_y as f64), cursor);
362            }
363        }
364    }
365
366    /// Route pointer/wheel over the viewport into `EngineState` (mirrors
367    /// `desktop.rs`). `rect` is the viewport in egui points; coords fed to the
368    /// engine are viewport-local logical px, the space `state.camera` lives in.
369    fn handle_viewport_input(
370        &mut self,
371        ui: &egui::Ui,
372        rect: egui::Rect,
373        response: &egui::Response,
374        state: &mut EngineState,
375    ) {
376        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);
377
378        // Sketch mode (S2) owns the pointer: hover/select/point-drag in plane space,
379        // never the modeling select/candidate/transform/ref-select branches. Routed
380        // BEFORE the modeling path, which stays byte-for-byte for `!sketch_mode()`.
381        if state.sketch_mode() {
382            self.handle_sketch_input(ui, rect, response, state);
383            return;
384        }
385
386        if response.drag_started() {
387            if let Some(pos) = response.interact_pointer_pos() {
388                let (lx, ly) = local(pos);
389                // Every drag-start ends the hover: the pointer is now steering
390                // the camera / the cube / a gizmo handle / a dimension arrow,
391                // not hovering the model — a frozen highlight riding a camera
392                // snap, an orbit, or geometry that a gizmo/dim drag is reshaping
393                // live reads as a stale pick. Hover re-resolves at the new pose
394                // as soon as the interaction ends (the per-frame hover branch).
395                state.clear_hover();
396                // WHERE the press landed — the point every handle hit test below is
397                // taken at ([`drag_start_local`]), which is NOT `pos`: egui only calls
398                // a press a drag once the pointer has left the click radius, so by
399                // this frame `pos` has already drifted off whatever the user
400                // pressed on.
401                let (gx, gy) = drag_start_local(response, rect).unwrap_or((lx, ly));
402                // A drag that STARTS over the ViewCube corner snaps/orbits via the
403                // cube (a plain click — which never fires drag_started — is snapped
404                // in the `clicked()` branch below); a press on an armed
405                // transform-gizmo HANDLE drives the gizmo; anywhere else starts a
406                // camera drag through the controls.
407                match route_drag_start(state, gx, gy) {
408                    // The cube already snapped the camera inside the router.
409                    DragStart::ViewCube => {}
410                    // Grabbed a gizmo handle → route the drag to the transform.
411                    DragStart::Gizmo => self.gizmo_dragging = true,
412                    // Grabbed the armed COMPONENT Move gizmo → free-move the gizmo
413                    // during the drag, commit the pose (+ re-solve) on release.
414                    DragStart::Component => self.component_gizmo_dragging = true,
415                    // Grabbed a ◎ dimension ARROWHEAD (Fix 4) → route the drag to
416                    // that param's live edit instead of orbiting the camera.
417                    DragStart::Dimension(field) => self.dim_dragging = Some(field),
418                    // Grabbed an ASSEMBLY-CONSTRAINT handle (a distance leader /
419                    // angle-arc handle, §8.4 grabbable arrows) → the drag previews
420                    // that constraint's value; release commits + auto-solves.
421                    DragStart::Constraint => self.constraint_dragging = true,
422                    DragStart::Camera => {
423                        let btn = if response.dragged_by(egui::PointerButton::Secondary) {
424                            BUTTON_RIGHT
425                        } else if response.dragged_by(egui::PointerButton::Middle) {
426                            BUTTON_MIDDLE
427                        } else {
428                            BUTTON_LEFT
429                        };
430                        // The camera anchors at the CURRENT pointer position, not
431                        // the press origin: `pointer_move` deltas run from whatever
432                        // `pointer_down` recorded, so anchoring at the (older) press
433                        // origin would make the first orbit frame jump by the whole
434                        // click-radius drift.
435                        state.pointer_down(lx, ly, btn);
436                        self.dragging = true;
437                    }
438                }
439            }
440        }
441        if response.dragged() {
442            if let Some(pos) = response.interact_pointer_pos() {
443                let (lx, ly) = local(pos);
444                if self.gizmo_dragging {
445                    // Drive the transform gizmo: updates the feature's transform +
446                    // re-runs the history (the model moves live).
447                    state.transform_drag_to(lx, ly);
448                } else if self.component_gizmo_dragging {
449                    // Drive the component Move gizmo: the GIZMO follows the pointer
450                    // (free move); the pose commits on release.
451                    state.component_drag_to(lx, ly);
452                } else if let Some(field) = self.dim_dragging.clone() {
453                    // Drive the dimension arrow (Fix 4): edit the param + re-run the
454                    // history live, so the geometry AND its arrow follow the pointer.
455                    let feature = state.dimension_armed_feature();
456                    if !feature.is_empty() {
457                        state.feature_dimension_drag(&feature, &field, lx, ly);
458                    }
459                } else if self.constraint_dragging {
460                    // Drive the constraint handle: the value PREVIEWS live (arrow +
461                    // label track the pointer); nothing commits until release.
462                    state.constraint_drag_to(lx, ly);
463                } else if self.dragging {
464                    state.pointer_move(lx, ly);
465                }
466            }
467        }
468        if response.drag_stopped() {
469            if self.gizmo_dragging {
470                state.transform_release();
471                self.gizmo_dragging = false;
472            }
473            if self.component_gizmo_dragging {
474                // COMMIT: compose the drag delta onto the ACOMP transform, one
475                // param write + rerun (the constraint tail re-solves — by design).
476                state.component_release();
477                self.component_gizmo_dragging = false;
478            }
479            if self.dim_dragging.take().is_some() {
480                // The overlay is already glued to the final value from the last drag
481                // frame; just drop the flag so hover/select resume.
482            }
483            if self.constraint_dragging {
484                // COMMIT the previewed constraint value: updates the constraint
485                // (auto-solves), re-tessellates the re-posed components, folds the
486                // solved poses into the history document, refreshes the overlay.
487                state.constraint_drag_release();
488                self.constraint_dragging = false;
489            }
490            if self.dragging {
491                state.pointer_up();
492                self.dragging = false;
493            }
494        }
495
496        // A plain click (press+release, no drag) over the viewport: in
497        // reference-selection mode it type-constrained-picks a reference under the
498        // cursor (engine drives the highlight); otherwise the modeling selection
499        // UX — a PLAIN click on ONE filter-admitted item selects it (replace, or
500        // toggle in the Click-toggles multi-select mode), a plain click on SEVERAL
501        // overlapping items opens the PICK LIST popup at the cursor (front/back
502        // faces, obstructed geometry), Ctrl/Cmd+click ADDS/TOGGLES the top pick
503        // directly, and Alt+click opens the pick list explicitly. ViewCube clicks
504        // are snapped in this branch (checked right after the popup), never a
505        // selection/pick.
506        if response.clicked() {
507            if let Some(pos) = response.interact_pointer_pos() {
508                let on_popup = self.candidate_popup.is_some()
509                    && self
510                        .candidate_popup_rect
511                        .map(|r| r.contains(pos))
512                        .unwrap_or(false);
513                let (lx, ly) = local(pos);
514                let mods = ui.ctx().input(|i| i.modifiers);
515                if on_popup {
516                    // A click inside the OPEN popup belongs to the popup — its
517                    // entry buttons handle it in `show_candidate_popup`. Don't let
518                    // the viewport close it or pick the geometry behind it.
519                } else if self.candidate_popup.is_some() {
520                    // A click OUTSIDE the open pick list DISMISSES it and is
521                    // SWALLOWED — it must not fall through to the selection
522                    // branches below, or dismissing the list would re-pick (or, on
523                    // empty space, CLEAR a multi-selection built through the
524                    // list). The NEXT click acts normally.
525                    self.candidate_popup = None;
526                    self.candidate_popup_rect = None;
527                } else if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
528                    // A click over the ViewCube corner snaps the camera to that
529                    // region's standard view. Checked FIRST (after the popup) so a
530                    // corner click is always a view snap, never a scene / ref-select
531                    // / gizmo pick. A PLAIN click never fires `drag_started` (egui
532                    // postpones the click/drag decision for a click_and_drag widget
533                    // and only fires drag_started once the pointer is decidedly
534                    // dragging), so the snap MUST run here — the drag-start path only
535                    // catches a press that egui classifies as a drag.
536                    state.viewcube_click(cx, cy);
537                } else if state.ref_select_active() {
538                    state.ref_select_click(lx, ly);
539                } else if state.transform_center_pick(lx, ly) {
540                    // The orange CENTER sphere in transform mode toggles to the
541                    // DIMENSION arrows (the old app's center-handle ◎ toggle).
542                    // Must precede the generic handle-swallow below so a center
543                    // click flips modes instead of being swallowed; a center
544                    // DRAG still free-moves (handled on drag-start, not here).
545                    state.toggle_to_dimension();
546                } else if state.dimension_origin_pick(lx, ly) {
547                    // The orange ORIGIN sphere in dimension mode toggles back to
548                    // the TRANSFORM controls (the reverse ◎ toggle).
549                    state.toggle_to_transform();
550                } else if state.dimension_arrow_pick(lx, ly).is_some() {
551                    // A bare click on a dimension ARROWHEAD is a no-op — only a DRAG
552                    // on it edits the value (Fix 4). Swallow it so the solid behind
553                    // the arrow isn't selected.
554                } else if state.constraint_arrow_pick(lx, ly).is_some() {
555                    // Same rule for an assembly-constraint handle: only a DRAG edits
556                    // the value; swallow the bare click so the geometry behind the
557                    // leader/arc isn't selected.
558                } else if (state.transform_armed() || state.component_move_armed())
559                    && state.transform_pick(lx, ly) != 0
560                {
561                    // A bare click on an armed (non-center) gizmo handle — feature
562                    // OR component Move gizmo — swallow it so the solid behind the
563                    // gizmo is not selected.
564                } else if mods.alt {
565                    // Alt+click → open the pick list explicitly, even for a
566                    // single candidate (the power-user inspection trigger).
567                    let cands = state.candidates_filtered_at(lx, ly);
568                    self.candidate_popup = (!cands.is_empty())
569                        .then(|| CandidatePopup { anchor: pos, candidates: cands });
570                    self.candidate_popup_fresh = self.candidate_popup.is_some();
571                } else if mods.command || mods.ctrl {
572                    // Ctrl/Cmd+click ADDS/TOGGLES the top pick directly, no list
573                    // (the classic additive shortcut, both multi-select modes).
574                    state.select_toggle_at(lx, ly);
575                } else if state.spline_anchor_pick_at(lx, ly).is_some() {
576                    // An anchor dot of the spline whose editor is open: that
577                    // anchor is selected (the gizmo arms on a free one) and the
578                    // click stops here — the sheet under it is not selected.
579                } else {
580                    // A plain click: ONE admitted item under the cursor selects it
581                    // directly — REPLACE in Ctrl+Click mode, TOGGLE in the
582                    // Click-toggles mode (a second click on the same item
583                    // unselects it). SEVERAL overlapping items open the PICK LIST
584                    // popup so front/back faces, obstructed geometry AND the
585                    // construction planes over them are all reachable. A miss
586                    // clears.
587                    //
588                    // Construction planes are ORDINARY candidates in this list
589                    // (`PickKind::Plane`, ranked right after faces), so there is
590                    // no geometry-miss `datum_pick` fallback any more: a plane
591                    // under other geometry used to be unreachable because the
592                    // fallback only ran when the list came back EMPTY, and an
593                    // unchecked Plane filter could not have excluded it.
594                    let cands = state.candidates_filtered_at(lx, ly);
595                    let toggles =
596                        state.settings.multi_select == MultiSelectMode::ClickToggles;
597                    match cands.len() {
598                        0 => {
599                            state.clear_selection();
600                        }
601                        1 => {
602                            if toggles {
603                                state.toggle_candidate(&cands[0]);
604                            } else {
605                                state.select_candidate(&cands[0]);
606                            }
607                        }
608                        _ => {
609                            self.candidate_popup =
610                                Some(CandidatePopup { anchor: pos, candidates: cands });
611                            self.candidate_popup_fresh = true;
612                        }
613                    }
614                }
615            }
616        }
617
618        // Hover: the ViewCube corner, an armed transform-gizmo handle, OR the
619        // top filter-admitted scene entity under the pointer (the modeling
620        // hover-highlight). Suppressed while dragging / over a gizmo handle / mid
621        // dimension-arrow or constraint-handle drag (feature, component-move, or
622        // constraint gizmo), while the candidate popup owns the highlight, and
623        // for the ONE frame a constraint LABEL applied its element highlight
624        // (the label pass draws after us and re-arms the flag while hovered —
625        // mirrors the sketch entity-list hover yield).
626        let label_hover = state.take_constraint_label_hover() | state.take_pmi_label_hover();
627        // The SCENE-TREE row hover (row → viewport highlight). Taken every frame
628        // so the one-frame flag never survives a skipped one, but honored only in
629        // the pointer-left-the-viewport branch below: the pointer is over the
630        // sidebar while a row is hovered, so that branch — and only that branch —
631        // would clear the row's highlight. Folding it into `label_hover` would also
632        // skip `viewcube_clear_hover` and leave the cube lit when the pointer moves
633        // from it to the tree.
634        let tree_hover = state.take_scene_tree_hover();
635        // The DIALOG row hover (a form's reference / `Outputs` line, the picker
636        // card's picked names) — the same deal as the tree's, from a different
637        // slot so the two panes cannot end each other's highlight.
638        let dialog_hover = state.take_dialog_hover();
639        if !self.dragging
640            && !self.gizmo_dragging
641            && !self.component_gizmo_dragging
642            && self.dim_dragging.is_none()
643            && !self.constraint_dragging
644            && self.pmi_label_dragging.is_none()
645            && !label_hover
646        {
647            match response.hover_pos() {
648                Some(pos) => {
649                    let (lx, ly) = local(pos);
650                    match self.viewcube_local(state, lx, ly) {
651                        Some((cx, cy)) => {
652                            state.viewcube_hover(cx, cy);
653                            state.clear_hover(); // over the cube, not the scene
654                        }
655                        None => {
656                            state.viewcube_clear_hover();
657                            // Over an armed gizmo handle → highlight the handle, not
658                            // the solid behind it (the previous app's "skip scene hover over
659                            // the gizmo" rule). Else hover-highlight the top pick.
660                            let over_handle = (state.transform_armed()
661                                || state.component_move_armed())
662                                && state.transform_hover(lx, ly) != 0;
663                            if over_handle {
664                                state.clear_hover();
665                            } else if self.candidate_popup.is_none() {
666                                state.hover_at(lx, ly);
667                            }
668                        }
669                    }
670                }
671                None => {
672                    state.viewcube_clear_hover();
673                    // Pointer left the viewport (or moved onto the popup, which
674                    // drives its own entry-hover) → drop the scene hover. UNLESS a
675                    // Scene-tree row or a DIALOG row is hovering an entity: that
676                    // highlight is the pointer's, drawn from the sidebar (mirrors
677                    // the sketch entity-list hover yield).
678                    if self.candidate_popup.is_none() && !tree_hover && !dialog_hover {
679                        state.clear_hover();
680                    }
681                }
682            }
683        }
684
685        // Wheel zoom toward the cursor when hovering the viewport.
686        if response.hovered() {
687            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
688            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
689            // scroll). See [`raw_wheel_delta_y`].
690            let scroll_y = raw_wheel_delta_y(ui.ctx());
691            if scroll_y != 0.0 {
692                let cursor = response.hover_pos().map(|p| {
693                    let (lx, ly) = local(p);
694                    [lx, ly]
695                });
696                // egui scroll: +y = wheel up = zoom in; the controls treat
697                // negative delta_y as zoom-in (see desktop.rs), so negate.
698                state.wheel(-(scroll_y as f64), cursor);
699            }
700        }
701    }
702
703    /// Draw the PICK LIST popup — a semi-transparent, scrollable list of the
704    /// ranked, filter-admitted candidates at the cursor, in the category order
705    /// points > edges > faces > solids > components (nearest first within each).
706    /// Opens on a plain click with MULTIPLE items under the pointer (so front +
707    /// back faces and obstructed geometry are reachable) and on Alt+click
708    /// explicitly. HOVERING an entry pre-highlights that entity in the scene
709    /// (and hover-out clears it); a row draws SELECTED while its entity is in
710    /// the selection, so toggling reads back visually. CLICKING an entry picks
711    /// it and ALWAYS closes the list: in the Click-toggles multi-select mode
712    /// (and on Ctrl/Cmd+click in either mode) the entry TOGGLES into the
713    /// selection — front AND back faces join one selection across two
714    /// click→entry rounds — while in Ctrl+Click mode a plain entry click
715    /// REPLACES the selection with exactly it. The header's "Clear Selection"
716    /// clears + closes. Also closes on Escape (routed via the app shell so it
717    /// never also clears the selection) and on a click outside (swallowed by
718    /// the viewport click router). Rebuilds `candidate_hits` (per-entry screen
719    /// rects) each frame for the headed verifier. Engine mutations are applied
720    /// AFTER the draw closure (the codebase's "no engine mutation inside the
721    /// draw" rule).
722    fn show_candidate_popup(&mut self, ctx: &egui::Context, state: &mut EngineState) {
723        self.candidate_hits.clear();
724        // A modal mode (reference-selection / sketch edit) supersedes the pick
725        // list: drop a popup left open by modeling clicks so it neither draws
726        // over the modal nor swallows the modal's first viewport click.
727        if state.ref_select_active() || state.sketch_mode() {
728            self.candidate_popup = None;
729        }
730        let Some(popup) = self.candidate_popup.as_ref() else {
731            self.candidate_popup_rect = None;
732            return;
733        };
734        let candidates = popup.candidates.clone();
735        let anchor = popup.anchor;
736        let mods = ctx.input(|i| i.modifiers);
737        // Row selected-state, read BEFORE the draw (no engine borrow inside it).
738        let selected_rows: Vec<bool> = candidates
739            .iter()
740            .map(|c| state.candidate_is_selected(c))
741            .collect();
742
743        let mut hits: Vec<egui::Rect> = Vec::with_capacity(candidates.len());
744        let mut hovered_index: Option<usize> = None;
745        let mut clicked_index: Option<usize> = None;
746        let mut clear_clicked = false;
747
748        let area = egui::Area::new(egui::Id::new("brep-candidate-popup"))
749            .order(egui::Order::Foreground)
750            .fixed_pos(anchor)
751            // Keep the whole list on screen when the click lands near an edge.
752            .constrain(true)
753            .show(ctx, |ui| {
754                // The standard popup frame at reduced opacity: the model stays
755                // visible through the list while scrolling it.
756                let mut frame = egui::Frame::popup(ui.style());
757                frame.fill = frame.fill.gamma_multiply(0.85);
758                frame.show(ui, |ui| {
759                    ui.set_max_width(280.0);
760                    // Header row: title + a "Clear Selection" action.
761                    ui.horizontal(|ui| {
762                        ui.label(egui::RichText::new("Select an object").weak().small());
763                        ui.with_layout(
764                            egui::Layout::right_to_left(egui::Align::Center),
765                            |ui| {
766                                if ui.small_button("Clear Selection").clicked() {
767                                    clear_clicked = true;
768                                }
769                            },
770                        );
771                    });
772                    // A long candidate list scrolls instead of growing past the
773                    // viewport; hover keeps re-resolving as rows slide under the
774                    // pointer, so scrolling through the list highlights each
775                    // entity in turn.
776                    egui::ScrollArea::vertical()
777                        .max_height(240.0)
778                        .show(ui, |ui| {
779                            ui.set_min_width(220.0);
780                            for (i, candidate) in candidates.iter().enumerate() {
781                                let label = format!(
782                                    "{}  {}",
783                                    state.candidate_kind_label(candidate),
784                                    candidate_label(candidate)
785                                );
786                                // TRUNCATE inside the popup's cap. A candidate
787                                // is labelled with the entity's own name, which
788                                // the modelling history makes as long as it
789                                // likes; left to extend, it grows this overlay
790                                // card past the 280 pt it just asked for and out
791                                // over the viewport edge. A `Button` (which is
792                                // what a selectable label is) has no elided-text
793                                // tooltip of its own, so the full name is spelled
794                                // out on hover — the rows the user is choosing
795                                // between often differ only in their tails.
796                                let resp = ui
797                                    .add(
798                                        egui::Button::selectable(
799                                            selected_rows[i],
800                                            label.as_str(),
801                                        )
802                                        .truncate(),
803                                    )
804                                    .on_hover_text(&label);
805                                hits.push(resp.rect);
806                                if resp.hovered() {
807                                    hovered_index = Some(i);
808                                }
809                                if resp.clicked() {
810                                    clicked_index = Some(i);
811                                }
812                            }
813                        });
814                });
815            });
816
817        self.candidate_hits = hits;
818        self.candidate_popup_rect = Some(area.response.rect);
819
820        // Apply engine mutations outside the draw closure.
821        if let Some(i) = hovered_index {
822            state.hover_candidate(&candidates[i]);
823        } else {
824            // No entry under the pointer → drop the pre-highlight, so the
825            // last-hovered row's entity doesn't stay lit while the pointer
826            // roams elsewhere.
827            state.clear_hover();
828        }
829        let mut close = false;
830        if clear_clicked {
831            state.clear_selection();
832            close = true;
833        }
834        if let Some(i) = clicked_index {
835            // Picking an entry ALWAYS dismisses the list — the pick is the
836            // list's job and it is done. In Click-toggles mode (or with
837            // Ctrl/Cmd held) the entry TOGGLES into the selection, so a
838            // front+back multi-selection is click → front, click → back (the
839            // list reopens on the next click); in Ctrl+Click mode a plain
840            // entry click REPLACES the selection with exactly that entity.
841            let toggles = state.settings.multi_select == MultiSelectMode::ClickToggles;
842            if toggles || mods.command || mods.ctrl {
843                state.toggle_candidate(&candidates[i]);
844            } else {
845                state.select_candidate(&candidates[i]);
846            }
847            close = true;
848        }
849        // Fallback only: the app shell's global Escape router consumes the key
850        // first and closes via `close_candidate_popup` (so Escape never ALSO
851        // clears the selection); this fires only when that router is skipped
852        // (e.g. a text edit had focus).
853        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
854            close = true;
855        }
856        // Ignore the OPENING Alt+click on the frame it opened; honor click-outside
857        // from the next frame on.
858        if self.candidate_popup_fresh {
859            self.candidate_popup_fresh = false;
860        } else if area.response.clicked_elsewhere() {
861            close = true;
862        }
863        if close {
864            state.clear_hover();
865            self.candidate_popup = None;
866            self.candidate_popup_rect = None;
867        }
868    }
869
870    /// The OPEN popup's candidate list as JSON `[{index,kind,name,solid,depth}]`
871    /// (empty when closed) — the headed verifier asserts the sorted list.
872    fn candidates_json(&self, state: &EngineState) -> String {
873        match self.candidate_popup.as_ref() {
874            Some(popup) => {
875                let out: Vec<serde_json::Value> = popup
876                    .candidates
877                    .iter()
878                    .enumerate()
879                    .map(|(i, c)| {
880                        serde_json::json!({
881                            "index": i,
882                            "kind": state.candidate_kind_label(c),
883                            "name": c.name,
884                            "solid": c.solid,
885                            "depth": c.depth,
886                        })
887                    })
888                    .collect();
889                serde_json::Value::Array(out).to_string()
890            }
891            None => "[]".to_string(),
892        }
893    }
894
895    /// The OPEN popup's per-entry screen rects as JSON `[{index,x,y,w,h}]` (egui
896    /// points) so the verifier can click a specific candidate entry.
897    fn candidate_hits_json(&self) -> String {
898        let out: Vec<serde_json::Value> = self
899            .candidate_hits
900            .iter()
901            .enumerate()
902            .map(|(i, r)| {
903                serde_json::json!({
904                    "index": i,
905                    "x": r.min.x,
906                    "y": r.min.y,
907                    "w": r.width(),
908                    "h": r.height(),
909                })
910            })
911            .collect();
912        serde_json::Value::Array(out).to_string()
913    }
914}
915
916/// Which branch CLAIMED a viewport drag-start — the one dispatch that decides
917/// whether a press drives a gizmo handle or the camera. Returned by
918/// [`route_drag_start`], which performs the claim; the caller only records which
919/// drag is now live.
920#[derive(Debug, Clone, PartialEq, Eq)]
921pub(super) enum DragStart {
922    /// The ViewCube corner: the router already snapped the camera.
923    ViewCube,
924    /// A transform-gizmo handle (arrow / ring ball / center sphere).
925    Gizmo,
926    /// A handle of the armed assembly-component Move gizmo.
927    Component,
928    /// A ◎ dimension arrowhead; carries the param field key it edits.
929    Dimension(String),
930    /// An assembly-constraint distance/angle handle.
931    Constraint,
932    /// Nothing claimed it → the camera orbit/pan fallthrough.
933    Camera,
934}
935
936/// The VIEWPORT-LOCAL point a drag-start hit test must be taken at. The ONE
937/// place that choice is made — the dispatch and its tests both call THIS, so a
938/// test can never pass against a call site that resolved the point differently.
939///
940/// NOT `response.interact_pointer_pos()`, which is the pointer's position on the
941/// frame egui DECIDED the press was a drag — by then it has travelled at least
942/// `max_click_dist` (6 pt) from the press, and a slow frame (the 3D viewport
943/// waking from egui's on-demand repaint) coalesces the whole flick into one
944/// step, so the reported point can be tens of px away. A gizmo handle carries
945/// 7-9 px of grab radius (axis arrow / rotation ball / centre sphere), a
946/// dimension leader 18, so hit-testing there misses the handle the user
947/// actually pressed and the press falls through to the camera orbit.
948///
949/// `press_origin` is exactly where the button went down, so the test asks "what
950/// did you press on", not "where has the cursor got to". It is `None` only when
951/// no button is down — a press and release inside ONE frame — where the reported
952/// interact position is all there is.
953fn drag_start_local(response: &egui::Response, rect: egui::Rect) -> Option<(f64, f64)> {
954    let p = response
955        .ctx
956        .input(|i| i.pointer.press_origin())
957        .or_else(|| response.interact_pointer_pos())?;
958    Some(((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64))
959}
960
961/// Dispatch ONE viewport drag-start at viewport-local px `(x, y)`: the first
962/// branch whose handle is under the press CLAIMS it (and arms its drag inside
963/// the engine), else the camera takes it.
964///
965/// Precedence is load-bearing and unchanged: ViewCube corner → transform gizmo →
966/// component Move gizmo → ◎ dimension arrowhead → assembly-constraint handle →
967/// camera. Split out of `handle_viewport_input` so the claim can be driven from
968/// a test without a GPU-backed [`Viewport`].
969pub(super) fn route_drag_start(state: &mut EngineState, x: f64, y: f64) -> DragStart {
970    if let Some((cx, cy)) = viewcube_local(state, x, y) {
971        state.viewcube_click(cx, cy);
972        DragStart::ViewCube
973    } else if state.transform_press(x, y) {
974        DragStart::Gizmo
975    } else if state.component_press(x, y) {
976        DragStart::Component
977    } else if let Some(field) = state.dimension_arrow_pick(x, y) {
978        DragStart::Dimension(field)
979    } else if state.constraint_drag_begin(x, y) {
980        DragStart::Constraint
981    } else {
982        DragStart::Camera
983    }
984}
985
986/// The ViewCube corner rect hit test (viewport-local logical px) — `Some` with
987/// the CUBE-local coords when `(x, y)` is inside the drawn cube, else `None`.
988fn viewcube_local(state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
989    let v: serde_json::Value = serde_json::from_str(&state.viewcube_rect_json()).ok()?;
990    let (rx, ry, rw, rh) = (
991        v["x"].as_f64()?,
992        v["y"].as_f64()?,
993        v["w"].as_f64()?,
994        v["h"].as_f64()?,
995    );
996    if rw > 0.0 && rh > 0.0 && x >= rx && x <= rx + rw && y >= ry && y <= ry + rh {
997        Some((x - rx, y - ry))
998    } else {
999        None
1000    }
1001}
1002
1003/// A human label for a pick candidate: its kernel name, or a positional tag for
1004/// unnamed vertices.
1005fn candidate_label(candidate: &PickCandidate) -> String {
1006    if candidate.name.trim().is_empty() {
1007        let p = candidate.position;
1008        format!("({:.2}, {:.2}, {:.2})", p[0], p[1], p[2])
1009    } else {
1010        candidate.name.clone()
1011    }
1012}
1013
1014// BREP private tests: b33029915c4119e4