git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit77acdcbd662b51d245d9af72bbe483cd9f60b5fd
parent260e742061
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 14:40
fix: a trackpad flick over the viewport coasts after the lift

The viewport's scroll orbit and ctrl-scroll zoom had a coast of their own
that never ran: the runner delivers a finger lift as a zero delta in the
FingerEnd phase, the viewport read it as more motion, and its per-frame
velocity estimate blended zeros until the gesture timed out - so a flick
stopped dead at the lift (0.01 degree after it, in a shadow).

Both now run on cce-ui's ScrollMotion, the model the network pan uses: the
finger tracks 1:1, the lift coasts on the finger's own velocity (about 46
degrees for the same flick), a wheel notch glides. inertial_scroll still
switches the coast off; its length is input.kdl's scroll_friction. The
per-gesture axis lock is kept. A pinch does not coast, and stops a zoom
that is coasting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md          |  21 ++++
 src/app.rs         |   4 +-
 src/main.rs        |  86 ++++++++++++++
 src/viewport_3d.rs | 344 +++++++++++++++++++++++------------------------------
 4 files changed, 261 insertions(+), 194 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3dfe6d6..da6d165 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2989,6 +2989,27 @@ scroll path makes, so a dragged camera and a scrolled one mean the same thing.
 A drag stops when the pointer does (`reset_velocity`), unlike a flicked scroll,
 which coasts.
 
+**The scroll orbit and ctrl-scroll zoom coast** (since 2026-09-30):
+`Viewport3D` runs them on cce-ui's `ScrollMotion`, the model the network
+pan uses — a finger tracks 1:1, the lift coasts on the velocity of the
+finger's own events, a wheel notch glides. Until then it had a coast of
+its own that never ran: the runner delivers the lift as a ZERO delta in
+the `FingerEnd` phase, the viewport read that as more motion, and its
+per-frame velocity estimate blended zeros for the 50 ms it waited before
+calling the gesture over, so a flick stopped dead at the lift (measured in
+a shadow: 0.01 degree after the lift, against some 46 now). The motion's
+positions are accumulators in wheel px; what turns the camera is how far
+they moved (`orbit_by_px`, `zoom_by_px`), so the pitch clamp and a camera
+node's pending orbit work as before. `inertial_scroll` in config.kdl's
+`input.inertial` still switches the coast off; how long it runs is
+input.kdl's `scroll_friction`, as for every pane that coasts (the old
+`scroll_friction` field is gone). A trackpad orbit still locks to the axis
+it clearly favours, per gesture. A pinch does NOT coast — the runner keeps
+a pinch's end to itself — and stops a zoom that is coasting.
+`Viewport3D::wheel` takes the phase as an argument, so
+`a_trackpad_flick_coasts_the_viewport_after_the_lift` drives a flick
+without the runner's global.
+
 Precedence matters and is load-bearing. The press arms AFTER the viewer state's
 own press hook, so dragging a curve handle still edits it, and after the node
 hit tests, so a press on a node still moves the node. "Empty" means the scene
diff --git a/src/app.rs b/src/app.rs
index 048e869..cf371f4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -7907,12 +7907,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         // Graph-side these shape the drag-release fling and the unrouted
         // wheel fallback; the routed wheel/trackpad pan is the Graph widget's
         // ScrollMotion, tuned by cce-ui's smooth-scroll keys in input.kdl.
+        // The viewport's orbit and zoom are ScrollMotions too: they take the
+        // speed and the on/off switch from here, and how long a coast runs
+        // from input.kdl's `scroll_friction`, as the network pan does.
         self.graph_scroll_speed = speed;
         self.graph_inertial_scroll = enabled;
         self.graph_scroll_friction = friction;
         self.viewport_mut().scroll_speed = speed;
         self.viewport_mut().inertial_scroll = enabled;
