siplot 0.3.0

silx-style scientific plotting for egui, rendered with wgpu
Documentation
//! Pure camera-interaction helpers — the orbit/pan drag state machines from
//! silx `silx.gui.plot3d.scene.interaction`, ported off Qt so they are unit
//! testable. The egui [`crate::widget::scene_widget::SceneWidget`] drives these
//! from pointer events; zoom and depth-extent adjustment live on
//! [`crate::core::scene3d::camera::Camera`] (they need its projection internals).
//!
//! Window coordinates are pixels with the origin at the top-left (egui's
//! convention, matching silx's `winY origin top`).

use crate::core::scene3d::camera::{Camera, CameraExtrinsic};
use crate::core::scene3d::mat4::{Vec3, mat4_rotate};

/// Window pixel → normalized device coordinates in `[-1, 1]`, origin centre, x
/// right, y up. Port of `Viewport.windowToNdc` (no viewport origin offset: the
/// widget passes coordinates already relative to the scene rect).
pub fn window_to_ndc(win: (f32, f32), size: (f32, f32)) -> (f32, f32) {
    let (x, y) = win;
    let (w, h) = size;
    (2.0 * x / w - 1.0, 1.0 - 2.0 * y / h)
}

/// Arcball-like camera rotation. Port of `CameraSelectRotate`: a drag rotates
/// the view direction (and orbits the position around a fixed centre) by an
/// angle proportional to the drag distance over the smaller viewport dimension.
#[derive(Clone, Copy, Debug)]
pub struct OrbitDrag {
    /// Window pixel where the drag began.
    origin: (f32, f32),
    /// Camera pose captured at drag start (the rotation is always relative to it,
    /// so the motion is absolute to the press point — no per-frame drift).
    start: CameraExtrinsic,
    /// Fixed centre of rotation (scene-space).
    center: Vec3,
}

impl OrbitDrag {
    /// Begin an orbit at window pixel `origin`, rotating around `center`.
    pub fn begin(camera: &Camera, origin: (f32, f32), center: Vec3) -> Self {
        OrbitDrag {
            origin,
            start: camera.extrinsic,
            center,
        }
    }

    /// Apply the rotation for the current cursor `win` (viewport `size` in
    /// pixels), relative to the captured start pose.
    pub fn update(&self, camera: &mut Camera, win: (f32, f32), size: (f32, f32)) {
        let dx = self.origin.0 - win.0;
        let dy = self.origin.1 - win.1;

        let (direction, up, position) = if dx == 0.0 && dy == 0.0 {
            (
                self.start.direction(),
                self.start.up(),
                self.start.position(),
            )
        } else {
            let minsize = size.0.min(size.1);
            let distance = (dx * dx + dy * dy).sqrt();
            let angle = distance / minsize * std::f32::consts::PI;

            // Drag vector in the image plane (note y inversion via -up).
            let drag = (self.start.side() * dx - self.start.up() * dy).normalized();
            let axis = drag.cross(self.start.direction()).normalized();

            let rotation = mat4_rotate(angle, axis.x, axis.y, axis.z);
            let direction = rotation.transform_dir(self.start.direction());
            let up = rotation.transform_dir(self.start.up());
            // Orbit position around centre: T(c)·R·T(-c) applied to start pos.
            let position =
                self.center + rotation.transform_dir(self.start.position() - self.center);
            (direction, up, position)
        };

        camera.extrinsic.set_orientation(Some(direction), Some(up));
        camera.extrinsic.set_position(position);
    }
}

/// Camera panning. Port of `CameraSelectPan`: a drag translates the camera so
/// the scene point on a fixed depth plane stays under the cursor. silx picks the
/// plane from the GPU depth at the cursor; with no picking yet, the widget uses
/// the scene-centre depth, so the centre plane tracks the cursor 1:1.
#[derive(Clone, Copy, Debug)]
pub struct PanDrag {
    /// Last cursor position as NDC `(x, y, z)`; `z` is the fixed pan-plane depth.
    last: Vec3,
}

impl PanDrag {
    /// Begin a pan at window pixel `win`, with the pan plane at `center`'s depth.
    pub fn begin(camera: &Camera, win: (f32, f32), size: (f32, f32), center: Vec3) -> Self {
        let plane_z = camera.matrix().transform_point(center, true).z;
        let (nx, ny) = window_to_ndc(win, size);
        PanDrag {
            last: Vec3::new(nx, ny, plane_z),
        }
    }

