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

commit93c8a12cd1cbc9f31a8b6c3732104159af3fd30d
parent6d0e3135dc
authorLucas Galante <[email protected]>
date2026-09-19 00:48
feat(viewport): dragging empty scene orbits the camera

The camera had no drag gesture at all: Viewport3D handles only
MouseWheel, so the scene turned by scrolling and by nothing else. That
suits a trackpad, which is what this DE is built around, and leaves a
mouse with no way to look around — and it is why a left-drag on the
scene did nothing when the network overlay started letting presses reach
it.

State::orbit_camera_by turns the camera by a drag delta, armed by a left
press that cursor_in_viewport says landed on scene.
ORBIT_RADIANS_PER_PX is the trackpad's own pixel-delta constant rather
than a number picked for drags, so a drag and a two-finger swipe turn the
scene at the same rate instead of feeling like two different cameras. The
default camera carries its orbit in rotation_x/rotation_y while a NAMED
camera accumulates into pending_yaw/pending_pitch for its node to pick
up — the same split the scroll path makes, so a dragged camera and a
scrolled one mean the same thing. A drag stops when the pointer does,
unlike a flicked scroll, which coasts.

Precedence is load-bearing, and the order is: the viewer state's press
hook first, so dragging a curve handle still edits it; then the node hit
tests, so a press on a node still moves the node; then this. "Empty"
means the scene really is what is under the cursor, which with the
network overlaying the whole window is exactly what in_network_pane's
node test decides.

Verified in a shadow rather than only in tests: a 140px drag turned the
active camera node's Rotation Y by 40.1 degrees, which is 140 * 0.005
radians, and the same drag started on a node moved the node and selected
it instead.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md          | 36 +++++++++++++++++++++------
 src/app.rs         | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs        | 55 ++++++++++++++++++++++++++++++++++++++++
 src/viewport_3d.rs |  2 +-
 4 files changed, 157 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8034a94..1af162e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -491,14 +491,11 @@ That makes the pane's RECT useless as a hit test, and three things route off it:
 - **`cursor_in_viewport`** becomes the complement: the body, minus what the
   network holds, minus the floating panes.
 
-What changes for the user, and it is worth knowing: a plain click on empty
-space is no longer the network's. In the default docked layout nothing else
-claims it either (the scene is drawn full-bleed but the Viewport3D WIDGET's
-rect is only the centre column, which those layouts leave at zero width), so
-such a click does nothing. Deselecting by clicking empty space is the
-behaviour that costs. Giving the viewport that space was tried and reverted: it
-claimed the press and still did not orbit, and a half-working claim is worse
-than none.
+What changes for the user: a plain click on empty space is no longer the
+network's — so deselecting by clicking empty space is gone while the plate is
+off. It now ORBITS THE CAMERA instead (see below), which is what makes the
+overlay feel like a scene with a graph on it rather than a graph with a
+picture behind it.
 
 `ViewportSettings::network_plate` persists it, beside the viewport toggles
 rather than in the project's pane-state list: a pane's VISIBILITY belongs to
@@ -588,6 +585,29 @@ Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
 select family this app cannot implement until the Graph widget has
 multi-selection, so taking `Shift+L` now would have to be given back later.
 
+### Dragging the scene orbits the camera
+
+`State::orbit_camera_by` turns the camera by a drag delta, armed by a left
+press that `cursor_in_viewport` says landed on scene. Before it the camera had
+NO drag gesture at all: `Viewport3D` handles only `MouseWheel`, so the scene
+turned by scrolling and by nothing else — which suits a trackpad and leaves a
+mouse with no way to look around.
+
+`ORBIT_RADIANS_PER_PX` is the trackpad's own pixel-delta constant, so a drag
+and a two-finger swipe turn the scene at the same rate rather than feeling like
+two different cameras. The default camera carries its orbit in
+`rotation_x`/`rotation_y`; a NAMED camera accumulates into
+`pending_yaw`/`pending_pitch` for its node to pick up — the same split the
+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.
+
+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
+really is what is under the cursor — which, with the network overlaying the
+window, is exactly what `in_network_pane`'s node test decides.
+
 ### Commands, chords and the palette
 
 `src/command.rs` is one list of everything the app can be asked to do. Each row
