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

commitc3d5d42f5267701aaa44f59c862ffd3da8d51f54
parentbbfd7f43c0
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 00:43
feat: the trackball follows the viewport camera

The params pane's trackballs are seen from the camera the viewport is
looking through: the stage pass, once it has resolved the active
camera's pose, hands the view matrix's rotation to the pane as the
camera's right, up and toward axes. The vector on the ball lies as the
pull arrows do in the viewport beside it, and rolling the ball to the
right swings the node's vector to the right of the screen. The numbers
stay the scene's. The pane may already be painted when the stage pass
runs, so a moved view asks for one more frame: the ball trails an orbit
by a frame, never by more.

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

 CLAUDE.md   | 11 ++++++++-
 src/app.rs  | 38 ++++++++++++++++++++++++++-----
 src/main.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 116 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7040a01..9391c82 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -595,7 +595,16 @@ old text Center a float3 from the load on. Tests build parameters with
   instance has not chosen, and it is serialized only when set, so a file
   that never used it is byte-identical. The row menu reads such a row as
   `Control: trackball and sliders`, `Type: float3`. With the ball on the
-  row writes three decimals.
+  row writes three decimals. **The ball is seen from the viewport's
+  camera** (`State::sync_trackball_view`, called from the stage pass once
+  the active camera's pose is resolved): the view matrix's rotation goes
+  to the pane as the camera's right, up and toward axes, so the vector on
+  the ball lies as the pull arrows do in the viewport beside it, and
+  rolling the ball right swings the vector to the right of the SCREEN. The
+  numbers stay the scene's. The pane may already be painted when the
+  stage pass runs, so a moved view returns true from `stage_frame` for one
+  more frame: the ball trails an orbit by a frame, never by more. A
+  detached params window has no viewport and keeps the identity view.
 - **`float` is a number with no range.** The pane's slider and float3 rows
   hold a FRACTION of their range and clamp to it, so a threshold, a scale
   factor or a manual ramp end cannot be a slider without losing values
diff --git a/src/app.rs b/src/app.rs
index 76b03a5..158a9fd 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -10379,9 +10379,31 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// the 3D scene / RT pane. Returns true while the path tracer is still
     /// refining, to keep frames coming. The renderer's window-corner clip is
     /// left at the engine default (0) — the compositor rounds the window.
+    /// See the params pane's trackballs from the camera the viewport is
+    /// looking through: the view matrix's rotation, as the camera's right,
+    /// its up and the direction toward it, in the scene's space — which is
+    /// the space a node's vector is in, the model matrix being the
+    /// identity. The vector on the ball then lies as the pull arrows do in
+    /// the viewport beside it, and rolling the ball to the right swings
+    /// the vector to the right of the screen. Returns whether the view
+    /// moved, so the stage pass can ask for the frame that shows it.
+    pub fn sync_trackball_view(&mut self, camera_pos: Vec3, rotation: Vec3, pivot: Vec3) -> bool {
+        let (_, view, model) = self.viewport().get_matrices(1.0, Some(camera_pos), Some(rotation), Some(pivot));
+        let m = view * model;
+        // Row i of the matrix is the view's axis i, as a direction of the
+        // scene: x right, y up, z back toward the camera (a right-handed
+        // view looks down its own -Z).
+        let row = |i: usize| [m.x_axis[i], m.y_axis[i], m.z_axis[i]];
+        self.slots.param.inner_mut().set_trackball_view([row(0), row(1), row(2)])
+    }
+
     pub fn stage_frame(&mut self, renderer: &mut cce_ui::vk::VkRenderer) -> bool {
         self.flush_pending_meshes(renderer);
         let meshes = self.meshes.expect("stage_frame before renderer_init");
+        // Whether the trackballs' view moved this pass. The pane may have
+        // been painted for this frame already, so a moved view asks for one
+        // more — the ball trails the camera by a frame, never by more.
+        let mut trackball_moved = false;
 
         // 3D canvas: stage the scene into the renderer's backdrop when the
         // viewport is visible and its inputs changed; unstaged frames reuse the
@@ -10462,6 +10484,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     }
                 }
 
+                trackball_moved = self.sync_trackball_view(camera_pos, Vec3::new(rx, ry, rz), pivot);
+
                 let rt_mode = self.viewport().rt_mode;
                 let viewport_changed = self.viewport_dirty
                     || self.last_viewport_rt_mode != rt_mode
@@ -10678,12 +10702,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
 
         // The engine draws the frame; keep frames coming while the path
