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