diff --git a/src/app.rs b/src/app.rs
index e85ee8b..a6f0253 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1125,6 +1125,9 @@ pub struct State {
     /// first call is this process's own and every later one is a REPLACEMENT
     /// after a reconnect. Remembering is the only way to tell them apart.
     pub seen_renderer: bool,
+    /// 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)>,
     /// 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
@@ -1657,6 +1660,39 @@ impl State {
 
     pub fn param_w(&self) -> f32 { self.get_col_geometries().5 }
     
+    /// Radians of camera rotation per logical pixel of drag.
+    ///
+    /// The same constant the trackpad's pixel-delta orbit uses, so a drag and a
+    /// two-finger swipe turn the scene at the same rate and the two gestures do
+    /// not feel like different cameras. A 300px drag is about 86 degrees.
+    pub const ORBIT_RADIANS_PER_PX: f32 = 0.005;
+
+    /// Turn the camera by a drag delta in logical pixels.
+    ///
+    /// Mirrors the scroll path's split: the default camera carries its own
+    /// orbit in `rotation_x`/`rotation_y`, while a named camera accumulates
+    /// into `pending_yaw`/`pending_pitch` for the node to pick up. Doing it any
+    /// other way would give a dragged camera a different meaning from a
+    /// scrolled one.
+    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;
+        if self.active_camera != "Default Camera" {
+            let vp = self.viewport_mut();
+            vp.pending_yaw += dx;
+            vp.pending_pitch += -dy;
+        } else {
+            let vp = self.viewport_mut();
+            vp.rotation_y += dx;
+            vp.rotation_x -= dy;
+            vp.clamp_orbit_pitch();
+        }
+        // A drag is a direct gesture: the scene stops when the pointer does,
+        // rather than coasting the way a flicked scroll does.
+        self.viewport_mut().reset_velocity();
+        self.viewport_dirty = true;
+    }
+
     /// Whether the network is drawn as an OVERLAY on the scene rather than on
     /// its own plate: the plate switched off, in the ordinary docked layout.
     ///
@@ -4136,6 +4172,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             sim_cache: crate::geometry::SimCache::default(),
             page_image: None,
             seen_renderer: false,
+            orbit_drag: None,
             page_dirty: false,
             last_sim_frame: i32::MIN,
             plate_menu_slot: None,
@@ -6011,6 +6048,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     return true;
                 }
 
+                // 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.orbit_drag {
+                    let (dx, dy) = (self.cursor_x - lx, self.cursor_y - ly);
+                    self.orbit_drag = Some((self.cursor_x, self.cursor_y));
+                    if dx != 0.0 || dy != 0.0 {
+                        self.orbit_camera_by(dx, dy);
+                    }
+                    return true;
+                }
+
                 // An armed corner-dot press becomes a layout drag once it
                 // moves; stubbed (collapsed/detached) panes stay click-only.
                 if let Some((idx, px, py)) = self.corner_press {
@@ -6466,6 +6514,28 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             }
                         }
 
