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