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    #[cfg(target_arch = "wasm32")]
41    pub fn viewport_rect_json(&self) -> String {
42        match self.last_rect {
43            Some(r) => {
44                serde_json::json!({ "x": r.min.x, "y": r.min.y, "w": r.width(), "h": r.height() })
45                    .to_string()
46            }
47            None => "null".to_string(),
48        }
49    }
50
51    /// The clean entry the shell calls: fill the central panel with the 3D
52    /// viewport — size tracking, input routing, on-demand render, and the blit
53    /// composite. Borrows the engine brain to draw + drive.
54    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
55        let ppp = ui.ctx().pixels_per_point();
56        // Empty frame → NO inner margin/padding: the 3D view fills the central
57        // area edge-to-edge (the viewport paints the whole rect anyway).
58        egui::containers::panel::CentralPanel::default()
59            .frame(egui::Frame::NONE)
60            .show(ui, |ui| {
61                let rect = ui.available_rect_before_wrap();
62            self.last_rect = Some(rect);
63            let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
64
65            // Track viewport size in the engine (logical px) + offscreen (physical).
66            let phys_w = (rect.width() * ppp).round().max(1.0) as u32;
67            let phys_h = (rect.height() * ppp).round().max(1.0) as u32;
68            self.ensure_offscreen(phys_w, phys_h);
69            state.resize(rect.width() as f64, rect.height() as f64);
70
71            // Feed input BEFORE rendering so a change is reflected this frame.
72            self.handle_viewport_input(ui, rect, &response, state);
73
74            // Per-frame overlay upkeep, AFTER the input + resize above so a zoom
75            // is reflected in the SAME frame it happened. Re-bakes the
76            // screen-constant sizing of every draggable gizmo that rides the
77            // pre-expanded `set_overlay` channel — assembly-constraint handles
78            // (§8.4), the ◎ feature-dimension gizmo, the live sketch overlay —
79            // on a material world-per-pixel change, and hides/restores the
80            // constraint graphics with their toggle + sketch mode.
81            state.ensure_overlays_current();
82
83            if state.dirty {
84                self.render_viewport(phys_w, phys_h, ppp, state);
85                // Keep animating while a drag is live / more input pending.
86                ui.ctx().request_repaint();
87            }
88
89            // Composite the offscreen 3D texture into egui's frame via the
90            // wgpu paint callback.
91            ui.painter().add(egui_wgpu::Callback::new_paint_callback(
92                rect,
93                ViewportCallback,
94            ));
95        });
96
97        // The "candidates under the cursor" disambiguation popup (Alt+click)
98        // floats over the viewport at ctx level, like the file dialog / palette.
99        let ctx = ui.ctx().clone();
100        self.show_candidate_popup(&ctx, state);
101
102        // Editable dimension labels (S5): value text drawn at each dimension's
103        // screen-projected anchor, click-to-edit + drag-to-reposition. Drawn at ctx
104        // level (foreground of the viewport) so it floats over the 3D like the popup.
105        if let Some(rect) = self.last_rect {
106            self.draw_dimension_labels(&ctx, rect, state);
107            // Feature-dimension labels (FD-1): the ◎ dimension-gizmo mode draws the
108            // primitive's param dims here, click-to-edit + drag-to-resize.
109            self.draw_feature_dimension_labels(&ctx, rect, state);
110            // Transform-gizmo axis labels (XC/YC/ZC): the colored cone-tip labels.
111            self.draw_transform_axis_labels(&ctx, rect, state);
112            // Assembly-constraint labels (§8.4): status-colored chips at each
113            // constraint's anchor — hover highlights the referenced geometry,
114            // click expands the row in the Assembly Constraints panel.
115            self.draw_constraint_labels(&ctx, rect, state);
116            // DEBUG: 1px RED outline of the EXACT gizmo-arrow hit regions (axis
117            // capsules + grab circles of the transform widget — feature transform
118            // mode AND the component Move gizmo — or the dimension arrowheads).
119            // Drawn LAST so the outlines overlay everything.
120            self.draw_gizmo_hit_areas(&ctx, rect, state);
121        }
122        if self.candidate_popup.is_some() || state.dirty {
123            // Keep animating while the popup is open (its entry hover / a fresh
124            // highlight is applied AFTER this frame's render).
125            ctx.request_repaint();
126        }
127
128        // Verification hooks (wasm only): the selection-UX globals the headed
129        // verifier reads. Published from HERE because viewport.rs owns hover + the
130        // candidate popup (keeps app.rs untouched). Purely additive.
131        #[cfg(target_arch = "wasm32")]
132        {
133            publish_to_js("__brepHover", &state.hovered_json());
134            publish_to_js("__brepCandidates", &self.candidates_json(state));
135            publish_to_js("__brepCandidateHit", &self.candidate_hits_json());
136            // Re-publish the selection with the POST-input value: the app shell
137            // publishes `__brepSelection` before this viewport draws (so its copy
138            // lags a viewport click by a frame); overwrite it here with the value
139            // that reflects this frame's click so the verifier reads it live.
140            publish_to_js("__brepSelection", &state.selection_json());
141            // The ◎ dimension-gizmo state (mode + annotations) for the verifier.
142            publish_to_js("__brepFeatureDim", &state.feature_dimension_state_json());
143            // The assembly-constraint overlay labels (id/text/status/color/world/
144            // draggable) so the verifier can locate + drag a constraint handle.
145            publish_to_js("__brepConstraints", &state.constraint_labels_json());
146            // The ViewCube corner rect (viewport-local `{x,y,w,h}`) so the verifier
147            // can click a cube face/edge/corner by hit-rect (offset by `__brepView`).
148            publish_to_js("__brepViewCube", &state.viewcube_rect_json());
149        }
150    }
151
152    /// The ViewCube corner rect (logical px, viewport-local), if enabled.
153    fn viewcube_local(&self, state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
154        viewcube_local(state, x, y)
155    }
156
157    /// Route pointer/wheel over the viewport into the engine's SKETCH interaction
158    /// (S2), while [`EngineState::sketch_mode`]. Point drags move sketch points;
159    /// empty-space drags still orbit/pan the camera; clicks select (Ctrl/Cmd adds);
160    /// hover lights the entity under the cursor. The ViewCube corner + wheel zoom
161    /// keep working. NONE of the modeling select/candidate/transform/ref-select
162    /// branches run here.
163    fn handle_sketch_input(
164        &mut self,
165        ui: &egui::Ui,
166        rect: egui::Rect,
167        response: &egui::Response,
168        state: &mut EngineState,
169    ) {
170        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);
171
172        // A DRAW tool (point/line/rect/circle/arc) is active vs S2 selection mode.
173        // In draw mode clicks PLACE geometry and points are NOT grabbed for dragging
174        // (so an empty drag still orbits the camera); hover still runs for snap + the
175        // rubber-band preview.
176        let draw_mode = state.sketch_active_tool().is_some();
177
178        // Right-click aborts the in-progress draw geometry. (Escape → drop back to
179        // the Select/drag tool is handled globally in `BrepApp::handle_shortcuts`,
180        // the only reliable capture point: it `consume_key`s Escape before the
181        // viewport runs, and it fires for EVERY armed tool, not just draw mode.)
182        if draw_mode && response.secondary_clicked() {
183            state.sketch_tool_cancel();
184        }
185
186        // Delete / Backspace removes the current sketch selection (S3b) — geometries +
187        // points + constraints + orphan cleanup, driven by the engine. Gated on sketch
188        // mode AND on no egui TEXT edit being focused, so a Backspace typed into an open
189        // dimension value editor edits the number rather than deleting the selection
190        // (same guard `handle_shortcuts` uses for the global keys).
191        let delete = !ui.ctx().text_edit_focused()
192            && ui
193                .ctx()
194                .input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace));
195        if delete {
196            state.sketch_delete_selection();
197        }
198
199        if response.drag_started() {
200            if let Some(pos) = response.interact_pointer_pos() {
201                let (lx, ly) = local(pos);
202                // ViewCube first (a view snap); then the freehand handdraw stroke
203                // capture (S6b-3 — a drag IS the stroke, never a camera orbit); then a
204                // sketch point grab (SELECT mode only — other draw modes never grab, so
205                // an empty drag orbits); else an empty-space camera orbit/pan so
206                // navigation still works.
207                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
208                    state.viewcube_click(cx, cy);
209                } else if state.sketch_active_tool() == Some("handdraw") {
210                    state.sketch_handdraw_begin(lx, ly);
211                    self.sketch_handdrawing = true;
212                } else if !draw_mode && state.sketch_drag_begin(lx, ly) {
213                    self.sketch_dragging = true;
214                } else {
215                    let btn = if response.dragged_by(egui::PointerButton::Secondary) {
216                        BUTTON_RIGHT
217                    } else if response.dragged_by(egui::PointerButton::Middle) {
218                        BUTTON_MIDDLE
219                    } else {
220                        BUTTON_LEFT
221                    };
222                    state.pointer_down(lx, ly, btn);
223                    self.dragging = true;
224                }
225            }
226        }
227        if response.dragged() {
228            if let Some(pos) = response.interact_pointer_pos() {
229                let (lx, ly) = local(pos);
230                if self.sketch_handdrawing {
231                    state.sketch_handdraw_move(lx, ly);
232                } else if self.sketch_dragging {
233                    state.sketch_drag_to(lx, ly);
234                } else if self.dragging {
235                    state.pointer_move(lx, ly);
236                }
237            }
238        }
239        if response.drag_stopped() {
240            if self.sketch_handdrawing {
241                state.sketch_handdraw_end();
242                self.sketch_handdrawing = false;
243            }
244            if self.sketch_dragging {
245                state.sketch_drag_end();
246                self.sketch_dragging = false;
247            }
248            if self.dragging {
249                state.pointer_up();
250                self.dragging = false;
251            }
252        }
253
254        // A plain click either PLACES draw-tool geometry (draw mode) or selects the
255        // entity under the cursor (SELECT mode: Ctrl/Cmd adds/toggles, empty clears).
256        // A click over the ViewCube corner snaps the camera, never a sketch pick/place.
257        if response.clicked() {
258            if let Some(pos) = response.interact_pointer_pos() {
259                let (lx, ly) = local(pos);
260                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
261                    // A plain click on the ViewCube corner snaps the camera. A plain
262                    // click never fires drag_started (see the modeling path), so the
263                    // snap must run here, not only on the drag-start branch above.
264                    state.viewcube_click(cx, cy);
265                } else if state.sketch_active_tool() == Some("handdraw") {
266                    // handdraw (S6b-3) captures a DRAG as a stroke; a plain click (no
267                    // drag) is a deliberate no-op.
268                } else if state.sketch_active_tool() == Some("pickEdges") {
269                    // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor
270                    // (pixel coords), never a plane place/select.
271                    state.sketch_pick_edge_at(lx, ly);
272                } else if draw_mode {
273                    state.sketch_tool_click_at(lx, ly);
274                } else {
275                    let mods = ui.ctx().input(|i| i.modifiers);
276                    state.sketch_click_at(lx, ly, mods.command || mods.ctrl);
277                }
278            }
279        }
280
281        // Hover: the entity under the pointer (skip while dragging / over the cube).
282        // Also FREEZE the entity hover while the primary button is held in SELECT
283        // mode: between the press and egui's drag-start (~6px of movement later) the
284        // pointer keeps moving, and re-hovering there would slide the highlight off
285        // the point the user pressed — so the grab (which takes the hovered entity)
286        // would miss. Draw mode keeps updating (its rubber-band preview rides the
287        // live hover).
288        let primary_held = ui.input(|i| i.pointer.primary_down());
289        if !self.sketch_dragging
290            && !self.dragging
291            && !self.sketch_handdrawing
292            && !(primary_held && !draw_mode)
293        {
294            match response.hover_pos() {
295                Some(pos) => {
296                    let (lx, ly) = local(pos);
297                    match self.viewcube_local(state, lx, ly) {
298                        Some((cx, cy)) => {
299                            state.viewcube_hover(cx, cy);
300                            state.sketch_clear_hover(); // over the cube, not the sketch
301                        }
302                        None => {
303                            state.viewcube_clear_hover();
304                            state.sketch_hover_at(lx, ly);
305                            // pickEdges (S6b-2) targets a 3D scene edge, so also
306                            // hover-highlight the edge under the cursor (modeling
307                            // emphasis) as a link affordance.
308                            if state.sketch_active_tool() == Some("pickEdges") {
309                                state.hover_at(lx, ly);
310                            }
311                        }
312                    }
313                }
314                None => {
315                    state.viewcube_clear_hover();
316                    // Pointer is off the viewport entirely. Don't clobber a hover the
317                    // entity-LIST panel set THIS frame (it drew before us) — that is
318                    // the list→canvas highlight. When the panel didn't set one, clear
319                    // as usual so a stale highlight doesn't linger.
320                    if !state.take_sketch_list_hover() {
321                        state.sketch_clear_hover();
322                    }
323                }
324            }
325        }
326
327        // Wheel zoom toward the cursor (same as modeling).
328        if response.hovered() {
329            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
330            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
331            // scroll). See [`raw_wheel_delta_y`].
332            let scroll_y = raw_wheel_delta_y(ui.ctx());
333            if scroll_y != 0.0 {
334                let cursor = response.hover_pos().map(|p| {
335                    let (lx, ly) = local(p);
336                    [lx, ly]
337                });
338                state.wheel(-(scroll_y as f64), cursor);
339            }
340        }
341    }
342
343    /// Route pointer/wheel over the viewport into `EngineState` (mirrors
344    /// `desktop.rs`). `rect` is the viewport in egui points; coords fed to the
345    /// engine are viewport-local logical px, the space `state.camera` lives in.
346    fn handle_viewport_input(
347        &mut self,
348        ui: &egui::Ui,
349        rect: egui::Rect,
350        response: &egui::Response,
351        state: &mut EngineState,
352    ) {
353        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);
354
355        // Sketch mode (S2) owns the pointer: hover/select/point-drag in plane space,
356        // never the modeling select/candidate/transform/ref-select branches. Routed
357        // BEFORE the modeling path, which stays byte-for-byte for `!sketch_mode()`.
358        if state.sketch_mode() {
359            self.handle_sketch_input(ui, rect, response, state);
360            return;
361        }
362
363        if response.drag_started() {
364            if let Some(pos) = response.interact_pointer_pos() {
365                let (lx, ly) = local(pos);
366                // Every drag-start ends the hover: the pointer is now steering
367                // the camera / the cube / a gizmo handle / a dimension arrow,
368                // not hovering the model — a frozen highlight riding a camera
369                // snap, an orbit, or geometry that a gizmo/dim drag is reshaping
370                // live reads as a stale pick. Hover re-resolves at the new pose
371                // as soon as the interaction ends (the per-frame hover branch).
372                state.clear_hover();
373                // WHERE the press landed — the point every handle hit test below is
374                // taken at ([`drag_start_local`]), which is NOT `pos`: egui only calls
375                // a press a drag once the pointer has left the click radius, so by
376                // this frame `pos` has already drifted off whatever the user
377                // pressed on.
378                let (gx, gy) = drag_start_local(response, rect).unwrap_or((lx, ly));
379                // A drag that STARTS over the ViewCube corner snaps/orbits via the
380                // cube (a plain click — which never fires drag_started — is snapped
381                // in the `clicked()` branch below); a press on an armed
382                // transform-gizmo HANDLE drives the gizmo; anywhere else starts a
383                // camera drag through the controls.
384                match route_drag_start(state, gx, gy) {
385                    // The cube already snapped the camera inside the router.
386                    DragStart::ViewCube => {}
387                    // Grabbed a gizmo handle → route the drag to the transform.
388                    DragStart::Gizmo => self.gizmo_dragging = true,
389                    // Grabbed the armed COMPONENT Move gizmo → free-move the gizmo
390                    // during the drag, commit the pose (+ re-solve) on release.
391                    DragStart::Component => self.component_gizmo_dragging = true,
392                    // Grabbed a ◎ dimension ARROWHEAD (Fix 4) → route the drag to
393                    // that param's live edit instead of orbiting the camera.
394                    DragStart::Dimension(field) => self.dim_dragging = Some(field),
395                    // Grabbed an ASSEMBLY-CONSTRAINT handle (a distance leader /
396                    // angle-arc handle, §8.4 grabbable arrows) → the drag previews
397                    // that constraint's value; release commits + auto-solves.
398                    DragStart::Constraint => self.constraint_dragging = true,
399                    DragStart::Camera => {
400                        let btn = if response.dragged_by(egui::PointerButton::Secondary) {
401                            BUTTON_RIGHT
402                        } else if response.dragged_by(egui::PointerButton::Middle) {
403                            BUTTON_MIDDLE
404                        } else {
405                            BUTTON_LEFT
406                        };
407                        // The camera anchors at the CURRENT pointer position, not
408                        // the press origin: `pointer_move` deltas run from whatever
409                        // `pointer_down` recorded, so anchoring at the (older) press
410                        // origin would make the first orbit frame jump by the whole
411                        // click-radius drift.
412                        state.pointer_down(lx, ly, btn);
413                        self.dragging = true;
414                    }
415                }
416            }
417        }
418        if response.dragged() {
419            if let Some(pos) = response.interact_pointer_pos() {
420                let (lx, ly) = local(pos);
421                if self.gizmo_dragging {
422                    // Drive the transform gizmo: updates the feature's transform +
423                    // re-runs the history (the model moves live).
424                    state.transform_drag_to(lx, ly);
425                } else if self.component_gizmo_dragging {
426                    // Drive the component Move gizmo: the GIZMO follows the pointer
427                    // (free move); the pose commits on release.
428                    state.component_drag_to(lx, ly);
429                } else if let Some(field) = self.dim_dragging.clone() {
430                    // Drive the dimension arrow (Fix 4): edit the param + re-run the
431                    // history live, so the geometry AND its arrow follow the pointer.
432                    let feature = state.dimension_armed_feature();
433                    if !feature.is_empty() {
434                        state.feature_dimension_drag(&feature, &field, lx, ly);
435                    }
436                } else if self.constraint_dragging {
437                    // Drive the constraint handle: the value PREVIEWS live (arrow +
438                    // label track the pointer); nothing commits until release.
439                    state.constraint_drag_to(lx, ly);
440                } else if self.dragging {
441                    state.pointer_move(lx, ly);
442                }
443            }
444        }
445        if response.drag_stopped() {
446            if self.gizmo_dragging {
447                state.transform_release();
448                self.gizmo_dragging = false;
449            }
450            if self.component_gizmo_dragging {
451                // COMMIT: compose the drag delta onto the ACOMP transform, one
452                // param write + rerun (the constraint tail re-solves — by design).
453                state.component_release();
454                self.component_gizmo_dragging = false;
455            }
456            if self.dim_dragging.take().is_some() {
457                // The overlay is already glued to the final value from the last drag
458                // frame; just drop the flag so hover/select resume.
459            }
460            if self.constraint_dragging {
461                // COMMIT the previewed constraint value: updates the constraint
462                // (auto-solves), re-tessellates the re-posed components, folds the
463                // solved poses into the history document, refreshes the overlay.
464                state.constraint_drag_release();
465                self.constraint_dragging = false;
466            }
467            if self.dragging {
468                state.pointer_up();
469                self.dragging = false;
470            }
471        }
472
473        // A plain click (press+release, no drag) over the viewport: in
474        // reference-selection mode it type-constrained-picks a reference under the
475        // cursor (engine drives the highlight); otherwise the modeling selection
476        // UX — a PLAIN click on ONE filter-admitted item selects it (replace, or
477        // toggle in the Click-toggles multi-select mode), a plain click on SEVERAL
478        // overlapping items opens the PICK LIST popup at the cursor (front/back
479        // faces, obstructed geometry), Ctrl/Cmd+click ADDS/TOGGLES the top pick
480        // directly, and Alt+click opens the pick list explicitly. ViewCube clicks
481        // are snapped in this branch (checked right after the popup), never a
482        // selection/pick.
483        if response.clicked() {
484            if let Some(pos) = response.interact_pointer_pos() {
485                let on_popup = self.candidate_popup.is_some()
486                    && self
487                        .candidate_popup_rect
488                        .map(|r| r.contains(pos))
489                        .unwrap_or(false);
490                let (lx, ly) = local(pos);
491                let mods = ui.ctx().input(|i| i.modifiers);
492                if on_popup {
493                    // A click inside the OPEN popup belongs to the popup — its
494                    // entry buttons handle it in `show_candidate_popup`. Don't let
495                    // the viewport close it or pick the geometry behind it.
496                } else if self.candidate_popup.is_some() {
497                    // A click OUTSIDE the open pick list DISMISSES it and is
498                    // SWALLOWED — it must not fall through to the selection
499                    // branches below, or dismissing the list would re-pick (or, on
500                    // empty space, CLEAR a multi-selection built through the
501                    // list). The NEXT click acts normally.
502                    self.candidate_popup = None;
503                    self.candidate_popup_rect = None;
504                } else if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
505                    // A click over the ViewCube corner snaps the camera to that
506                    // region's standard view. Checked FIRST (after the popup) so a
507                    // corner click is always a view snap, never a scene / ref-select
508                    // / gizmo pick. A PLAIN click never fires `drag_started` (egui
509                    // postpones the click/drag decision for a click_and_drag widget
510                    // and only fires drag_started once the pointer is decidedly
511                    // dragging), so the snap MUST run here — the drag-start path only
512                    // catches a press that egui classifies as a drag.
513                    state.viewcube_click(cx, cy);
514                } else if state.ref_select_active() {
515                    state.ref_select_click(lx, ly);
516                } else if state.transform_center_pick(lx, ly) {
517                    // The orange CENTER sphere in transform mode toggles to the
518                    // DIMENSION arrows (the old app's center-handle ◎ toggle).
519                    // Must precede the generic handle-swallow below so a center
520                    // click flips modes instead of being swallowed; a center
521                    // DRAG still free-moves (handled on drag-start, not here).
522                    state.toggle_to_dimension();
523                } else if state.dimension_origin_pick(lx, ly) {
524                    // The orange ORIGIN sphere in dimension mode toggles back to
525                    // the TRANSFORM controls (the reverse ◎ toggle).
526                    state.toggle_to_transform();
527                } else if state.dimension_arrow_pick(lx, ly).is_some() {
528                    // A bare click on a dimension ARROWHEAD is a no-op — only a DRAG
529                    // on it edits the value (Fix 4). Swallow it so the solid behind
530                    // the arrow isn't selected.
531                } else if state.constraint_arrow_pick(lx, ly).is_some() {
532                    // Same rule for an assembly-constraint handle: only a DRAG edits
533                    // the value; swallow the bare click so the geometry behind the
534                    // leader/arc isn't selected.
535                } else if (state.transform_armed() || state.component_move_armed())
536                    && state.transform_pick(lx, ly) != 0
537                {
538                    // A bare click on an armed (non-center) gizmo handle — feature
539                    // OR component Move gizmo — swallow it so the solid behind the
540                    // gizmo is not selected.
541                } else if mods.alt {
542                    // Alt+click → open the pick list explicitly, even for a
543                    // single candidate (the power-user inspection trigger).
544                    let cands = state.candidates_filtered_at(lx, ly);
545                    self.candidate_popup = (!cands.is_empty())
546                        .then(|| CandidatePopup { anchor: pos, candidates: cands });
547                    self.candidate_popup_fresh = self.candidate_popup.is_some();
548                } else if mods.command || mods.ctrl {
549                    // Ctrl/Cmd+click ADDS/TOGGLES the top pick directly, no list
550                    // (the classic additive shortcut, both multi-select modes).
551                    state.select_toggle_at(lx, ly);
552                } else {
553                    // A plain click: ONE admitted item under the cursor selects it
554                    // directly — REPLACE in Ctrl+Click mode, TOGGLE in the
555                    // Click-toggles mode (a second click on the same item
556                    // unselects it). SEVERAL overlapping items open the PICK LIST
557                    // popup so front/back faces, obstructed geometry AND the
558                    // construction planes over them are all reachable. A miss
559                    // clears.
560                    //
561                    // Construction planes are ORDINARY candidates in this list
562                    // (`PickKind::Plane`, ranked right after faces), so there is
563                    // no geometry-miss `datum_pick` fallback any more: a plane
564                    // under other geometry used to be unreachable because the
565                    // fallback only ran when the list came back EMPTY, and an
566                    // unchecked Plane filter could not have excluded it.
567                    let cands = state.candidates_filtered_at(lx, ly);
568                    let toggles =
569                        state.settings.multi_select == MultiSelectMode::ClickToggles;
570                    match cands.len() {
571                        0 => {
572                            state.clear_selection();
573                        }
574                        1 => {
575                            if toggles {
576                                state.toggle_candidate(&cands[0]);
577                            } else {
578                                state.select_candidate(&cands[0]);
579                            }
580                        }
581                        _ => {
582                            self.candidate_popup =
583                                Some(CandidatePopup { anchor: pos, candidates: cands });
584                            self.candidate_popup_fresh = true;
585                        }
586                    }
587                }
588            }
589        }
590
591        // Hover: the ViewCube corner, an armed transform-gizmo handle, OR the
592        // top filter-admitted scene entity under the pointer (the modeling
593        // hover-highlight). Suppressed while dragging / over a gizmo handle / mid
594        // dimension-arrow or constraint-handle drag (feature, component-move, or
595        // constraint gizmo), while the candidate popup owns the highlight, and
596        // for the ONE frame a constraint LABEL applied its element highlight
597        // (the label pass draws after us and re-arms the flag while hovered —
598        // mirrors the sketch entity-list hover yield).
599        let label_hover = state.take_constraint_label_hover();
600        if !self.dragging
601            && !self.gizmo_dragging
602            && !self.component_gizmo_dragging
603            && self.dim_dragging.is_none()
604            && !self.constraint_dragging
605            && !label_hover
606        {
607            match response.hover_pos() {
608                Some(pos) => {
609                    let (lx, ly) = local(pos);
610                    match self.viewcube_local(state, lx, ly) {
611                        Some((cx, cy)) => {
612                            state.viewcube_hover(cx, cy);
613                            state.clear_hover(); // over the cube, not the scene
614                        }
615                        None => {
616                            state.viewcube_clear_hover();
617                            // Over an armed gizmo handle → highlight the handle, not
618                            // the solid behind it (the previous app's "skip scene hover over
619                            // the gizmo" rule). Else hover-highlight the top pick.
620                            let over_handle = (state.transform_armed()
621                                || state.component_move_armed())
622                                && state.transform_hover(lx, ly) != 0;
623                            if over_handle {
624                                state.clear_hover();
625                            } else if self.candidate_popup.is_none() {
626                                state.hover_at(lx, ly);
627                            }
628                        }
629                    }
630                }
631                None => {
632                    state.viewcube_clear_hover();
633                    // Pointer left the viewport (or moved onto the popup, which
634                    // drives its own entry-hover) → drop the scene hover.
635                    if self.candidate_popup.is_none() {
636                        state.clear_hover();
637                    }
638                }
639            }
640        }
641
642        // Wheel zoom toward the cursor when hovering the viewport.
643        if response.hovered() {
644            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
645            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
646            // scroll). See [`raw_wheel_delta_y`].
647            let scroll_y = raw_wheel_delta_y(ui.ctx());
648            if scroll_y != 0.0 {
649                let cursor = response.hover_pos().map(|p| {
650                    let (lx, ly) = local(p);
651                    [lx, ly]
652                });
653                // egui scroll: +y = wheel up = zoom in; the controls treat
654                // negative delta_y as zoom-in (see desktop.rs), so negate.
655                state.wheel(-(scroll_y as f64), cursor);
656            }
657        }
658    }
659
660    /// Draw the PICK LIST popup — a semi-transparent, scrollable list of the
661    /// ranked, filter-admitted candidates at the cursor, in the category order
662    /// points > edges > faces > solids > components (nearest first within each).
663    /// Opens on a plain click with MULTIPLE items under the pointer (so front +
664    /// back faces and obstructed geometry are reachable) and on Alt+click
665    /// explicitly. HOVERING an entry pre-highlights that entity in the scene
666    /// (and hover-out clears it); a row draws SELECTED while its entity is in
667    /// the selection, so toggling reads back visually. CLICKING an entry picks
668    /// it and ALWAYS closes the list: in the Click-toggles multi-select mode
669    /// (and on Ctrl/Cmd+click in either mode) the entry TOGGLES into the
670    /// selection — front AND back faces join one selection across two
671    /// click→entry rounds — while in Ctrl+Click mode a plain entry click
672    /// REPLACES the selection with exactly it. The header's "Clear Selection"
673    /// clears + closes. Also closes on Escape (routed via the app shell so it
674    /// never also clears the selection) and on a click outside (swallowed by
675    /// the viewport click router). Rebuilds `candidate_hits` (per-entry screen
676    /// rects) each frame for the headed verifier. Engine mutations are applied
677    /// AFTER the draw closure (the codebase's "no engine mutation inside the
678    /// draw" rule).
679    fn show_candidate_popup(&mut self, ctx: &egui::Context, state: &mut EngineState) {
680        self.candidate_hits.clear();
681        // A modal mode (reference-selection / sketch edit) supersedes the pick
682        // list: drop a popup left open by modeling clicks so it neither draws
683        // over the modal nor swallows the modal's first viewport click.
684        if state.ref_select_active() || state.sketch_mode() {
685            self.candidate_popup = None;
686        }
687        let Some(popup) = self.candidate_popup.as_ref() else {
688            self.candidate_popup_rect = None;
689            return;
690        };
691        let candidates = popup.candidates.clone();
692        let anchor = popup.anchor;
693        let mods = ctx.input(|i| i.modifiers);
694        // Row selected-state, read BEFORE the draw (no engine borrow inside it).
695        let selected_rows: Vec<bool> = candidates
696            .iter()
697            .map(|c| state.candidate_is_selected(c))
698            .collect();
699
700        let mut hits: Vec<egui::Rect> = Vec::with_capacity(candidates.len());
701        let mut hovered_index: Option<usize> = None;
702        let mut clicked_index: Option<usize> = None;
703        let mut clear_clicked = false;
704
705        let area = egui::Area::new(egui::Id::new("brep-candidate-popup"))
706            .order(egui::Order::Foreground)
707            .fixed_pos(anchor)
708            // Keep the whole list on screen when the click lands near an edge.
709            .constrain(true)
710            .show(ctx, |ui| {
711                // The standard popup frame at reduced opacity: the model stays
712                // visible through the list while scrolling it.
713                let mut frame = egui::Frame::popup(ui.style());
714                frame.fill = frame.fill.gamma_multiply(0.85);
715                frame.show(ui, |ui| {
716                    ui.set_max_width(280.0);
717                    // Header row: title + a "Clear Selection" action.
718                    ui.horizontal(|ui| {
719                        ui.label(egui::RichText::new("Select an object").weak().small());
720                        ui.with_layout(
721                            egui::Layout::right_to_left(egui::Align::Center),
722                            |ui| {
723                                if ui.small_button("Clear Selection").clicked() {
724                                    clear_clicked = true;
725                                }
726                            },
727                        );
728                    });
729                    // A long candidate list scrolls instead of growing past the
730                    // viewport; hover keeps re-resolving as rows slide under the
731                    // pointer, so scrolling through the list highlights each
732                    // entity in turn.
733                    egui::ScrollArea::vertical()
734                        .max_height(240.0)
735                        .show(ui, |ui| {
736                            ui.set_min_width(220.0);
737                            for (i, candidate) in candidates.iter().enumerate() {
738                                let label = format!(
739                                    "{}  {}",
740                                    state.candidate_kind_label(candidate),
741                                    candidate_label(candidate)
742                                );
743                                let resp = ui.selectable_label(selected_rows[i], label);
744                                hits.push(resp.rect);
745                                if resp.hovered() {
746                                    hovered_index = Some(i);
747                                }
748                                if resp.clicked() {
749                                    clicked_index = Some(i);
750                                }
751                            }
752                        });
753                });
754            });
755
756        self.candidate_hits = hits;
757        self.candidate_popup_rect = Some(area.response.rect);
758
759        // Apply engine mutations outside the draw closure.
760        if let Some(i) = hovered_index {
761            state.hover_candidate(&candidates[i]);
762        } else {
763            // No entry under the pointer → drop the pre-highlight, so the
764            // last-hovered row's entity doesn't stay lit while the pointer
765            // roams elsewhere.
766            state.clear_hover();
767        }
768        let mut close = false;
769        if clear_clicked {
770            state.clear_selection();
771            close = true;
772        }
773        if let Some(i) = clicked_index {
774            // Picking an entry ALWAYS dismisses the list — the pick is the
775            // list's job and it is done. In Click-toggles mode (or with
776            // Ctrl/Cmd held) the entry TOGGLES into the selection, so a
777            // front+back multi-selection is click → front, click → back (the
778            // list reopens on the next click); in Ctrl+Click mode a plain
779            // entry click REPLACES the selection with exactly that entity.
780            let toggles = state.settings.multi_select == MultiSelectMode::ClickToggles;
781            if toggles || mods.command || mods.ctrl {
782                state.toggle_candidate(&candidates[i]);
783            } else {
784                state.select_candidate(&candidates[i]);
785            }
786            close = true;
787        }
788        // Fallback only: the app shell's global Escape router consumes the key
789        // first and closes via `close_candidate_popup` (so Escape never ALSO
790        // clears the selection); this fires only when that router is skipped
791        // (e.g. a text edit had focus).
792        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
793            close = true;
794        }
795        // Ignore the OPENING Alt+click on the frame it opened; honor click-outside
796        // from the next frame on.
797        if self.candidate_popup_fresh {
798            self.candidate_popup_fresh = false;
799        } else if area.response.clicked_elsewhere() {
800            close = true;
801        }
802        if close {
803            state.clear_hover();
804            self.candidate_popup = None;
805            self.candidate_popup_rect = None;
806        }
807    }
808
809    /// The OPEN popup's candidate list as JSON `[{index,kind,name,solid,depth}]`
810    /// (empty when closed) — the headed verifier asserts the sorted list.
811    #[cfg(target_arch = "wasm32")]
812    fn candidates_json(&self, state: &EngineState) -> String {
813        match self.candidate_popup.as_ref() {
814            Some(popup) => {
815                let out: Vec<serde_json::Value> = popup
816                    .candidates
817                    .iter()
818                    .enumerate()
819                    .map(|(i, c)| {
820                        serde_json::json!({
821                            "index": i,
822                            "kind": state.candidate_kind_label(c),
823                            "name": c.name,
824                            "solid": c.solid,
825                            "depth": c.depth,
826                        })
827                    })
828                    .collect();
829                serde_json::Value::Array(out).to_string()
830            }
831            None => "[]".to_string(),
832        }
833    }
834
835    /// The OPEN popup's per-entry screen rects as JSON `[{index,x,y,w,h}]` (egui
836    /// points) so the verifier can click a specific candidate entry.
837    #[cfg(target_arch = "wasm32")]
838    fn candidate_hits_json(&self) -> String {
839        let out: Vec<serde_json::Value> = self
840            .candidate_hits
841            .iter()
842            .enumerate()
843            .map(|(i, r)| {
844                serde_json::json!({
845                    "index": i,
846                    "x": r.min.x,
847                    "y": r.min.y,
848                    "w": r.width(),
849                    "h": r.height(),
850                })
851            })
852            .collect();
853        serde_json::Value::Array(out).to_string()
854    }
855}
856
857/// Which branch CLAIMED a viewport drag-start — the one dispatch that decides
858/// whether a press drives a gizmo handle or the camera. Returned by
859/// [`route_drag_start`], which performs the claim; the caller only records which
860/// drag is now live.
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub(super) enum DragStart {
863    /// The ViewCube corner: the router already snapped the camera.
864    ViewCube,
865    /// A transform-gizmo handle (arrow / ring ball / center sphere).
866    Gizmo,
867    /// A handle of the armed assembly-component Move gizmo.
868    Component,
869    /// A ◎ dimension arrowhead; carries the param field key it edits.
870    Dimension(String),
871    /// An assembly-constraint distance/angle handle.
872    Constraint,
873    /// Nothing claimed it → the camera orbit/pan fallthrough.
874    Camera,
875}
876
877/// The VIEWPORT-LOCAL point a drag-start hit test must be taken at. The ONE
878/// place that choice is made — the dispatch and its tests both call THIS, so a
879/// test can never pass against a call site that resolved the point differently.
880///
881/// NOT `response.interact_pointer_pos()`, which is the pointer's position on the
882/// frame egui DECIDED the press was a drag — by then it has travelled at least
883/// `max_click_dist` (6 pt) from the press, and a slow frame (the 3D viewport
884/// waking from egui's on-demand repaint) coalesces the whole flick into one
885/// step, so the reported point can be tens of px away. A gizmo handle carries
886/// 7-9 px of grab radius (axis arrow / rotation ball / centre sphere), a
887/// dimension leader 18, so hit-testing there misses the handle the user
888/// actually pressed and the press falls through to the camera orbit.
889///
890/// `press_origin` is exactly where the button went down, so the test asks "what
891/// did you press on", not "where has the cursor got to". It is `None` only when
892/// no button is down — a press and release inside ONE frame — where the reported
893/// interact position is all there is.
894fn drag_start_local(response: &egui::Response, rect: egui::Rect) -> Option<(f64, f64)> {
895    let p = response
896        .ctx
897        .input(|i| i.pointer.press_origin())
898        .or_else(|| response.interact_pointer_pos())?;
899    Some(((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64))
900}
901
902/// Dispatch ONE viewport drag-start at viewport-local px `(x, y)`: the first
903/// branch whose handle is under the press CLAIMS it (and arms its drag inside
904/// the engine), else the camera takes it.
905///
906/// Precedence is load-bearing and unchanged: ViewCube corner → transform gizmo →
907/// component Move gizmo → ◎ dimension arrowhead → assembly-constraint handle →
908/// camera. Split out of `handle_viewport_input` so the claim can be driven from
909/// a test without a GPU-backed [`Viewport`].
910pub(super) fn route_drag_start(state: &mut EngineState, x: f64, y: f64) -> DragStart {
911    if let Some((cx, cy)) = viewcube_local(state, x, y) {
912        state.viewcube_click(cx, cy);
913        DragStart::ViewCube
914    } else if state.transform_press(x, y) {
915        DragStart::Gizmo
916    } else if state.component_press(x, y) {
917        DragStart::Component
918    } else if let Some(field) = state.dimension_arrow_pick(x, y) {
919        DragStart::Dimension(field)
920    } else if state.constraint_drag_begin(x, y) {
921        DragStart::Constraint
922    } else {
923        DragStart::Camera
924    }
925}
926
927/// The ViewCube corner rect hit test (viewport-local logical px) — `Some` with
928/// the CUBE-local coords when `(x, y)` is inside the drawn cube, else `None`.
929fn viewcube_local(state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
930    let v: serde_json::Value = serde_json::from_str(&state.viewcube_rect_json()).ok()?;
931    let (rx, ry, rw, rh) = (
932        v["x"].as_f64()?,
933        v["y"].as_f64()?,
934        v["w"].as_f64()?,
935        v["h"].as_f64()?,
936    );
937    if rw > 0.0 && rh > 0.0 && x >= rx && x <= rx + rw && y >= ry && y <= ry + rh {
938        Some((x - rx, y - ry))
939    } else {
940        None
941    }
942}
943
944/// A human label for a pick candidate: its kernel name, or a positional tag for
945/// unnamed vertices.
946fn candidate_label(candidate: &PickCandidate) -> String {
947    if candidate.name.trim().is_empty() {
948        let p = candidate.position;
949        format!("({:.2}, {:.2}, {:.2})", p[0], p[1], p[2])
950    } else {
951        candidate.name.clone()
952    }
953}
954
955/// Mirror an engine JSON string to `window.<name>` (wasm/verification only) — the
956/// viewport's own copy so it can publish its hover/candidate globals without
957/// reaching into the app shell.
958#[cfg(target_arch = "wasm32")]
959fn publish_to_js(name: &str, json: &str) {
960    if let Some(win) = web_sys::window() {
961        let _ = js_sys::Reflect::set(
962            &win,
963            &wasm_bindgen::JsValue::from_str(name),
964            &wasm_bindgen::JsValue::from_str(json),
965        );
966    }
967}
968
969#[cfg(test)]
970mod drag_start_tests {
971    use super::*;
972    use brep_render::engine_state::EngineState;
973
974    /// The viewport rect the harness allocates: the WHOLE 800x600 screen, so
975    /// viewport-local px and egui screen points are the same numbers.
976    const VIEW: egui::Rect =
977        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(800.0, 600.0));
978
979    /// A one-feature history whose cube carries a `transform` group, so the
980    /// transform gizmo can arm on it.
981    fn cube_history(name: &str) -> String {
982        serde_json::json!({
983            "expressions": "",
984            "configurator": {},
985            "features": [{
986                "type": "P.CU",
987                "inputParams": {
988                    "id": name,
989                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
990                    "transform": {
991                        "position": [0.0, 0.0, 0.0],
992                        "rotationEuler": [0.0, 0.0, 0.0],
993                        "scale": [1.0, 1.0, 1.0]
994                    },
995                    "boolean": { "targets": [], "operation": "NONE" }
996                },
997                "persistentData": {}
998            }]
999        })
1000        .to_string()
1001    }
1002
1003    /// An engine with the cube armed for transform, framed dead-on down -Z in
1004    /// orthographic so the gizmo origin projects to the viewport centre.
1005    fn armed_engine() -> EngineState {
1006        let mut state = EngineState::new();
1007        state.set_history_json(&cube_history("Pin")).unwrap();
1008        state.resize(800.0, 600.0);
1009        state.camera.eye = [0.0, 0.0, 40.0];
1010        state.camera.target = [0.0, 0.0, 0.0];
1011        state.camera.up = [0.0, 1.0, 0.0];
1012        state.camera.projection =
1013            brep_render::view::Projection::Orthographic { half_height: 20.0 };
1014        state.arm_transform("Pin");
1015        state
1016    }
1017
1018    /// The published hit regions of the armed gizmo, exactly as the debug
1019    /// outline (and the hit test) see them, in viewport-local px.
1020    fn hit_areas(state: &EngineState) -> Vec<serde_json::Value> {
1021        serde_json::from_str(&state.transform_hit_areas_json()).unwrap()
1022    }
1023
1024    /// The FIRST axis-arrow capsule, as `(a, b, r)` screen px.
1025    fn axis_capsule(state: &EngineState) -> (egui::Pos2, egui::Pos2, f32) {
1026        let areas = hit_areas(state);
1027        let cap = areas
1028            .iter()
1029            .find(|a| a["kind"] == "capsule")
1030            .expect("an axis-arrow capsule");
1031        let at = |v: &serde_json::Value| {
1032            egui::pos2(v[0].as_f64().unwrap() as f32, v[1].as_f64().unwrap() as f32)
1033        };
1034        (at(&cap["a"]), at(&cap["b"]), cap["r"].as_f64().unwrap() as f32)
1035    }
1036
1037    /// The LAST circle region — a rotation grab BALL (the centre sphere is
1038    /// emitted first, the three ring balls after it).
1039    fn ring_ball(state: &EngineState) -> (egui::Pos2, f32) {
1040        let areas = hit_areas(state);
1041        let ball = areas
1042            .iter()
1043            .filter(|a| a["kind"] == "circle")
1044            .next_back()
1045            .expect("a rotation grab ball");
1046        let c = &ball["c"];
1047        (
1048            egui::pos2(c[0].as_f64().unwrap() as f32, c[1].as_f64().unwrap() as f32),
1049            ball["r"].as_f64().unwrap() as f32,
1050        )
1051    }
1052
1053    /// Run ONE egui frame over the viewport rect with `events`, dispatching a
1054    /// drag-start exactly the way `handle_viewport_input` does. Returns the
1055    /// claimed branch on the frame egui decides the press is a drag.
1056    fn frame(
1057        ctx: &egui::Context,
1058        state: &mut EngineState,
1059        events: Vec<egui::Event>,
1060    ) -> Option<DragStart> {
1061        let raw = egui::RawInput {
1062            screen_rect: Some(VIEW),
1063            events,
1064            ..Default::default()
1065        };
1066        let mut claimed = None;
1067        let _ = ctx.run_ui(raw, |ui| {
1068            let response = ui.allocate_rect(VIEW, egui::Sense::click_and_drag());
1069            if response.drag_started() {
1070                if let Some(pos) = response.interact_pointer_pos() {
1071                    let (gx, gy) = drag_start_local(&response, VIEW).unwrap_or((
1072                        (pos.x - VIEW.min.x) as f64,
1073                        (pos.y - VIEW.min.y) as f64,
1074                    ));
1075                    claimed = Some(route_drag_start(state, gx, gy));
1076                }
1077            }
1078        });
1079        claimed
1080    }
1081
1082    fn moved(pos: egui::Pos2) -> egui::Event {
1083        egui::Event::PointerMoved(pos)
1084    }
1085
1086    fn button(pos: egui::Pos2, pressed: bool) -> egui::Event {
1087        egui::Event::PointerButton {
1088            pos,
1089            button: egui::PointerButton::Primary,
1090            pressed,
1091            modifiers: egui::Modifiers::default(),
1092        }
1093    }
1094
1095    /// Hover, press at `press`, then take ONE coalesced move to `press + delta`
1096    /// — the frame egui promotes the press to a drag. Returns the claiming
1097    /// branch and the pointer position the drag is now at (still held down).
1098    fn press_and_drag(
1099        ctx: &egui::Context,
1100        state: &mut EngineState,
1101        press: egui::Pos2,
1102        delta: egui::Vec2,
1103    ) -> (DragStart, egui::Pos2) {
1104        frame(ctx, state, vec![moved(press)]);
1105        frame(ctx, state, vec![button(press, true)]);
1106        let to = press + delta;
1107        let claimed = frame(ctx, state, vec![moved(to)])
1108            .expect("egui promotes the press to a drag on the move frame");
1109        (claimed, to)
1110    }
1111
1112    /// The same, released at the end. Returns the claiming branch.
1113    fn press_drag_release(
1114        ctx: &egui::Context,
1115        state: &mut EngineState,
1116        press: egui::Pos2,
1117        delta: egui::Vec2,
1118    ) -> DragStart {
1119        let (claimed, to) = press_and_drag(ctx, state, press, delta);
1120        frame(ctx, state, vec![button(to, false)]);
1121        claimed
1122    }
1123
1124    /// The reported bug: the FIRST click-drag on a gizmo BALL orbits the camera
1125    /// instead of grabbing the handle (only the second drag moves the gizmo).
1126    ///
1127    /// The press lands dead-centre on a rotation grab ball's published hit
1128    /// region, then the pointer takes ONE coalesced ~20 px step — what a real
1129    /// flick looks like when the 3D viewport wakes from egui's on-demand
1130    /// repaint. The grab must resolve at the PRESS, not at where the cursor got
1131    /// to, so BOTH cycles claim the gizmo.
1132    #[test]
1133    fn the_first_drag_on_a_gizmo_ball_grabs_it_instead_of_orbiting() {
1134        let ctx = egui::Context::default();
1135        let mut state = armed_engine();
1136        let (ball, r) = ring_ball(&state);
1137        assert!(r < 12.0, "grab ball radius {r}px is smaller than the click radius");
1138
1139        for cycle in 1..=2 {
1140            let claimed = press_drag_release(&ctx, &mut state, ball, egui::vec2(14.0, 14.0));
1141            assert_eq!(
1142                claimed,
1143                DragStart::Gizmo,
1144                "cycle {cycle}: pressing the rotation ball must drive the gizmo, not the camera"
1145            );
1146            assert!(state.transform_dragging(), "cycle {cycle}: the gizmo drag is armed");
1147            state.transform_release();
1148        }
1149    }
1150
1151    /// Same class on an axis ARROW, at the MINIMUM drift egui can report: the
1152    /// press sits 3 px off the shaft spine (inside the 7 px capsule) and the
1153    /// pointer moves just past the 6 px click radius perpendicular to it, so the
1154    /// reported drag position is ~9.5 px off the spine — outside the arrow.
1155    #[test]
1156    fn the_first_drag_on_a_gizmo_arrow_grabs_it_at_the_minimum_drift() {
1157        let ctx = egui::Context::default();
1158        let mut state = armed_engine();
1159        let (a, b, r) = axis_capsule(&state);
1160        assert!(r > 3.0 && r < 10.0, "axis hit radius {r}px");
1161        let dir = (b - a).normalized();
1162        let normal = egui::vec2(-dir.y, dir.x);
1163        // 60% along the shaft (clear of the centre ball), 3 px off the spine.
1164        let press = a + dir * ((b - a).length() * 0.6) + normal * 3.0;
1165
1166        for cycle in 1..=2 {
1167            let claimed = press_drag_release(&ctx, &mut state, press, normal * 6.5);
1168            assert_eq!(
1169                claimed,
1170                DragStart::Gizmo,
1171                "cycle {cycle}: pressing the axis arrow must drive the gizmo, not the camera"
1172            );
1173            state.transform_release();
1174        }
1175    }
1176
1177
1178
1179    /// Why the SECOND drag used to work: how far egui's reported drag position
1180    /// has drifted depends entirely on how fast the pointer was moving.
1181    ///
1182    /// Same press, same handle, two speeds — a brisk flick that egui coalesces
1183    /// into ONE 20 px step, and a deliberate crawl in 2 px steps that trips the
1184    /// 6 px click radius barely past it. Hit-testing at the reported position
1185    /// claimed the gizmo only for the crawl, which is exactly the shape of the
1186    /// report: the natural first attempt orbited, the careful second one (made
1187    /// while watching for the glitch) grabbed. Anchored at the press, BOTH
1188    /// speeds claim the handle.
1189    #[test]
1190    fn a_flick_and_a_crawl_on_the_same_handle_both_grab_it() {
1191        let ctx = egui::Context::default();
1192        let mut state = armed_engine();
1193        let (ball, _) = ring_ball(&state);
1194
1195        // The flick: one coalesced 20 px step.
1196        let flicked = press_drag_release(&ctx, &mut state, ball, egui::vec2(14.0, 14.0));
1197        assert_eq!(flicked, DragStart::Gizmo, "a brisk flick must grab the ball");
1198        state.transform_release();
1199
1200        // The crawl: 2 px a frame until egui calls it a drag.
1201        frame(&ctx, &mut state, vec![moved(ball)]);
1202        frame(&ctx, &mut state, vec![button(ball, true)]);
1203        let mut at = ball;
1204        let mut crawled = None;
1205        for _ in 0..8 {
1206            at += egui::vec2(2.0, 0.0);
1207            if let Some(claim) = frame(&ctx, &mut state, vec![moved(at)]) {
1208                crawled = Some(claim);
1209                break;
1210            }
1211        }
1212        frame(&ctx, &mut state, vec![button(at, false)]);
1213        assert_eq!(
1214            crawled,
1215            Some(DragStart::Gizmo),
1216            "a deliberate crawl must grab the ball too"
1217        );
1218        state.transform_release();
1219    }
1220
1221    /// The OUTCOME the user asked for: the first drag MOVES the feature. The
1222    /// CLAIM is what keeps the camera out of it (a `Camera` route is the bug);
1223    /// the eye check below is belt-and-braces, since `pointer_down` alone
1224    /// records rather than orbits. The press is dead-centre on the +X arrow and the
1225    /// pointer takes one coalesced (25, 10) px step — 10 px off the shaft spine,
1226    /// outside the 7 px capsule, so hit-testing at the reported drag position
1227    /// would orbit instead.
1228    ///
1229    /// The translation is the WHOLE press → cursor delta (25 px of axial travel
1230    /// = 25 × `world_per_pixel`), not that minus the click radius: the grab is
1231    /// anchored at the press, so the feature ends up under the cursor rather
1232    /// than lagging it by the drift for the rest of the drag.
1233    #[test]
1234    fn the_first_drag_moves_the_feature_and_leaves_the_camera_alone() {
1235        let ctx = egui::Context::default();
1236        let mut state = armed_engine();
1237        let eye_before = state.camera.eye;
1238        let wpp = state.camera.world_per_pixel();
1239        let (a, b, _) = axis_capsule(&state);
1240        // Dead-centre on the shaft, 60% out from the origin ball.
1241        let dir = (b - a).normalized();
1242        let press = a + dir * ((b - a).length() * 0.6);
1243
1244        let (claimed, to) = press_and_drag(&ctx, &mut state, press, egui::vec2(25.0, 10.0));
1245        assert_eq!(claimed, DragStart::Gizmo, "the press must claim the arrow");
1246        state.transform_drag_to(to.x as f64, to.y as f64);
1247        frame(&ctx, &mut state, vec![button(to, false)]);
1248        state.transform_release();
1249
1250        let index = state.history.index_of("Pin").unwrap();
1251        let params = state.history.feature_params(index).unwrap();
1252        let moved_x = params["transform"]["position"][0].as_f64().unwrap();
1253        let want = 25.0 * wpp;
1254        assert!(
1255            (moved_x - want).abs() < want * 0.02,
1256            "the feature must track the whole press→cursor travel: {moved_x} vs {want}"
1257        );
1258        assert_eq!(state.camera.eye, eye_before, "the camera must not have orbited");
1259    }
1260
1261    /// The ◎ DIMENSION arrowheads share the drag-start chain, so they shared the
1262    /// bug and the same fix covers them: a press on a dimension leader followed
1263    /// by a coalesced step off its capsule must still grab the param, not orbit.
1264    /// The leader's grab radius is 18 px (`ARROW_HANDLE_HIT_RAD_PX`), so the
1265    /// step here clears it perpendicular to the shaft.
1266    #[test]
1267    fn the_first_drag_on_a_dimension_arrow_grabs_the_param() {
1268        let ctx = egui::Context::default();
1269        let mut state = armed_engine();
1270        state.arm_dimension("Pin");
1271        assert_eq!(state.gizmo_mode(), "dimension");
1272
1273        // The sizeX leader runs origin → (sizeX, 0, 0); press on its shaft.
1274        let annotations: Vec<serde_json::Value> =
1275            serde_json::from_str(&state.feature_dimension_annotations_json("Pin")).unwrap();
1276        let ann = annotations
1277            .iter()
1278            .find(|a| a["fieldKey"] == "sizeX")
1279            .expect("a sizeX dim");
1280        let world = |v: &serde_json::Value| {
1281            [
1282                v[0].as_f64().unwrap(),
1283                v[1].as_f64().unwrap(),
1284                v[2].as_f64().unwrap(),
1285            ]
1286        };
1287        let (ax, ay, _) = state.camera.project(world(&ann["pointA"]));
1288        let (bx, by, _) = state.camera.project(world(&ann["pointB"]));
1289        let a = egui::pos2(ax as f32, ay as f32);
1290        let b = egui::pos2(bx as f32, by as f32);
1291        let dir = (b - a).normalized();
1292        let normal = egui::vec2(-dir.y, dir.x);
1293        let press = a + dir * ((b - a).length() * 0.7);
1294
1295        for cycle in 1..=2 {
1296            let claimed = press_drag_release(&ctx, &mut state, press, normal * 25.0);
1297            assert_eq!(
1298                claimed,
1299                DragStart::Dimension("sizeX".to_string()),
1300                "cycle {cycle}: pressing the dimension leader must edit sizeX, not orbit"
1301            );
1302        }
1303    }
1304
1305    /// The FALLTHROUGH stays intact: a press on empty space still starts a
1306    /// camera orbit and arms no gizmo drag.
1307    #[test]
1308    fn an_empty_space_drag_still_orbits_the_camera() {
1309        let ctx = egui::Context::default();
1310        let mut state = armed_engine();
1311        // Far from the gizmo (which sits at the viewport centre) and from the
1312        // ViewCube corner.
1313        let empty = egui::pos2(120.0, 480.0);
1314        let claimed = press_drag_release(&ctx, &mut state, empty, egui::vec2(20.0, 8.0));
1315        assert_eq!(claimed, DragStart::Camera, "empty space must still orbit");
1316        assert!(!state.transform_dragging(), "no gizmo drag was armed");
1317    }
1318
1319    /// The ViewCube corner keeps its precedence over everything else in the
1320    /// chain: a drag that starts inside the cube rect snaps the view, never a
1321    /// gizmo grab or an orbit.
1322    #[test]
1323    fn a_drag_started_over_the_viewcube_still_snaps_the_view() {
1324        let mut state = armed_engine();
1325        let cube: serde_json::Value =
1326            serde_json::from_str(&state.viewcube_rect_json()).unwrap();
1327        let (x, y, w, h) = (
1328            cube["x"].as_f64().unwrap(),
1329            cube["y"].as_f64().unwrap(),
1330            cube["w"].as_f64().unwrap(),
1331            cube["h"].as_f64().unwrap(),
1332        );
1333        assert!(w > 0.0 && h > 0.0, "the ViewCube is drawn");
1334        assert_eq!(
1335            route_drag_start(&mut state, x + w * 0.5, y + h * 0.5),
1336            DragStart::ViewCube
1337        );
1338        assert!(!state.transform_dragging(), "the cube beat the gizmo");
1339    }
1340}