-        self.viewport_mut().scroll_friction = friction;
     }
 
     pub fn update_graph_settings_from_config(&mut self) {
diff --git a/src/main.rs b/src/main.rs
index fa1ef6c..6cd8941 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2971,6 +2971,92 @@ mod tests {
         assert_eq!(proj.root.children[1].position, (4.0, 2.0));
     }
 
+    /// A trackpad flick over the viewport coasts: the orbit follows the
+    /// fingers 1:1, and after the lift — a zero delta in the FingerEnd
+    /// phase — it carries on the way it was going, slowing, and stops. The
+    /// hand-rolled coast this replaced read the lift as more motion and
+    /// blended its velocity to nothing, so the viewport never coasted.
+    /// Ctrl-scroll zoom coasts the same way; with `inertial_scroll` off in
+    /// config.kdl a lift stops dead; and a drag, or anything else that takes
+    /// the camera over, stops a coast (`reset_velocity`).
+    #[test]
+    fn a_trackpad_flick_coasts_the_viewport_after_the_lift() {
+        use crate::viewport_3d::Viewport3D;
+        use cce_ui::widget::{Input, MouseScrollDelta, Position, ScrollPhase, ScrollSettings};
+        cce_ui::widget::scroll_motion::force_scroll_settings(Some(ScrollSettings {
+            smooth: true,
+            ease_rate: 12.0,
+            kinetic: true,
+            friction: 6.0,
+        }));
+        let rect = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
+        let frame = 1.0 / 60.0;
+        let px = |x: f64, y: f64| MouseScrollDelta::PixelDelta(Position { x, y });
+        let flick = |vp: &mut Viewport3D, d: MouseScrollDelta| {
+            for _ in 0..6 {
+                vp.wheel(&d, ScrollPhase::Finger);
+                std::thread::sleep(std::time::Duration::from_millis(8));
+            }
+        };
+
+        // A sideways flick: yaw only, the axis lock keeping pitch out.
+        let mut a = Viewport3D::new();
+        let vp = a.inner_mut();
+        flick(vp, px(12.0, 0.5));
+        let at_lift = vp.rotation_y;
+        assert!(at_lift > 0.0, "the fingers turned the camera");
+        let pitch = vp.rotation_x;
+        vp.wheel(&px(0.0, 0.0), ScrollPhase::FingerEnd);
+        assert_eq!(vp.rotation_y, at_lift, "the lift itself moves nothing");
+        assert!(vp.is_coasting(), "the lift starts a coast");
+        let mut steps = Vec::new();
+        let mut last = vp.rotation_y;
+        let mut frames = 0;
+        while Input::tick(vp, frame, rect) && frames < 600 {
+            steps.push(vp.rotation_y - last);
+            last = vp.rotation_y;
+            frames += 1;
+        }
+        assert!(frames < 600, "the coast stops");
+        assert!(vp.rotation_y > at_lift + 0.05, "it carried on well past the lift: {} -> {}", at_lift, vp.rotation_y);
+        assert!(steps[0] > 0.0 && steps.windows(2).all(|w| w[1] <= w[0] + 1e-6), "the same way, slowing: {steps:?}");
+        assert_eq!(vp.rotation_x, pitch, "the locked axis stays put");
+        assert!(!vp.is_coasting());
+
+        // Ctrl-scroll zoom coasts too.
+        let mut a = Viewport3D::new();
+        let vp = a.inner_mut();
+        Input::set_modifiers(vp, true, false, false);
+        flick(vp, px(0.0, 10.0));
+        let zoom_at_lift = vp.zoom;
+        assert!(zoom_at_lift < 1.0, "the fingers zoomed in");
+        vp.wheel(&px(0.0, 0.0), ScrollPhase::FingerEnd);
+        for _ in 0..30 {
+            Input::tick(vp, frame, rect);
+        }
+        assert!(vp.zoom < zoom_at_lift * 0.95, "and it carries on zooming after the lift");
+
+        // inertial_scroll off: the lift stops the orbit dead.
+        let mut a = Viewport3D::new();
+        let vp = a.inner_mut();
+        vp.inertial_scroll = false;
+        flick(vp, px(12.0, 0.0));
+        vp.wheel(&px(0.0, 0.0), ScrollPhase::FingerEnd);
+        let stopped = vp.rotation_y;
+        assert!(!Input::tick(vp, frame, rect) && vp.rotation_y == stopped, "no coast with inertial_scroll off");
+
+        // Anything that takes the camera over stops a coast.
+        let mut a = Viewport3D::new();
+        let vp = a.inner_mut();
+        flick(vp, px(12.0, 0.0));
+        vp.wheel(&px(0.0, 0.0), ScrollPhase::FingerEnd);
+        vp.reset_velocity();
+        let stopped = vp.rotation_y;
+        assert!(!Input::tick(vp, frame, rect) && vp.rotation_y == stopped, "reset_velocity ends the coast");
+
+        cce_ui::widget::scroll_motion::force_scroll_settings(None);
+    }
+
     /// A wheel over the viewport orbits whichever camera is active AFTER the
     /// active camera has changed. The viewport widget routes its wheel by a
     /// copy of the camera name, and that copy used to be written once, at
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index b206ed6..3786219 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -37,23 +37,35 @@ pub struct Viewport3D {
     shift_pressed: bool,
     alt_pressed: bool,
 
-    // Drag & scroll state tracking
-    is_rotating: bool,
-    is_zooming: bool,
+    /// The scroll orbit as cce-ui's `ScrollMotion`, in wheel px — x turns
+    /// the yaw, y the pitch — the model the network pane's pan runs on: a
+    /// finger tracks 1:1, the lift coasts on the velocity of the finger's
+    /// own events, a wheel notch glides. The positions are accumulators;
+    /// what moves the camera is how far they moved (`orbit_by_px`).
+    orbit: ScrollMotion,
+    /// Ctrl-scroll zoom, the same way, on its y axis.
+    zoom_motion: ScrollMotion,
+    /// A trackpad orbit keeps to one axis once it clearly favours it:
+    /// 0 undecided, 1 yaw only, 2 pitch only. Decided per gesture.
     scroll_lock: u8,
-    rotate_accum_yaw: f32,
-    rotate_accum_pitch: f32,
-    zoom_accum: f32,
-    rotate_velocity_yaw: f32,
-    rotate_velocity_pitch: f32,
-    zoom_velocity: f32,
-    last_rotate_time: std::time::Instant,
-    last_zoom_time: std::time::Instant,
+    lock_accum: (f32, f32),
     pub scroll_speed: f32,
+    /// `input.inertial.inertial_scroll` in config.kdl: off, a lift stops
+    /// the orbit dead. How long a coast runs is cce-ui's `scroll_friction`
+    /// in input.kdl, as it is for every pane that coasts.
     pub inertial_scroll: bool,
-    pub scroll_friction: f32,
 }
 
+/// Radians of orbit per wheel px, and px per wheel notch (a notch turns
+/// 0.05 rad, as it always did).
+const ORBIT_RAD_PER_PX: f32 = 0.005;
+const ORBIT_PX_PER_LINE: f32 = 10.0;
+/// Log-zoom per wheel px, and px per notch (0.15 a notch).
+const ZOOM_PER_PX: f32 = 0.005;
+const ZOOM_PX_PER_LINE: f32 = 30.0;
+/// How far a trackpad orbit runs before its axis lock is decided, in px.
+const LOCK_DECIDE_PX: f32 = 0.4;
+
 impl Viewport3D {
     /// Hard pitch limit for the orbit: just short of the poles. Past ±90° the
     /// up-vector flips and the view rolls — from there orbiting reads as the
@@ -91,20 +103,12 @@ impl Viewport3D {
             ctrl_pressed: false,
             shift_pressed: false,
             alt_pressed: false,
-            is_rotating: false,
-            is_zooming: false,
+            orbit: ScrollMotion::new(),
+            zoom_motion: ScrollMotion::new(),
             scroll_lock: 0,
-            rotate_accum_yaw: 0.0,
-            rotate_accum_pitch: 0.0,
-            zoom_accum: 0.0,
-            rotate_velocity_yaw: 0.0,
-            rotate_velocity_pitch: 0.0,
-            zoom_velocity: 0.0,
-            last_rotate_time: std::time::Instant::now(),
-            last_zoom_time: std::time::Instant::now(),
+            lock_accum: (0.0, 0.0),
             scroll_speed: 1.0,
             inertial_scroll: true,
-            scroll_friction: 0.90,
         })
     }
 
