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

commit3b55135a3cb1fbe6af74d17f625c0643648a6b5c
parentf925b3c846
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 21:38
feat: the camera pans

The middle button dragged, shift and the left button, and shift with a
scroll slide the pivot and the eye together across the view. What is on
the pivot's plane follows the pointer px for px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md   |  25 +++++++++++++
 src/app.rs  | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/main.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 256 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 69b7f48..f3f6ff6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2711,6 +2711,31 @@ hit tests, so a press on a node still moves the node. "Empty" means the scene
 really is what is under the cursor — which, with the network overlaying the
 window, is exactly what `in_network_pane`'s node test decides.
 
+**The camera PANS as well** (`State::pan_camera_by`, since 2026-09-29;
+until then the view only turned and zoomed about its pivot, and the
+pivot moved by Frame All alone). A pan slides the pivot and the eye
+together across the plane of the screen, so the view turns nowhere and
+what is on the PIVOT's plane follows the pointer px for px — the
+projection is a perspective, so what is nearer moves further. Three
+gestures, `pan_drag` beside `orbit_drag`:
+
+- **the middle button, dragged** over the scene. Armed after the
+  network's own pan, which the middle button is wherever the network is
+  laid out — with its plate off that is the whole window — and AHEAD of
+  the press cascade's `button != Left && != Right` return, which is where
+  the first cut put it and where it never ran;
+- **shift and the left button**, in the orbit's own arm, so a viewer
+  state's handle and a node still take the press first;
+- **shift and a scroll**, at the top of the wheel arm, a notch being
+  `PAN_PX_PER_LINE` px.
+
+The Default Camera's eye hangs off its pivot, so `Viewport3D::pivot` is
+all that moves. A camera node has its Pivot and Position rewritten, to
+four decimals; a pan is hundreds of small moves and each read back from
+the text would lose what the text could not hold, so `State::pan_exact`
+keeps what the last pan wrote in full and is used while the node still
+says it. `the_camera_pans_with_the_pointer` is the test.
+
 **The active camera's name lives in two places, and `State::set_active_camera`
 is the only writer of either.** The viewport widget keeps its own copy because
 its wheel handler routes by it — the Default Camera's orbit lands on the
