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

commitec07dd7aced3839ff7657ec2e745cdedf2b2afb2
parent1a6f081f4e
authorLucas Galante <[email protected]>
date2026-09-24 09:42
fix: the viewport's wheel orbits the camera that is actually active

The viewport widget routes its wheel by its own copy of the active
camera's name: the Default Camera's orbit lands on the widget's rotation,
a camera node's accumulates into pending_yaw/pending_pitch for tick_frame
to write onto the node. That copy was written once, at construction, from
whichever project State::new loaded — the bundled one, whose active camera
is camera1 — and never again. Opening a project whose active camera was
the Default Camera (the startup default-project pointer, Open, New, the
viewport menu) left the two disagreeing: the widget parked every wheel
into the pending pair, the drain saw no camera node active and threw it
away, and trackpad scrolling in the viewport did nothing, while a drag
(which reads State's copy) still orbited.

State::set_active_camera writes both, and every site that changed the
camera goes through it. Reproduced live by injecting finger scrolls with
ccectl over a traced debug build and watching the widget accept each one
without moving the camera; the new test fails on the old code.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md      | 14 +++++++++++++
 src/app.rs     | 22 +++++++++++++++++++-
 src/main.rs    | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs |  6 +++---
 src/window.rs  |  2 +-
 5 files changed, 103 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 61e6674..bd17e2a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1175,6 +1175,20 @@ 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 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
+widget's `rotation_x`/`rotation_y`, a camera node's accumulates into
+`pending_yaw`/`pending_pitch` for `tick_frame` to write onto the node — and
+until 2026-09-24 that copy was written once, at construction, from whichever
+project `State::new` loaded. Open a project whose active camera differed (the
+startup default-project pointer, Open, New, the viewport menu) and the two
+disagreed: the widget parked every wheel into the pending pair, the drain saw
+the Default Camera active and discarded it, and trackpad scrolling in the
+viewport did nothing while a drag — which reads `State`'s copy — still orbited.
+`a_wheel_orbits_the_camera_that_is_active_after_a_change` covers the three
+paths.
+
 ### 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 c801a6d..c28e3f1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2285,6 +2285,26 @@ impl State {
 
     /// Whether the cursor sits over the 3D viewport pane — the wheel arm's
     /// routing test, shared with `handle_pinch`.
+    /// Make `name` the active camera — on the app AND on the viewport
+    /// widget, which keeps a copy of the name because its wheel handler
+    /// routes by it: the Default Camera's orbit lands on the widget's own
+    /// `rotation_x`/`rotation_y`, a camera NODE's accumulates into
+    /// `pending_yaw`/`pending_pitch` for `tick_frame` to write onto the
+    /// node. Until 2026-09-24 the widget's copy was written once, at
+    /// construction, from whichever project `State::new` loaded — so after
+    /// opening a project whose active camera differed (the startup default
+    /// project pointer, Open, New, the viewport menu), the two disagreed:
+    /// the widget parked every wheel into the pending pair, the drain saw
+    /// the Default Camera active and threw it away, and trackpad scrolling
+    /// in the viewport did nothing while a drag (which reads `State`'s
+    /// copy) still orbited. Every site that changes the camera goes
+    /// through here; nothing else writes either field.
+    pub fn set_active_camera(&mut self, name: impl Into<String>) {
+        let name = name.into();
+        self.viewport_mut().active_camera = name.clone();
+        self.active_camera = name;
+    }
+
     pub fn cursor_in_viewport(&self) -> bool {
         if self.network_overlay() {
             // The complement of the overlay: everything in the body the
@@ -4321,7 +4341,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let mut items = vec!["Default Camera".to_string()];
         items.extend(camera_nodes);
         if !items.contains(&self.active_camera) {
-            self.active_camera = "Default Camera".to_string();
+            self.set_active_camera("Default Camera");
         }
         self.menu_mut(RIGHT_MENUBAR_IDX).set_menu_items(0, &items);
         for (i, item) in items.iter().enumerate() {
diff --git a/src/main.rs b/src/main.rs
index d44e200..7b255b4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1432,6 +1432,69 @@ mod tests {
         assert_eq!(proj.root.children[1].position, (4.0, 2.0));
     }
 
+    /// 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
+    /// construction, from the bundled project (camera1) — so opening a
+    /// project whose active camera was the Default Camera left the widget
+    /// parking every wheel into the camera-node pending pair, which the
+    /// drain discarded because the app said no node was active. Scrolling
+    /// in the viewport did nothing, while a drag (which reads the app's
+    /// copy) still orbited. Every path that changes the camera is covered:
+    /// New, a saved project, and the viewport menu's own choice.
+    #[test]
+    fn a_wheel_orbits_the_camera_that_is_active_after_a_change() {
+        use crate::slots::VIEWPORT_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{MouseScrollDelta, Position};
+        let dir = std::env::temp_dir().join(format!("cce-designer-active-camera-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        assert_eq!(state.active_camera, "camera1", "the bundled project starts on its camera node");
+        assert_eq!(state.viewport().active_camera, state.active_camera);
+
+        // A project saved with the Default Camera active, opened over it.
+        state.set_active_camera("Default Camera");
+        state.save_to_file(&dir).expect("save");
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.load_from_file(&dir).expect("load");
+        assert_eq!(state.active_camera, "Default Camera");
+        assert_eq!(state.viewport().active_camera, "Default Camera", "the widget's copy follows a load");
+
+        let (vx, vy, vw, vh) = state.positions[VIEWPORT_IDX];
+        let (cx, cy) = (vx + vw * 0.5, vy + vh * 0.5);
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: cx as f64, y: cy as f64 } });
+        assert!(state.cursor_in_viewport());
+        let before = (state.viewport().rotation_x, state.viewport().rotation_y);
+        // A trackpad's pixel delta, then a mouse notch: both routes.
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }) });
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 1.0) });
+        let after = (state.viewport().rotation_x, state.viewport().rotation_y);
+        assert_ne!(before, after, "the wheel orbits the Default Camera once it is the active one");
+        assert_eq!(state.viewport().pending_yaw, 0.0, "nothing was parked for a camera node");
+        assert_eq!(state.viewport().pending_pitch, 0.0);
+
+        // New: back to the Default Camera from a node, on both copies.
+        let mut state = State::new(false);
+        state.new_project();
+        assert_eq!(state.viewport().active_camera, "Default Camera");
+
+        // The viewport menu's choice: a node, then the default again.
+        let mut state = State::new(false);
+        state.set_active_camera("Default Camera");
+        state.set_active_camera("camera1");
+        assert_eq!(state.viewport().active_camera, "camera1");
+
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     #[test]
     fn test_default_camera_orbit_moves_camera_not_geometry() {
         use cce_ui::widget::WidgetHost;
@@ -10056,3 +10119,4 @@ mod tests {
         assert!(label_right(&state, long) <= px + pw - padding + 0.5);
     }
 }