@@ -118,38 +122,60 @@ impl Viewport3D {
         self
     }
 
-    pub fn with_scroll_friction(mut self, friction: f32) -> Self {
-        self.scroll_friction = friction;
-        self
-    }
-
+    /// Stop every scroll motion in flight: a coast, a notch's glide. What
+    /// takes the camera over (a drag, Frame All, a load) calls this.
     pub fn reset_velocity(&mut self) {
-        self.rotate_velocity_yaw = 0.0;
-        self.rotate_velocity_pitch = 0.0;
-        self.zoom_velocity = 0.0;
-        self.is_rotating = false;
-        self.is_zooming = false;
+        self.orbit = ScrollMotion::new();
+        self.zoom_motion = ScrollMotion::new();
         self.scroll_lock = 0;
+        self.lock_accum = (0.0, 0.0);
+    }
+
+    /// Whether a scroll is still moving the camera on its own — a coast
+    /// after the lift, or a wheel notch's glide.
+    pub fn is_coasting(&self) -> bool {
+        self.orbit.is_animating() || self.zoom_motion.is_animating()
     }
 
-    /// Trackpad pinch: direct-manipulation camera zoom. `factor` is the
-    /// scale change since the last gesture update (engine `handle_pinch`
-    /// semantics), applied 1:1 — spreading fingers 2x halves the camera
-    /// distance. Feeds the same accumulator as the ctrl-wheel zoom so the
-    /// release inertia matches.
     /// The zoom range. The top is generous because `View 1:1` on a small
     /// world unit needs the camera far out; the far plane follows it.
     pub const MAX_ZOOM: f32 = 400.0;
 