diff --git a/src/app.rs b/src/app.rs
index 32a9e22..92334c1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2103,6 +2103,15 @@ pub struct State {
     /// An in-flight camera orbit drag: the cursor position the last motion was
     /// measured from. `None` when no orbit drag is running.
     pub orbit_drag: Option<(f32, f32)>,
+    /// An in-flight camera PAN drag (middle button, or shift and the left):
+    /// the cursor position the last motion was measured from.
+    pub pan_drag: Option<(f32, f32)>,
+    /// The camera node a pan last wrote, with the Pivot and Position it
+    /// wrote IN FULL. The node holds them as text, to four decimals; a pan
+    /// is hundreds of small moves, and each read back from the text would
+    /// lose what the text could not hold. Used while the node still says
+    /// what was written.
+    pub pan_exact: Option<(String, Vec3, Vec3)>,
     /// Set when the page raster must be re-uploaded — after a replacement
     /// renderer drops the old id. Consumed on the next tick rather than acted
     /// on in `renderer_init`, which runs before the frame has settled and
@@ -2876,6 +2885,9 @@ impl State {
 
     pub fn param_w(&self) -> f32 { self.get_col_geometries().5 }
     
+    /// How far a wheel notch slides the camera under shift, in logical px.
+    pub(crate) const PAN_PX_PER_LINE: f32 = 40.0;
+
     /// Radians of camera rotation per logical pixel of drag.
     ///
     /// The same constant the trackpad's pixel-delta orbit uses, so a drag and a
@@ -2899,6 +2911,7 @@ impl State {
         self.drag_widget.is_some()
             || self.app_drag.is_some()
             || self.orbit_drag.is_some()
+            || self.pan_drag.is_some()
             || self.is_panning
             || self.grid_cursor_drag.is_some()
             || self.viewer_tool.as_ref().map_or(false, |t| t.drag.is_some())
@@ -2958,6 +2971,59 @@ impl State {
         changed
     }
 
+    /// Slide the camera across its own view by a pointer delta, in logical
+    /// px: the pivot and the eye move together, in the plane of the screen,
+    /// so the view turns nowhere and the scene follows the pointer. What is
+    /// on the PIVOT's plane moves exactly as far as the pointer did — the
+    /// projection is a perspective, so what is nearer moves further and
+    /// what is beyond it less.
+    ///
+    /// The Default Camera's eye hangs off its pivot, so its pivot is all
+    /// that moves. A camera node has its Pivot and Position rewritten, as
+    /// Frame All rewrites them.
+    pub(crate) fn pan_camera_by(&mut self, dx_px: f32, dy_px: f32) {
+        let (pos, rot, pivot) = self.active_camera_pose();
+        let (_, view, _) = self.viewport().get_matrices(1.0, Some(pos), Some(rot), Some(pivot));
+        let inv = view.inverse();
+        let (right, up, eye) = (inv.x_axis.truncate(), inv.y_axis.truncate(), inv.w_axis.truncate());
+        let pane_h = self.last_scene_view_rect.3.max(1.0);
+        // World units a logical px covers on the pivot's plane: the
+        // projection's vertical field of view is 0.9 rad.
+        let per_px = 2.0 * (eye - pivot).length() * (0.45f32).tan() / pane_h;
+        let by = (up * dy_px - right * dx_px) * per_px;
+        if by.length_squared() == 0.0 || !by.is_finite() {
+            return;
+        }
+        let active = self.active_camera.clone();
+        let fmt3 = |v: Vec3| format!("{:.4}:{:.4}:{:.4}", v.x, v.y, v.z);
+        let exact = self.pan_exact.take();
+        let node = (active != "Default Camera")
+            .then(|| self.current_dir_mut().children.iter_mut().find(|c| c.node_type == "camera" && c.name == active))
+            .flatten();
+        match node {
+            Some(node) => {
+                let text = |node: &FsNode, name: &str| node.params.iter().find(|p| p.name == name).map(|p| p.text().to_string());
+                // From what the last pan wrote in full, while the node
+                // still says it; from the node otherwise.
+                let (from_pivot, from_pos) = match exact {
+                    Some((name, p, e)) if name == active && text(node, "Pivot") == Some(fmt3(p)) && text(node, "Position") == Some(fmt3(e)) => (p, e),
+                    _ => (pivot, pos),
+                };
+                let (to_pivot, to_pos) = (from_pivot + by, from_pos + by);
+                for (name, v) in [("Pivot", to_pivot), ("Position", to_pos)] {
+                    if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+                        p.set_text(fmt3(v));
+                    }
+                }
+                self.pan_exact = Some((active, to_pivot, to_pos));
+                self.sync_parameters_pane();
+            }
+            None => self.viewport_mut().pivot = pivot + by,
+        }
+        self.viewport_mut().reset_velocity();
+        self.viewport_dirty = true;
+    }
+
     pub(crate) fn orbit_camera_by(&mut self, dx_px: f32, dy_px: f32) {
         let dx = dx_px * Self::ORBIT_RADIANS_PER_PX;
         let dy = dy_px * Self::ORBIT_RADIANS_PER_PX;
@@ -6805,6 +6871,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             seen_renderer: false,
             deselected_cell: None,
             orbit_drag: None,
+            pan_drag: None,
+            pan_exact: None,
             page_dirty: false,
             last_sim_frame: i32::MIN,
             plate_menu_slot: None,
@@ -8992,6 +9060,21 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     }
                     return true;
                 }
+                // Shift and a scroll over the scene slide the camera, as the
+                // drag does: the scene follows the fingers.
+                if self.modifiers.shift_key() && !self.modifiers.control_key() && self.cursor_in_viewport() && !self.in_network_pane() {
+                    let (dx, dy) = match delta {
+                        MouseScrollDelta::PixelDelta(p) => {
+                            let s = (self.scale as f32).max(0.001);
+                            (p.x as f32 / s, p.y as f32 / s)
+                        }
+                        MouseScrollDelta::LineDelta(x, y) => (*x * Self::PAN_PX_PER_LINE, *y * Self::PAN_PX_PER_LINE),
+                    };
+                    if dx != 0.0 || dy != 0.0 {
+                        self.pan_camera_by(dx, dy);
+                    }
+                    return true;
+                }
                 let in_network_pane = self.in_network_pane();
                 // eprintln!("DEBUG MOUSEWHEEL: delta={:?}, phase={:?}, cursor=({}, {}), in_network_pane={}", delta, phase, self.cursor_x, self.cursor_y, in_network_pane);
                 let node_area_y = self.positions[CONTENT_IDX].1;
@@ -9197,6 +9280,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                 // An in-flight camera orbit, likewise — it was armed by a press
                 // on empty scene, so nothing else is competing for the motion.
+                if let Some((lx, ly)) = self.pan_drag {
+                    let (dx, dy) = (self.cursor_x - lx, self.cursor_y - ly);
+                    self.pan_drag = Some((self.cursor_x, self.cursor_y));
+                    if dx != 0.0 || dy != 0.0 {
+                        self.pan_camera_by(dx, dy);
+                    }
+                    return true;
+                }
                 if let Some((lx, ly)) = self.orbit_drag {
                     let (dx, dy) = (self.cursor_x - lx, self.cursor_y - ly);
                     self.orbit_drag = Some((self.cursor_x, self.cursor_y));
@@ -9455,6 +9546,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     return true;
                 }
 
+                // A middle press on the scene slides the camera. After the
+                // network's own pan above, which the middle button is
+                // wherever the network is laid out — with its plate off,
+                // the whole window, where shift and the left button pan
+                // the camera instead.
+                if *button == MouseButton::Middle
+                    && *btn_state == ElementState::Pressed
+                    && self.cursor_in_viewport()
+                    && !(self.circular_network_pane && self.circular_network_layout.hit_test_content(self.cursor_x, self.cursor_y, 0.0, BREADCRUMB_H))
+                    && self.app_drag.is_none()
+                {
+                    self.pan_drag = Some((self.cursor_x, self.cursor_y));
+                    self.focused_pane = RIGHT_MENUBAR_IDX;
+                    self.sync_pane_focus();
+                    return true;
+                }
+                if *button == MouseButton::Middle && *btn_state == ElementState::Released && self.pan_drag.take().is_some() {
+                    self.broadcast_pointer();
+                    return true;
+                }
+
                 if *button != MouseButton::Left && *button != MouseButton::Right { return false; }
                 let mut changed = false;
                 let old_focus = self.focused_widget;
@@ -9711,7 +9823,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             && !in_circle_network_pane
                             && self.app_drag.is_none()
                         {
-                            self.orbit_drag = Some((self.cursor_x, self.cursor_y));
+                            // With shift the drag slides the camera
+                            // where without it the drag turns it.
+                            if self.modifiers.shift_key() {
+                                self.pan_drag = Some((self.cursor_x, self.cursor_y));
+                            } else {
+                                self.orbit_drag = Some((self.cursor_x, self.cursor_y));
+                            }
                             self.focused_pane = RIGHT_MENUBAR_IDX;
                             if let Some(old) = self.focused_widget {
                                 self.slots.get_dyn_mut(old).unfocus();
@@ -10004,7 +10122,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         if self.grid_cursor_drag.take().is_some() && self.settle_cursor_expansion() {
                             changed = true;
                         }
-                        if self.orbit_drag.take().is_some() {
+                        if self.orbit_drag.take().is_some() || self.pan_drag.take().is_some() {
                             self.broadcast_pointer();
                             return true;
                         }
diff --git a/src/main.rs b/src/main.rs
index e7592d7..9cff151 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2911,6 +2911,117 @@ mod tests {
         let _ = fs::remove_dir_all(&dir);
     }
 
+    /// A pan slides the camera across its own view: what is at the pivot
+    /// follows the pointer px for px, the view turns nowhere, and a camera
+    /// node's Pivot and Position move together. Middle-drag, shift and the
+    /// left button, and shift with a scroll all pan; the plain left drag
+    /// and the plain scroll still orbit.
+    #[test]
+    fn the_camera_pans_with_the_pointer() {
+        use crate::slots::VIEWPORT_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton, MouseScrollDelta, Position};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        let rect = (0.0f32, 0.0f32, 1600.0f32, 900.0f32);
+        state.last_scene_view_rect = rect;
+        // Where a world point is on screen, in logical px.
+        let on_screen = |state: &State, at: Vec3| {
+            let (pos, rot, pivot) = state.active_camera_pose();
+            let (proj, view, model) = state.viewport().get_matrices(rect.2 / rect.3, Some(pos), Some(rot), Some(pivot));
+            let c = proj * view * model * at.extend(1.0);
+            (rect.0 + (c.x / c.w * 0.5 + 0.5) * rect.2, rect.1 + (0.5 - c.y / c.w * 0.5) * rect.3)
+        };
+        let view_of = |state: &State| {
+            let (pos, rot, pivot) = state.active_camera_pose();
+            state.viewport().get_matrices(1.0, Some(pos), Some(rot), Some(pivot)).1
+        };
+
+        // The Default Camera, turned a little first so the axes are not the world's.
+        state.set_active_camera("Default Camera");
+        state.orbit_camera_by(120.0, -40.0);
+        let mark = state.viewport().pivot;
+        let (before, turned) = (on_screen(&state, mark), view_of(&state));
+        state.pan_camera_by(50.0, -30.0);
+        let after = on_screen(&state, mark);
+        assert!((after.0 - before.0 - 50.0).abs() < 0.05 && (after.1 - before.1 + 30.0).abs() < 0.05, "{before:?} -> {after:?}");
+        assert_ne!(state.viewport().pivot, mark, "the pivot moved");
+        let (a, b) = (turned.to_cols_array(), view_of(&state).to_cols_array());
+        assert!((0..12).all(|i| (a[i] - b[i]).abs() < 1e-5), "the view turned");
+
+        // A camera node: Pivot and Position move as one, Rotation stays, and
+        // a hundred small moves come to what one large one does.
+        state.set_active_camera("camera1");
+        let read = |state: &State, name: &str| {
+            let node = state.current_dir().children.iter().find(|c| c.name == "camera1").unwrap();
+            crate::geometry::node_param_vec3(node, name, Vec3::ZERO)
+        };
+        let (pivot0, pos0, rot0) = (read(&state, "Pivot"), read(&state, "Position"), read(&state, "Rotation"));
+        let mark = pivot0;
+        let before = on_screen(&state, mark);
+        for _ in 0..100 {
+            state.pan_camera_by(0.7, 0.3);
+        }
+        let after = on_screen(&state, mark);
+        assert!((after.0 - before.0 - 70.0).abs() < 0.5 && (after.1 - before.1 - 30.0).abs() < 0.5, "{before:?} -> {after:?}");
+        let (moved_pivot, moved_pos) = (read(&state, "Pivot") - pivot0, read(&state, "Position") - pos0);
+        assert!(moved_pivot.length() > 0.01 && (moved_pivot - moved_pos).length() < 1e-3, "{moved_pivot:?} against {moved_pos:?}");
+        assert_eq!(read(&state, "Rotation"), rot0);
+
+        // The three ways of asking, by pointer.
+        state.set_active_camera("Default Camera");
+        let (vx, vy, vw, vh) = state.positions[VIEWPORT_IDX];
+        let (cx, cy) = (vx + vw * 0.5, vy + vh * 0.5);
+        let at = |state: &mut State, x: f32, y: f32| {
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        };
+        let press = |state: &mut State, b: MouseButton, s: ElementState| {
+            state.handle_event(&WindowEvent::MouseInput { state: s, button: b });
+        };
+        at(&mut state, cx, cy);
+        assert!(state.cursor_in_viewport());
+        let orbit = |state: &State| (state.viewport().rotation_x, state.viewport().rotation_y);
+
+        let (pivot, turned) = (state.viewport().pivot, orbit(&state));
+        press(&mut state, MouseButton::Middle, ElementState::Pressed);
+        assert!(state.pan_drag.is_some() && state.pointer_captured(), "a middle press arms the pan");
+        at(&mut state, cx + 40.0, cy + 10.0);
+        press(&mut state, MouseButton::Middle, ElementState::Released);
+        assert!(state.pan_drag.is_none());
+        assert_ne!(state.viewport().pivot, pivot, "the middle drag panned");
+        assert_eq!(orbit(&state), turned, "and did not turn the camera");
+
+        at(&mut state, cx, cy);
+        let pivot = state.viewport().pivot;
+        state.modifiers.shift = true;
+        press(&mut state, MouseButton::Left, ElementState::Pressed);
+        assert!(state.pan_drag.is_some() && state.orbit_drag.is_none(), "shift and the left button pan");
+        at(&mut state, cx - 25.0, cy + 5.0);
+        press(&mut state, MouseButton::Left, ElementState::Released);
+        assert_ne!(state.viewport().pivot, pivot);
+        assert_eq!(orbit(&state), turned);
+
+        let pivot = state.viewport().pivot;
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::PixelDelta(Position { x: 12.0, y: -8.0 }) });
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 1.0) });
+        assert_ne!(state.viewport().pivot, pivot, "shift and a scroll pan");
+        assert_eq!(orbit(&state), turned);
+        state.modifiers.shift = false;
+
+        // Without shift they are the orbit's, as they were.
+        at(&mut state, cx, cy);
+        let pivot = state.viewport().pivot;
+        press(&mut state, MouseButton::Left, ElementState::Pressed);
+        assert!(state.orbit_drag.is_some() && state.pan_drag.is_none());
+        at(&mut state, cx + 30.0, cy);
+        press(&mut state, MouseButton::Left, ElementState::Released);
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }) });
+        assert_eq!(state.viewport().pivot, pivot);
+        assert_ne!(orbit(&state), turned);
+    }
+
     #[test]
     fn test_default_camera_orbit_moves_camera_not_geometry() {
         use cce_ui::widget::WidgetHost;