+
diff --git a/src/project.rs b/src/project.rs
index 261c060..d4b5a42 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -398,7 +398,7 @@ impl State {
             self.last_applied_wire_color = None;
             self.migrate_meta_settings_node();
             self.apply_pane_state_from_project(&proj.view_state);
-            self.active_camera = proj.view_state.active_camera;
+            self.set_active_camera(proj.view_state.active_camera);
             self.pan_x = proj.view_state.pan.0;
             self.pan_y = proj.view_state.pan.1;
             self.pan_velocity_x = 0.0;
@@ -454,7 +454,7 @@ impl State {
         self.last_applied_wire_color = None;
         self.migrate_meta_settings_node();
         self.apply_pane_state_from_project(&proj.view_state);
-        self.active_camera = proj.view_state.active_camera;
+        self.set_active_camera(proj.view_state.active_camera);
         self.pan_x = proj.view_state.pan.0;
         self.pan_y = proj.view_state.pan.1;
         self.pan_velocity_x = 0.0;
@@ -551,7 +551,7 @@ impl State {
             outputs: 0,
         };
         self.migrate_meta_settings_node();
-        self.active_camera = "Default Camera".to_string();
+        self.set_active_camera("Default Camera");
         self.pan_x = 0.0;
         self.pan_y = 0.0;
         self.pan_velocity_x = 0.0;
diff --git a/src/window.rs b/src/window.rs
index c60a13c..5ac11b8 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -385,7 +385,7 @@ impl State {
                     let mut items = vec!["Default Camera".to_string()];
                     items.extend(camera_nodes);
                     if item_idx < items.len() {
-                        state.active_camera = items[item_idx].clone();
+                        state.set_active_camera(items[item_idx].clone());
                         let active_cam = state.active_camera.clone();
                         for (i, item) in items.iter().enumerate() {
                             state.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(0, i, item == &active_cam);