Skip to main content

brep_render/
controls.rs

1//! Arcball orbit/pan/zoom (R22) — the ArcballControls feel the user base has
2//! muscle memory for, as a pure state machine over [`ViewCamera`]:
3//!
4//! - left drag  = trackball rotate (Shoemake sphere/hyperbola blend, 1:1),
5//! - right/middle drag = pan (scene follows the cursor),
6//! - wheel = zoom about the target, a gentle exponential step per notch
7//!   (`enableAnimations` was false in the app, so there is deliberately NO
8//!   inertia/damping; the smooth ramp comes from egui's own per-frame scroll
9//!   smoothing — see [`ZOOM_PER_NOTCH`]).
10//!
11//! Input arrives as forwarded browser pointer/wheel events through the R3 API (CSS
12//! pixels); the same struct drives the winit desktop shell.
13
14use crate::view::{add3, cross3, dot3, len3, norm3, rotate3, scale3, sub3, Projection, ViewCamera};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Gesture {
18    None,
19    Rotate,
20    Pan,
21}
22
23/// Buttons follow the browser `PointerEvent.button` values.
24pub const BUTTON_LEFT: i32 = 0;
25pub const BUTTON_MIDDLE: i32 = 1;
26pub const BUTTON_RIGHT: i32 = 2;
27
28/// Wheel-zoom sensitivity. Input `delta_y` is in egui POINTS (pixel-like): the
29/// eframe viewport forwards `smooth_scroll_delta.y`, the browser bridge forwards
30/// `WheelEvent.deltaY` (normalized to points by the host forwarder), and the winit
31/// desktop shell forwards `LineDelta*100 / PixelDelta`. egui SMOOTHS one physical
32/// wheel notch across several frames (see egui `WheelState::after_events`), so a
33/// single notch can arrive as many small point-fragments.
34///
35/// We map points → a fractional notch count and apply an EXPONENTIAL zoom
36/// `ZOOM_PER_NOTCH^notches`. Because the per-frame factors MULTIPLY, the product
37/// over a smoothed notch telescopes to exactly
38/// `ZOOM_PER_NOTCH^(total_points / WHEEL_POINTS_PER_NOTCH)` — so the feel is
39/// independent of how egui splits the notch into frames, and zoom is smooth and
40/// can never overshoot/jump through the target, on both native and wasm. (The
41/// old code treated each sub-40-point smoothing fragment AS whole notches and
42/// clamped to 3, compounding ~1.331× per frame → one notch overshot several-fold.)
43///
44/// `WHEEL_POINTS_PER_NOTCH = 70` sits between egui's native line notch
45/// (`line_scroll_speed = 40` pt) and a browser's pixel notch (~100 pt), so a
46/// native wheel notch ≈ 4.5% and a browser notch ≈ 11.6% view-distance change —
47/// both a gentle, controllable step. Tune `ZOOM_PER_NOTCH` to taste.
48const ZOOM_PER_NOTCH: f64 = 1.08;
49/// egui points per physical wheel notch, used to normalize `delta_y` (see above).
50const WHEEL_POINTS_PER_NOTCH: f64 = 70.0;
51/// Cap a single pathological wheel event (e.g. a trackpad flick reporting a huge
52/// one-frame delta) so it can't jump abruptly. This deliberately breaks the
53/// telescoping property ONLY for such outliers; normal smoothed notches stay far
54/// below the cap, so their per-frame factors still multiply cleanly.
55const MAX_WHEEL_NOTCHES_PER_EVENT: f64 = 3.0;
56const MIN_ORTHO_HALF_HEIGHT: f64 = 1e-9;
57const MIN_PERSP_DISTANCE: f64 = 1e-6;
58
59#[derive(Debug, Default)]
60pub struct ArcballControls {
61    pub enabled: bool,
62    gesture: GestureState,
63}
64
65#[derive(Debug)]
66struct GestureState {
67    kind: Gesture,
68    last: (f64, f64),
69}
70
71impl Default for GestureState {
72    fn default() -> Self {
73        Self {
74            kind: Gesture::None,
75            last: (0.0, 0.0),
76        }
77    }
78}
79
80impl ArcballControls {
81    pub fn new() -> Self {
82        Self {
83            enabled: true,
84            gesture: GestureState::default(),
85        }
86    }
87
88    pub fn active_gesture(&self) -> Gesture {
89        self.gesture.kind
90    }
91
92    /// Begin a gesture. Returns true when the pointer is captured for camera
93    /// interaction.
94    pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
95        if !self.enabled {
96            return false;
97        }
98        self.gesture.kind = match button {
99            BUTTON_LEFT => Gesture::Rotate,
100            BUTTON_MIDDLE | BUTTON_RIGHT => Gesture::Pan,
101            _ => Gesture::None,
102        };
103        self.gesture.last = (x, y);
104        self.gesture.kind != Gesture::None
105    }
106
107    /// Advance the active gesture; mutates the camera. Returns true when the
108    /// camera changed (the dirty signal).
109    pub fn pointer_move(&mut self, camera: &mut ViewCamera, x: f64, y: f64) -> bool {
110        if !self.enabled || self.gesture.kind == Gesture::None {
111            return false;
112        }
113        let (lx, ly) = self.gesture.last;
114        if (x - lx).abs() < f64::EPSILON && (y - ly).abs() < f64::EPSILON {
115            return false;
116        }
117        match self.gesture.kind {
118            Gesture::Rotate => rotate_arcball(camera, (lx, ly), (x, y)),
119            Gesture::Pan => pan(camera, x - lx, y - ly),
120            Gesture::None => {}
121        }
122        self.gesture.last = (x, y);
123        true
124    }
125
126    /// End the gesture. Returns true if one was active.
127    pub fn pointer_up(&mut self) -> bool {
128        let was = self.gesture.kind != Gesture::None;
129        self.gesture.kind = Gesture::None;
130        was
131    }
132
133    /// Wheel zoom about the target. `delta_y` is in egui POINTS (see
134    /// [`ZOOM_PER_NOTCH`]); negative = zoom in. Returns true when the camera
135    /// changed.
136    pub fn wheel(&mut self, camera: &mut ViewCamera, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
137        if !self.enabled || delta_y == 0.0 {
138            return false;
139        }
140        // Points → fractional notches, then an EXPONENTIAL per-notch zoom. The
141        // clamp only guards a pathological single-frame delta; normal smoothed
142        // notches stay well under it so their per-frame factors telescope.
143        let notches = (delta_y / WHEEL_POINTS_PER_NOTCH)
144            .clamp(-MAX_WHEEL_NOTCHES_PER_EVENT, MAX_WHEEL_NOTCHES_PER_EVENT);
145        let factor = ZOOM_PER_NOTCH.powf(notches);
146        match cursor {
147            Some([cx, cy]) => zoom_toward(camera, factor, cx, cy),
148            None => zoom(camera, factor),
149        }
150        true
151    }
152}
153
154/// Zoom by `factor` while keeping the world point under the cursor fixed —
155/// "zoom toward the mouse". `cx,cy` are cursor CSS px (top-left origin).
156pub fn zoom_toward(camera: &mut ViewCamera, factor: f64, cx: f64, cy: f64) {
157    let wpp = camera.world_per_pixel();
158    let (right, up, _) = camera.basis();
159    let sx = cx - camera.width * 0.5;
160    let sy = -(cy - camera.height * 0.5); // screen-y-down → world-up
161    // World offset from the target to the cursor point on the focus plane.
162    let off = add3(scale3(right, sx * wpp), scale3(up, sy * wpp));
163    match &mut camera.projection {
164        Projection::Orthographic { half_height } => {
165            let old = *half_height;
166            *half_height = (old * factor).max(MIN_ORTHO_HALF_HEIGHT);
167            let f = *half_height / old; // actual (clamped) factor
168            let shift = scale3(off, 1.0 - f);
169            camera.target = add3(camera.target, shift);
170            camera.eye = add3(camera.eye, shift);
171        }
172        Projection::Perspective { .. } => {
173            // Scale eye + target about the cursor world point (keeps the view
174            // direction; the cursor point stays put → zoom toward the mouse).
175            let cursor_world = add3(camera.target, off);
176            let nt = add3(cursor_world, scale3(sub3(camera.target, cursor_world), factor));
177            let ne = add3(cursor_world, scale3(sub3(camera.eye, cursor_world), factor));
178            let dir = sub3(ne, nt);
179            let dist = len3(dir).max(MIN_PERSP_DISTANCE);
180            camera.target = nt;
181            camera.eye = add3(nt, scale3(norm3(dir), dist));
182        }
183    }
184}
185
186/// Zoom by a frustum-scale factor (>1 zooms out).
187pub fn zoom(camera: &mut ViewCamera, factor: f64) {
188    match &mut camera.projection {
189        Projection::Orthographic { half_height } => {
190            *half_height = (*half_height * factor).max(MIN_ORTHO_HALF_HEIGHT);
191        }
192        Projection::Perspective { .. } => {
193            let dir = sub3(camera.eye, camera.target);
194            let dist = (len3(dir) * factor).max(MIN_PERSP_DISTANCE);
195            camera.eye = add3(camera.target, scale3(norm3(dir), dist));
196        }
197    }
198}
199
200/// Pan by a screen-space delta in CSS px: the scene follows the cursor
201/// (dragging right moves the model right, i.e. the camera left).
202pub fn pan(camera: &mut ViewCamera, dx: f64, dy: f64) {
203    let (right, up, _) = camera.basis();
204    let wpp = camera.world_per_pixel();
205    let offset = add3(scale3(right, -dx * wpp), scale3(up, dy * wpp));
206    camera.eye = add3(camera.eye, offset);
207    camera.target = add3(camera.target, offset);
208}
209
210/// Map a CSS-pixel cursor position onto the virtual trackball (camera-space
211/// unit vector). Shoemake sphere with the ArcballControls hyperbolic skirt so
212/// the rotation stays continuous past the sphere edge.
213fn trackball_point(camera: &ViewCamera, x: f64, y: f64) -> [f64; 3] {
214    let radius = 0.5 * camera.width.min(camera.height).max(1.0) * 0.75;
215    let cx = camera.width * 0.5;
216    let cy = camera.height * 0.5;
217    let px = x - cx;
218    let py = cy - y; // y up in trackball space
219    let r2 = radius * radius;
220    let d2 = px * px + py * py;
221    let pz = if d2 <= r2 * 0.5 {
222        (r2 - d2).sqrt()
223    } else {
224        // Hyperbolic sheet: z = (r²/2)/√d²
225        r2 * 0.5 / d2.sqrt()
226    };
227    norm3([px, py, pz])
228}
229
230/// Trackball rotate from cursor `from` → `to` (CSS px): rotates the eye AND
231/// the up vector around the target — a free arcball, no up-axis lock (the
232/// ArcballControls behavior).
233fn rotate_arcball(camera: &mut ViewCamera, from: (f64, f64), to: (f64, f64)) {
234    let v0 = trackball_point(camera, from.0, from.1);
235    let v1 = trackball_point(camera, to.0, to.1);
236    let axis_cam = cross3(v0, v1);
237    let axis_len = len3(axis_cam);
238    if axis_len < 1e-12 {
239        return;
240    }
241    let angle = dot3(v0, v1).clamp(-1.0, 1.0).acos();
242    if angle.abs() < 1e-12 {
243        return;
244    }
245    // Camera-space axis → world space through the camera basis; the scene
246    // rotates WITH the drag, so the camera rotates by the inverse.
247    let (right, up, forward) = camera.basis();
248    let axis_cam = scale3(axis_cam, 1.0 / axis_len);
249    let axis_world = norm3(add3(
250        add3(scale3(right, axis_cam[0]), scale3(up, axis_cam[1])),
251        scale3(forward, -axis_cam[2]),
252    ));
253    let offset = sub3(camera.eye, camera.target);
254    camera.eye = add3(camera.target, rotate3(offset, axis_world, -angle));
255    camera.up = norm3(rotate3(camera.up, axis_world, -angle));
256}
257
258// BREP private tests: 553c65bff75a91e5