-        // tracer is still refining.
-        !self.is_detached_network
-            && self.detached_pane.is_none()
-            && self.show_viewport
-            && self.viewport().rt_mode
-            && renderer.rt_accumulating()
+        // tracer is still refining, and for the one that shows a trackball
+        // its new view.
+        trackball_moved
+            || (!self.is_detached_network
+                && self.detached_pane.is_none()
+                && self.show_viewport
+                && self.viewport().rt_mode
+                && renderer.rt_accumulating())
     }
 }
 
diff --git a/src/main.rs b/src/main.rs
index c985d2f..c7ce68b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13081,4 +13081,78 @@ mod tests {
         assert!(w[1] > 0.01, "a notch down turns it toward +Y: {w:?}");
         assert!((len(&w) - 0.06).abs() < 2e-3);
     }
+
+    /// The trackball is seen from the viewport's camera: the direction
+    /// from the scene toward the camera is the ball's toward-the-viewer
+    /// axis, the scene's up stays up on the ball, and orbiting the camera
+    /// moves the view. A vector pointing at the camera faces the viewer on
+    /// the ball, and rolling the ball to the right swings the node's
+    /// vector to the right of the SCREEN.
+    #[test]
+    fn the_trackball_follows_the_viewport_camera() {
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton, ParametersBg};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.param_editor = crate::slots::CONTENT_IDX;
+        let mut redraw = false;
+        state.apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: Some("pull1".into()), x: 5.0, y: 8.0 }, &mut redraw).unwrap();
+        let pull = state.current_dir().children.iter().position(|c| c.name == "pull1").unwrap();
+        // A pull of length 0.6 straight at a camera out along (2.5, 1.8, 2.5).
+        let eye = Vec3::new(2.5, 1.8, 2.5);
+        let at_camera = eye.normalize() * 0.6;
+        let text = format!("{:.4}:{:.4}:{:.4}", at_camera.x, at_camera.y, at_camera.z);
+        for (name, value) in [("Input", "sphere1"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", text.as_str())] {
+            state.apply_action(McpAction::SetParam { slot: pull, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+        }
+        state.graph_mut().set_selected_node(Some(pull));
+        state.sync_parameters_pane();
+        state.rebuild_positions();
+        state.apply_layout();
+
+        assert!(state.sync_trackball_view(eye, Vec3::ZERO, Vec3::ZERO), "the view moved off the identity");
+        assert!(!state.sync_trackball_view(eye, Vec3::ZERO, Vec3::ZERO), "the same camera again moves nothing");
+        let ball_view = |state: &State| {
+            let pane: &ParametersBg = state.slots.param.inner();
+            pane.float3s.iter().flatten().next().expect("the Value row").view()
+        };
+        let view = ball_view(&state);
+        let (right, up, toward) = (Vec3::from(view[0]), Vec3::from(view[1]), Vec3::from(view[2]));
+        assert!(toward.distance(eye.normalize()) < 1e-4, "toward the viewer is toward the camera: {toward:?}");
+        assert!(up.y > 0.5, "the scene's up is up on the ball: {up:?}");
+        assert!(right.dot(toward).abs() < 1e-4 && right.cross(up).distance(toward) < 1e-4, "a right-handed view");
+
+        // Roll the ball a quarter turn right: the pull, which pointed at
+        // the camera, now points along the camera's right.
+        let (cx, cy, r) = {
+            let pane: &ParametersBg = state.slots.param.inner();
+            pane.float3s.iter().flatten().next().unwrap().ball_circle().unwrap()
+        };
+        let at = |x: f32, y: f32| WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } };
+        state.handle_event(&at(cx, cy));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        for i in 1..=20 {
+            state.handle_event(&at(cx + r * std::f32::consts::FRAC_PI_2 * i as f32 / 20.0, cy));
+        }
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        let v: Vec<f32> = state.current_dir().children[pull].params.iter().find(|p| p.name == "Value").unwrap()
+            .text().split(':').map(|c| c.parse().unwrap()).collect();
+        let v = Vec3::new(v[0], v[1], v[2]);
+        assert!(v.distance(right * 0.6) < 5e-3, "the pull lies along screen right: {v:?} against {:?}", right * 0.6);
+
+        // Orbiting the camera moves the ball's view with it.
+        state.orbit_camera_by(120.0, 0.0);
+        let orbited = if state.active_camera == "Default Camera" {
+            state.sync_trackball_view(eye, Vec3::ZERO, Vec3::ZERO)
+        } else {
+            state.set_active_camera("Default Camera");
+            state.orbit_camera_by(120.0, 0.0);
+            state.sync_trackball_view(eye, Vec3::ZERO, Vec3::ZERO)
+        };
+        assert!(orbited, "an orbit moves the view");
+        assert!(Vec3::from(ball_view(&state)[2]).distance(toward) > 0.05);
+    }
 }