    /// Apply the pan translation for the current cursor `win`.
    pub fn update(&mut self, camera: &mut Camera, win: (f32, f32), size: (f32, f32)) {
        let (nx, ny) = window_to_ndc(win, size);
        let cur = Vec3::new(nx, ny, self.last.z);
        camera.pan(self.last, cur);
        self.last = cur;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::scene3d::camera::Camera;

    fn approx(a: f32, b: f32, eps: f32) {
        assert!((a - b).abs() < eps, "{a} != {b}");
    }

    fn test_camera() -> Camera {
        // Perspective at (0,0,5) looking down -z, up +y; square viewport.
        Camera::new(
            30.0,
            0.1,
            100.0,
            (300.0, 300.0),
            Vec3::new(0.0, 0.0, 5.0),
            Vec3::new(0.0, 0.0, -1.0),
            Vec3::new(0.0, 1.0, 0.0),
        )
    }

    #[test]
    fn window_to_ndc_maps_centre_and_corners() {
        let size = (200.0, 100.0);
        let (cx, cy) = window_to_ndc((100.0, 50.0), size);
        approx(cx, 0.0, 1e-6);
        approx(cy, 0.0, 1e-6);
        // Top-left pixel → (-1, +1); bottom-right → (+1, -1).
        let (lx, ly) = window_to_ndc((0.0, 0.0), size);
        approx(lx, -1.0, 1e-6);
        approx(ly, 1.0, 1e-6);
        let (rx, ry) = window_to_ndc((200.0, 100.0), size);
        approx(rx, 1.0, 1e-6);
        approx(ry, -1.0, 1e-6);
    }

    #[test]
    fn orbit_preserves_radius_and_keeps_looking_at_centre() {
        let mut camera = test_camera();
        let size = (300.0, 300.0);
        let center = Vec3::ZERO;
        let start_radius = camera.extrinsic.position().length();

        // Horizontal drag from centre, a quarter-width to the right.
        let drag = OrbitDrag::begin(&camera, (150.0, 150.0), center);
        drag.update(&mut camera, (225.0, 150.0), size);

        let pos = camera.extrinsic.position();
        let dir = camera.extrinsic.direction();
        // Radius to the centre of rotation is preserved.
        approx((pos - center).length(), start_radius, 1e-3);
        // The camera still looks at the centre: pos + dir*radius ≈ centre.
        let looked_at = pos + dir * start_radius;
        approx(looked_at.x, center.x, 1e-2);
        approx(looked_at.y, center.y, 1e-2);
        approx(looked_at.z, center.z, 1e-2);
        // The rotation actually happened (not the identity branch).
        assert!(pos.x.abs() > 0.1, "horizontal orbit should move x: {pos:?}");
        // Basis stays orthonormal.
        approx(dir.length(), 1.0, 1e-4);
        approx(camera.extrinsic.up().length(), 1.0, 1e-4);
    }

    #[test]
    fn orbit_zero_drag_is_identity() {
        let mut camera = test_camera();
        let before = camera.extrinsic.position();
        let drag = OrbitDrag::begin(&camera, (150.0, 150.0), Vec3::ZERO);
        drag.update(&mut camera, (150.0, 150.0), (300.0, 300.0));
        let after = camera.extrinsic.position();
        approx(after.x, before.x, 1e-6);
        approx(after.y, before.y, 1e-6);
        approx(after.z, before.z, 1e-6);
    }

    #[test]
    fn pan_keeps_grabbed_point_under_the_cursor() {
        let mut camera = test_camera();
        let size = (300.0, 300.0);
        let center = Vec3::ZERO;

        // Pan plane depth = centre's NDC z.
        let plane_z = camera.matrix().transform_point(center, true).z;
        let a = (120.0, 160.0);
        let b = (180.0, 130.0);
        let (nax, nay) = window_to_ndc(a, size);
        let (nbx, nby) = window_to_ndc(b, size);

        // The scene point under cursor A on the pan plane, before the drag.
        let inv0 = camera.matrix().inverse().expect("invertible");
        let p_a = inv0.transform_point(Vec3::new(nax, nay, plane_z), true);

        let mut pan = PanDrag::begin(&camera, a, size, center);
        pan.update(&mut camera, b, size);

        // After the pan, P_A must project to cursor B (defining pan property).
        let projected = camera.matrix().transform_point(p_a, true);
        approx(projected.x, nbx, 1e-3);
        approx(projected.y, nby, 1e-3);
    }
}