+                        // A left press on empty scene arms a camera orbit.
+                        // AFTER the viewer state above, so dragging a handle
+                        // still edits it, and after the node hit tests, so a
+                        // press on a node is still the node's — "empty" means
+                        // the scene really is what is under the cursor. The
+                        // camera otherwise turns only by scrolling, which is
+                        // the trackpad gesture; this is the mouse's.
+                        if *button == MouseButton::Left
+                            && self.cursor_in_viewport()
+                            && !in_circle_network_pane
+                            && self.app_drag.is_none()
+                        {
+                            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();
+                                self.focused_widget = None;
+                            }
+                            self.sync_pane_focus();
+                            return true;
+                        }
+
                         if *button == MouseButton::Right {
                             self.close_node_menu();
                             self.close_viewport_menu();
@@ -6690,6 +6760,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         self.sync_pane_focus();
                     }
                     ElementState::Released => {
+                        if self.orbit_drag.take().is_some() {
+                            return true;
+                        }
                         if self.viewer_tool_release() {
                             changed = true;
                         }
diff --git a/src/main.rs b/src/main.rs
index e07237a..763c38b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4646,6 +4646,61 @@ mod tests {
         assert!(state.page_dirty, "nothing would re-upload the page");
     }
 
+    /// Dragging empty scene turns the camera, at the same rate the trackpad's
+    /// pixel-delta orbit does.
+    ///
+    /// The camera had no drag gesture at all before this: `Viewport3D` handles
+    /// only `MouseWheel`, so the scene could be turned by scrolling and by
+    /// nothing else. The rate is shared with that path deliberately — a drag
+    /// and a two-finger swipe should not feel like different cameras.
+    #[test]
+    fn test_dragging_empty_scene_turns_the_camera() {
+        let mut state = State::new(false);
+        let k = State::ORBIT_RADIANS_PER_PX;
+
+        // The default camera carries its own orbit. X drag yaws, Y drag
+        // pitches, and the pitch is inverted so dragging down looks down.
+        state.active_camera = "Default Camera".to_string();
+        let (y0, x0) = (state.viewport().rotation_y, state.viewport().rotation_x);
+        state.orbit_camera_by(100.0, 40.0);
+        assert!(
+            (state.viewport().rotation_y - (y0 + 100.0 * k)).abs() < 1e-5,
+            "yaw did not follow the drag"
+        );
+        assert!(
+            (state.viewport().rotation_x - (x0 - 40.0 * k)).abs() < 1e-5,
+            "pitch did not follow the drag, or is not inverted"
+        );
+
+        // A named camera accumulates instead, for the camera NODE to pick up —
+        // the same split the scroll path makes.
+        let mut state = State::new(false);
+        state.active_camera = "Camera 1".to_string();
+        let before = (state.viewport().rotation_y, state.viewport().rotation_x);
+        state.orbit_camera_by(100.0, 40.0);
+        assert!(
+            (state.viewport().pending_yaw - 100.0 * k).abs() < 1e-5,
+            "a named camera's yaw did not accumulate"
+        );
+        assert!(
+            (state.viewport().pending_pitch - (-40.0 * k)).abs() < 1e-5,
+            "a named camera's pitch did not accumulate"
+        );
+        assert_eq!(
+            (state.viewport().rotation_y, state.viewport().rotation_x),
+            before,
+            "a named camera must not move the default camera's orbit"
+        );
+
+        // Pitch is clamped, so a long downward drag cannot roll the scene over.
+        let mut state = State::new(false);
+        state.active_camera = "Default Camera".to_string();
+        state.orbit_camera_by(0.0, -100000.0);
+        let pitch = state.viewport().rotation_x;
+        assert!(pitch.abs() < std::f32::consts::PI, "pitch ran past vertical: {pitch}");
+
+    }
+
     /// A page's raster is its physical size times its resolution — the
     /// property that makes DPI a page parameter rather than an export one.
     #[test]
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index a716338..240d8c1 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -63,7 +63,7 @@ impl Viewport3D {
 
     /// Clamp the default-camera scroll orbit short of the poles
     /// (total pitch = pitch0 - rotation_x).
-    fn clamp_orbit_pitch(&mut self) {
+    pub(crate) fn clamp_orbit_pitch(&mut self) {
         let p0 = Self::default_pitch0();
         self.rotation_x = self.rotation_x.clamp(p0 - Self::MAX_PITCH, p0 + Self::MAX_PITCH);
     }