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}