+    /// Trackpad pinch: direct-manipulation camera zoom. `factor` is the
+    /// scale change since the last gesture update (engine `handle_pinch`
+    /// semantics), applied 1:1 — spreading fingers 2x halves the camera
+    /// distance. It does not coast: the runner keeps a pinch's end to
+    /// itself. It does stop a ctrl-scroll zoom that is.
     pub fn pinch_zoom(&mut self, factor: f32) {
         if factor <= 0.0 {
             return;
         }
-        let dy = factor.ln();
-        self.zoom = (self.zoom * (-dy).exp()).clamp(0.05, Self::MAX_ZOOM);
-        self.is_zooming = true;
-        self.last_zoom_time = std::time::Instant::now();
-        self.zoom_accum += dy;
+        self.zoom_motion = ScrollMotion::new();
+        self.zoom = (self.zoom / factor).clamp(0.05, Self::MAX_ZOOM);
+    }
+
+    /// Turn the camera by a scroll's worth of wheel px: the Default Camera's
+    /// own orbit, or a camera node's pending one for `tick_frame` to write.
+    fn orbit_by_px(&mut self, dx: f32, dy: f32) {
+        let (yaw, pitch) = (dx * ORBIT_RAD_PER_PX, dy * ORBIT_RAD_PER_PX);
+        if yaw == 0.0 && pitch == 0.0 {
+            return;
+        }
+        if self.active_camera != "Default Camera" {
+            self.pending_yaw += yaw;
+            self.pending_pitch -= pitch;
+        } else {
+            self.rotation_y += yaw;
+            self.rotation_x -= pitch;
+            self.clamp_orbit_pitch();
+        }
+    }
+
+    /// Zoom by a scroll's worth of wheel px.
+    fn zoom_by_px(&mut self, dy: f32) {
+        if dy != 0.0 {
+            self.zoom = (self.zoom * (-dy * ZOOM_PER_PX).exp()).clamp(0.05, Self::MAX_ZOOM);
+        }
     }
 
     pub fn get_matrices(&self, aspect: f32, custom_camera_pos: Option<Vec3>, custom_camera_rot: Option<Vec3>, custom_pivot: Option<Vec3>) -> (Mat4, Mat4, Mat4) {
@@ -210,167 +236,99 @@ impl cce_ui::widget::Input for Viewport3D {
     }
 
     fn on_event(&mut self, event: &Event, _ectx: &mut cce_ui::widget::EventCtx) -> bool {
-        // Wheel arrives hit-gated to the pane rect (the adapter's gate replaces the old
-        // leading self.hit_test); the rotate/zoom handling is the legacy body verbatim.
+        // Wheel arrives hit-gated to the pane rect. Every wheel goes through
+        // a ScrollMotion, whose phase (published by the runner from the
+        // Wayland axis source and stop) decides what it is: a finger's
+        // motion, the finger's lift, or a notch. The lift is a zero delta
+        // and must reach `apply_px` as one — the hand-rolled coast this
+        // replaced read it as more motion and killed its own velocity on
+        // every lift, so the viewport never coasted.
         let Event::MouseWheel { delta, .. } = event else { return false };
-
-        let scale = cce_ui::scale::scale_factor();
-        if self.ctrl_pressed {
-            match delta {
-                MouseScrollDelta::LineDelta(_x, y) => {
-                    let dy = *y * 0.15 * self.scroll_speed;
-                    self.zoom *= (-dy).exp();
-                    self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
-                    self.is_zooming = false;
-                    
-                    let dt = 0.016;
-                    self.zoom_velocity = self.zoom_velocity * 0.4 + (dy / dt) * 0.6;
-                    true
-                }
-                MouseScrollDelta::PixelDelta(pos) => {
-                    let dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
-                    self.zoom *= (-dy).exp();
-                    self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
-                    self.is_zooming = true;
-                    self.last_zoom_time = std::time::Instant::now();
-                    self.zoom_accum += dy;
-                    true
-                }
-            }
-        } else {
-            match delta {
-                MouseScrollDelta::LineDelta(x, y) => {
-                    self.scroll_lock = 0;
-                    let dx = *x * 0.05 * self.scroll_speed;
-                    let dy = *y * 0.05 * self.scroll_speed;
-                    
-                    if self.active_camera != "Default Camera" {
-                        self.pending_yaw += dx;
-                        self.pending_pitch += -dy;
-                    } else {
-                        self.rotation_y += dx;
-                        self.rotation_x -= dy;
-                        self.clamp_orbit_pitch();
-                    }
-
-                    self.is_rotating = false;
-                    let dt = 0.016;
-                    self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + (dx / dt) * 0.6;
-                    self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + (-dy / dt) * 0.6;
-                    true
-                }
-                MouseScrollDelta::PixelDelta(pos) => {
-                    let mut dx = (pos.x as f32 / scale) * 0.005 * self.scroll_speed;
-                    let mut dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
-
-                    self.rotate_accum_yaw += dx;
-                    self.rotate_accum_pitch -= dy;
-                    if self.scroll_lock == 0 {
-                        if self.rotate_accum_yaw.abs() > 0.002 || self.rotate_accum_pitch.abs() > 0.002 {
-                            if self.rotate_accum_pitch.abs() > 1.2 * self.rotate_accum_yaw.abs() {
-                                self.scroll_lock = 2;
-                            } else if self.rotate_accum_yaw.abs() > 1.2 * self.rotate_accum_pitch.abs() {
-                                self.scroll_lock = 1;
-                            }
-                        }
-                    } else {
-                        if self.scroll_lock == 1 {
-                            dy = 0.0;
-                        } else {
-                            dx = 0.0;
-                        }
-                    }
-
-                    if self.active_camera != "Default Camera" {
-                        self.pending_yaw += dx;
-                        self.pending_pitch += -dy;
-                    } else {
-                        self.rotation_y += dx;
-                        self.rotation_x -= dy;
-                        self.clamp_orbit_pitch();
-                    }
-
-                    self.is_rotating = true;
-                    self.last_rotate_time = std::time::Instant::now();
-                    true
-                }
-            }
-        }
-    
+        self.wheel(delta, cce_ui::widget::scroll_motion::current_scroll_phase())
     }
 
+    /// Advance a coast or a notch's glide. True while either still moves,
+    /// which keeps the frames coming.
     fn tick(&mut self, dt: f32, _rect: cce_ui::scene::layout::Rect) -> bool {
-
-        let now = std::time::Instant::now();
-        let mut changed = false;
-
-        if self.is_rotating {
-            if now.duration_since(self.last_rotate_time).as_secs_f32() > 0.05 {
-                self.is_rotating = false;
-                self.scroll_lock = 0;
-            } else if dt > 1e-5 {
-                let vel_yaw = self.rotate_accum_yaw / dt;
-                let vel_pitch = self.rotate_accum_pitch / dt;
-                self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + vel_yaw * 0.6;
-                self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + vel_pitch * 0.6;
-            }
-            self.rotate_accum_yaw = 0.0;
-            self.rotate_accum_pitch = 0.0;
+        let free = Bounds::UNBOUNDED;
+        let (ox, oy) = (self.orbit.x.pos(), self.orbit.y.pos());
+        let mut changed = self.orbit.tick(dt, free, free);
+        if changed {
+            self.orbit_by_px(self.orbit.x.pos() - ox, self.orbit.y.pos() - oy);
+        }
+        let z = self.zoom_motion.y.pos();
+        if self.zoom_motion.tick(dt, free, free) {
+            self.zoom_by_px(self.zoom_motion.y.pos() - z);
+            changed = true;
         }
+        changed || self.is_coasting()
+    }
+}
 
-        if self.is_zooming {
-            if now.duration_since(self.last_zoom_time).as_secs_f32() > 0.05 {
-                self.is_zooming = false;
-            } else if dt > 1e-5 {
-                let vel_zoom = self.zoom_accum / dt;
-                self.zoom_velocity = self.zoom_velocity * 0.4 + vel_zoom * 0.6;
+impl Viewport3D {
+    /// One wheel event in the gesture phase `phase` (the runner's, for a
+    /// real event — see `on_event`): ctrl zooms, otherwise it orbits. A
+    /// notch is always `Wheel`, whatever the phase says.
+    pub fn wheel(&mut self, delta: &MouseScrollDelta, phase: ScrollPhase) -> bool {
+        let scale = cce_ui::scale::scale_factor().max(0.001);
+        let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
+        let phase = if discrete { ScrollPhase::Wheel } else { phase };
+        let free = Bounds::UNBOUNDED;
+        let speed = self.scroll_speed;
+        if self.ctrl_pressed {
+            let dy = match delta {
+                MouseScrollDelta::LineDelta(_x, y) => *y * ZOOM_PX_PER_LINE,
+                MouseScrollDelta::PixelDelta(pos) => pos.y as f32 / scale,
+            } * speed;
+            let before = self.zoom_motion.y.pos();
+            self.zoom_motion.apply_phase(phase, 0.0, dy, discrete, free, free);
+            if phase == ScrollPhase::FingerEnd && !self.inertial_scroll {
+                self.zoom_motion = ScrollMotion::new();
+                return true;
             }
-            self.zoom_accum = 0.0;
+            self.zoom_by_px(self.zoom_motion.y.pos() - before);
+            return true;
         }
-
-        if !self.is_rotating && (self.rotate_velocity_yaw.abs() > 0.001 || self.rotate_velocity_pitch.abs() > 0.001) {
-            if !self.inertial_scroll {
-                self.rotate_velocity_yaw = 0.0;
-                self.rotate_velocity_pitch = 0.0;
-            } else {
-                let dx = self.rotate_velocity_yaw * dt;
-                let dy = self.rotate_velocity_pitch * dt;
-                
-                if self.active_camera != "Default Camera" {
-                    self.pending_yaw += dx;
-                    self.pending_pitch += dy;
-                } else {
-                    self.rotation_y += dx;
-                    self.rotation_x += dy;
-                    self.clamp_orbit_pitch();
+        let (mut dx, mut dy) = match delta {
+            MouseScrollDelta::LineDelta(x, y) => (*x * ORBIT_PX_PER_LINE, *y * ORBIT_PX_PER_LINE),
+            MouseScrollDelta::PixelDelta(pos) => (pos.x as f32 / scale, pos.y as f32 / scale),
+        };
+        dx *= speed;
+        dy *= speed;
+        match phase {
+            ScrollPhase::Finger => {
+                // Once a trackpad orbit clearly favours one axis it keeps to
+                // it, so a vertical swipe does not wander in yaw.
+                if self.scroll_lock == 0 {
+                    self.lock_accum.0 += dx;
+                    self.lock_accum.1 += dy;
+                    let (ax, ay) = (self.lock_accum.0.abs(), self.lock_accum.1.abs());
+                    if ax > LOCK_DECIDE_PX || ay > LOCK_DECIDE_PX {
+                        if ay > 1.2 * ax {
+                            self.scroll_lock = 2;
+                        } else if ax > 1.2 * ay {
+                            self.scroll_lock = 1;
+                        }
+                    }
+                }
+                match self.scroll_lock {
+                    1 => dy = 0.0,
+                    2 => dx = 0.0,
+                    _ => {}
                 }
-
-                let decay = self.scroll_friction.powf(dt * 60.0);
-                self.rotate_velocity_yaw *= decay;
-                self.rotate_velocity_pitch *= decay;
-
-                if self.rotate_velocity_yaw.abs() < 0.01 { self.rotate_velocity_yaw = 0.0; }
-                if self.rotate_velocity_pitch.abs() < 0.01 { self.rotate_velocity_pitch = 0.0; }
-                changed = true;
             }
-        }
-
-        if !self.is_zooming && self.zoom_velocity.abs() > 0.001 {
-            if !self.inertial_scroll {
-                self.zoom_velocity = 0.0;
-            } else {
-                let d_zoom = self.zoom_velocity * dt;
-                self.zoom *= (-d_zoom).exp();
-                self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
-
-                let decay = self.scroll_friction.powf(dt * 60.0);
-                self.zoom_velocity *= decay;
-                if self.zoom_velocity.abs() < 0.01 { self.zoom_velocity = 0.0; }
-                changed = true;
+            ScrollPhase::FingerEnd | ScrollPhase::Wheel => {
+                self.scroll_lock = 0;
+                self.lock_accum = (0.0, 0.0);
             }
         }
-
-        changed
-    
+        let before = (self.orbit.x.pos(), self.orbit.y.pos());
+        self.orbit.apply_phase(phase, dx, dy, discrete, free, free);
+        if phase == ScrollPhase::FingerEnd && !self.inertial_scroll {
+            self.orbit = ScrollMotion::new();
+            return true;
+        }
+        self.orbit_by_px(self.orbit.x.pos() - before.0, self.orbit.y.pos() - before.1);
+        true
     }
 }