graphic design tool
git clone https://git.lucas.co/cce-designer.git
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;