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    /// Direct two-finger navigation in viewport pixels. Zoom about the previous
134    /// midpoint, then carry that point to the new midpoint. Unlike wheel input,
135    /// the distance ratio is applied exactly, without notch sensitivity.
136    pub fn touch_navigation(
137        &mut self,
138        camera: &mut ViewCamera,
139        from: [f64; 2],
140        to: [f64; 2],
141        scale: f64,
142    ) -> bool {
143        if !self.enabled
144            || !scale.is_finite()
145            || scale <= 0.0
146            || !from.into_iter().chain(to).all(f64::is_finite)
147            || (from == to && scale == 1.0)
148        {
149            return false;
150        }
151        zoom_toward(camera, scale.recip(), from[0], from[1]);
152        pan(camera, to[0] - from[0], to[1] - from[1]);
153        true
154    }
155
156    /// Wheel zoom about the target. `delta_y` is in egui POINTS (see
157    /// [`ZOOM_PER_NOTCH`]); negative = zoom in. Returns true when the camera
158    /// changed.
159    pub fn wheel(
160        &mut self,
161        camera: &mut ViewCamera,
162        delta_y: f64,
163        cursor: Option<[f64; 2]>,
164    ) -> bool {
165        if !self.enabled || delta_y == 0.0 {
166            return false;
167        }
168        // Points → fractional notches, then an EXPONENTIAL per-notch zoom. The
169        // clamp only guards a pathological single-frame delta; normal smoothed
170        // notches stay well under it so their per-frame factors telescope.
171        let notches = (delta_y / WHEEL_POINTS_PER_NOTCH)
172            .clamp(-MAX_WHEEL_NOTCHES_PER_EVENT, MAX_WHEEL_NOTCHES_PER_EVENT);
173        let factor = ZOOM_PER_NOTCH.powf(notches);
174        match cursor {
175            Some([cx, cy]) => zoom_toward(camera, factor, cx, cy),
176            None => zoom(camera, factor),
177        }
178        true
179    }
180}
181
182/// Zoom by `factor` while keeping the world point under the cursor fixed —
183/// "zoom toward the mouse". `cx,cy` are cursor CSS px (top-left origin).
184pub fn zoom_toward(camera: &mut ViewCamera, factor: f64, cx: f64, cy: f64) {
185    let wpp = camera.world_per_pixel();
186    let (right, up, _) = camera.basis();
187    let sx = cx - camera.width * 0.5;
188    let sy = -(cy - camera.height * 0.5); // screen-y-down → world-up
189                                          // World offset from the target to the cursor point on the focus plane.
190    let off = add3(scale3(right, sx * wpp), scale3(up, sy * wpp));
191    match &mut camera.projection {
192        Projection::Orthographic { half_height } => {
193            let old = *half_height;
194            *half_height = (old * factor).max(MIN_ORTHO_HALF_HEIGHT);
195            let f = *half_height / old; // actual (clamped) factor
196            let shift = scale3(off, 1.0 - f);
197            camera.target = add3(camera.target, shift);
198            camera.eye = add3(camera.eye, shift);
199        }
200        Projection::Perspective { .. } => {
201            // Scale eye + target about the cursor world point (keeps the view
202            // direction; the cursor point stays put → zoom toward the mouse).
203            let cursor_world = add3(camera.target, off);
204            let nt = add3(
205                cursor_world,
206                scale3(sub3(camera.target, cursor_world), factor),
207            );
208            let ne = add3(cursor_world, scale3(sub3(camera.eye, cursor_world), factor));
209            let dir = sub3(ne, nt);
210            let dist = len3(dir).max(MIN_PERSP_DISTANCE);
211            camera.target = nt;
212            camera.eye = add3(nt, scale3(norm3(dir), dist));
213        }
214    }
215}
216
217/// Zoom by a frustum-scale factor (>1 zooms out).
218pub fn zoom(camera: &mut ViewCamera, factor: f64) {
219    match &mut camera.projection {
220        Projection::Orthographic { half_height } => {
221            *half_height = (*half_height * factor).max(MIN_ORTHO_HALF_HEIGHT);
222        }
223        Projection::Perspective { .. } => {
224            let dir = sub3(camera.eye, camera.target);
225            let dist = (len3(dir) * factor).max(MIN_PERSP_DISTANCE);
226            camera.eye = add3(camera.target, scale3(norm3(dir), dist));
227        }
228    }
229}
230
231/// Pan by a screen-space delta in CSS px: the scene follows the cursor
232/// (dragging right moves the model right, i.e. the camera left).
233pub fn pan(camera: &mut ViewCamera, dx: f64, dy: f64) {
234    let (right, up, _) = camera.basis();
235    let wpp = camera.world_per_pixel();
236    let offset = add3(scale3(right, -dx * wpp), scale3(up, dy * wpp));
237    camera.eye = add3(camera.eye, offset);
238    camera.target = add3(camera.target, offset);
239}
240
241/// Map a CSS-pixel cursor position onto the virtual trackball (camera-space
242/// unit vector). Shoemake sphere with the ArcballControls hyperbolic skirt so
243/// the rotation stays continuous past the sphere edge.
244fn trackball_point(camera: &ViewCamera, x: f64, y: f64) -> [f64; 3] {
245    let radius = 0.5 * camera.width.min(camera.height).max(1.0) * 0.75;
246    let cx = camera.width * 0.5;
247    let cy = camera.height * 0.5;
248    let px = x - cx;
249    let py = cy - y; // y up in trackball space
250    let r2 = radius * radius;
251    let d2 = px * px + py * py;
252    let pz = if d2 <= r2 * 0.5 {
253        (r2 - d2).sqrt()
254    } else {
255        // Hyperbolic sheet: z = (r²/2)/√d²
256        r2 * 0.5 / d2.sqrt()
257    };
258    norm3([px, py, pz])
259}
260
261/// Trackball rotate from cursor `from` → `to` (CSS px): rotates the eye AND
262/// the up vector around the target — a free arcball, no up-axis lock (the
263/// ArcballControls behavior).
264fn rotate_arcball(camera: &mut ViewCamera, from: (f64, f64), to: (f64, f64)) {
265    let v0 = trackball_point(camera, from.0, from.1);
266    let v1 = trackball_point(camera, to.0, to.1);
267    let axis_cam = cross3(v0, v1);
268    let axis_len = len3(axis_cam);
269    if axis_len < 1e-12 {
270        return;
271    }
272    let angle = dot3(v0, v1).clamp(-1.0, 1.0).acos();
273    if angle.abs() < 1e-12 {
274        return;
275    }
276    // Camera-space axis → world space through the camera basis; the scene
277    // rotates WITH the drag, so the camera rotates by the inverse.
278    let (right, up, forward) = camera.basis();
279    let axis_cam = scale3(axis_cam, 1.0 / axis_len);
280    let axis_world = norm3(add3(
281        add3(scale3(right, axis_cam[0]), scale3(up, axis_cam[1])),
282        scale3(forward, -axis_cam[2]),
283    ));
284    let offset = sub3(camera.eye, camera.target);
285    camera.eye = add3(camera.target, rotate3(offset, axis_world, -angle));
286    camera.up = norm3(rotate3(camera.up, axis_world, -angle));
287}
288