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

commita5140f339a68fae7db930439c171c0c88c67c3ac
parentc3d5d42f52
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 00:54
fix: a detached pane window syncs, and its trackballs follow the camera

A detached parameters window opened empty and stayed as it started.
Three faults, all in the path every pane but the circular network takes:

- The window never read the sync channel at startup. State::new seeds
  the tree, camera name, pan and path from the bundled file and records
  its mtime, so the window waited for a change the file it had just been
  handed was never going to have — and opened with nothing selected.
  seed_detached_window takes the channel whole.
- The two places that ASK for an autosave named only the circular
  network, so with the parameters, spreadsheet or playbar detached
  neither window wrote the channel again. State::syncing_windows is the
  one test the requests, the poll and the exit save share.
- A load set the selection before the graph had the loaded nodes. The
  graph resolves an index to an id against the nodes it holds, so a
  selection of a node it had not seen yet was dropped.

On that channel the camera now crosses too: the main window asks for an
autosave when its trackball view moves, the file carries the camera, and
the detached window — which has no 3D canvas — works the view out from
what it reloaded (active_camera_pose, split out of the stage pass). It
trails by the autosave's debounce and the poll, a few tenths of a second.

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

 CLAUDE.md          |  24 +++++++-
 src/app.rs         | 174 ++++++++++++++++++++++++++++++++++++-----------------
 src/application.rs |  16 ++---
 src/main.rs        |  83 +++++++++++++++++++++++++
 src/project.rs     |  14 +++++
 src/window.rs      |   4 +-
 6 files changed, 244 insertions(+), 71 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9391c82..daf3a77 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,19 @@ gone.)
   so the file always carries the main window's layout.
   Note that detaching REWRITES `default_project.json` in the source tree, since that
   file is the sync channel; it is versioned, so check `git status` after testing.
+  **A detached pane window is a working satellite only since 2026-09-29.**
+  Two things were missing for every pane but the circular network. The
+  window never read the channel at startup: `State::new` seeds the tree,
+  camera name, pan and path from the bundled file and records its mtime, so
+  the window waited for a change the file it had just been handed was never
+  going to have, and a detached parameters window opened with nothing
+  selected — an empty pane (`seed_detached_window` takes the channel whole
+  now). And the two places that ASK for an autosave named only the circular
+  network, so with the parameters, spreadsheet or playbar detached neither
+  window wrote the channel again after the detach; `State::syncing_windows`
+  is the one test the requests, the poll and the exit save share.
+  `a_detached_params_window_follows_the_selection_and_the_camera` covers
+  both, and the camera that rides the same channel.
 
 ### MCP automation server
 
@@ -603,8 +616,15 @@ old text Center a float3 from the load on. Tests build parameters with
   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.
+  more frame: the ball trails an orbit by a frame, never by more. **A
+  detached parameters window follows the camera too**, over the sync
+  channel: the main window asks for an autosave when its trackball view
+  moves (`sync_trackball_view_from_camera`), the file carries the camera —
+  the active camera's name and node, the Default Camera's orbit in
+  `default_view` — and the detached window, which has no 3D canvas, works
+  the view out from what it reloaded (`active_camera_pose`, split out of
+  the stage pass for exactly this). It trails by the autosave's debounce
+  and the poll, a few tenths of a second.
 - **`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 158a9fd..9282338 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -10379,6 +10379,116 @@ 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.
+    /// The pose of the camera the viewport looks through, as
+    /// `(position, rotation in degrees, pivot)`: the active camera node's
+    /// Position, Rotation and Pivot when it lives in the current directory,
+    /// the Default Camera's fixed eye ray from the viewport's own pivot
+    /// otherwise. The Default Camera's ORBIT is not in here — it is the
+    /// viewport widget's `rotation_x` / `rotation_y`, which `get_matrices`
+    /// folds in. Split out of the stage pass so a window with no 3D canvas
+    /// (a detached pane) can ask too.
+    pub fn active_camera_pose(&self) -> (Vec3, Vec3, Vec3) {
+        // The Default Camera: the fixed eye ray from the viewport's own
+        // pivot (`Viewport3D::pivot`, the origin until Frame All moves
+        // it). Also what a NAMED camera that is not in this directory
+        // resolves to — a camera node applies where it lives.
+        let mut pivot = self.viewport().pivot;
+        let mut camera_pos = pivot + Vec3::new(2.5, 1.8, 2.5);
+        let mut rx = 0.0f32;
+        let mut ry = 0.0f32;
+        let mut rz = 0.0f32;
+        if self.active_camera != "Default Camera" {
+            if let Some(node) = self.current_dir().children.iter().find(|c| c.node_type == "camera" && c.name == self.active_camera) {
+                let mut cx = 2.5f32;
+                let mut cy = 1.8f32;
+                let mut cz = 2.5f32;
+                for p in &node.params {
+                    if p.name == "Position" {
+                        let parts: Vec<&str> = p.text()
+                            .split(|c| c == ':' || c == ',' || c == ' ')
+                            .filter(|s| !s.is_empty())
+                            .collect();
+                        if parts.len() >= 3 {
+                            if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
+                                cx = vx;
+                                cy = vy;
+                                cz = vz;
+                            }
+                        }
+                    } else if p.name == "Rotation" {
+                        let parts: Vec<&str> = p.text()
+                            .split(|c| c == ':' || c == ',' || c == ' ')
+                            .filter(|s| !s.is_empty())
+                            .collect();
+                        if parts.len() >= 3 {
+                            if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
+                                rx = vx;
+                                ry = vy;
+                                rz = vz;
+                            }
+                        }
+                    } else if p.name == "Pivot" {
+                        let parts: Vec<&str> = p.text()
+                            .split(|c| c == ':' || c == ',' || c == ' ')
+                            .filter(|s| !s.is_empty())
+                            .collect();
+                        if parts.len() >= 3 {
+                            if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
+                                pivot = Vec3::new(vx, vy, vz);
+                            }
+                        }
+                    }
+                }
+                camera_pos = Vec3::new(cx, cy, cz);
+            }
+        }
+
+        (camera_pos, Vec3::new(rx, ry, rz), pivot)
+    }
+
+    /// Whether this window shares the project with another over the sync
+    /// channel — a detached window, or the main one with a pane or the
+    /// circular network detached. The one test the autosave requests, the
+    /// poll and the exit save share: until 2026-09-29 the REQUESTS named
+    /// only the circular network, so with the parameters, spreadsheet or
+    /// playbar detached neither window ever wrote the channel again after
+    /// the detach, and the detached window showed what it started with.
+    pub fn syncing_windows(&self) -> bool {
+        self.is_detached_network
+            || self.detached_circular_network
+            || self.detached_pane.is_some()
+            || self.detached_panes.iter().any(|d| *d)
+    }
+
+    /// [`Self::sync_trackball_view`] from the camera this window knows
+    /// of, and the word to the other windows when it moved: the MAIN
+    /// window asks for an autosave of the sync channel, which carries the
+    /// camera (the active camera's name and node, the Default Camera's
+    /// orbit), so a detached parameters window's trackballs turn with the
+    /// viewport they cannot see. A detached window only reads: it has no
+    /// camera of its own to tell anyone about.
+    pub fn sync_trackball_view_from_camera(&mut self) -> bool {
+        let (position, rotation, pivot) = self.active_camera_pose();
+        let moved = self.sync_trackball_view(position, rotation, pivot);
+        if moved && self.syncing_windows() && !self.is_detached_network && self.detached_pane.is_none() {
+            self.needs_autosave = true;
+        }
+        moved
+    }
+
+    /// What a detached window does once, at startup: take the sync channel
+    /// WHOLE — the tree, the selection, the camera. `State::new` seeds the
+    /// tree, camera name, pan and path from the bundled file and records
+    /// its mtime, so without this the window waited for a change that the
+    /// file it had just been handed was never going to have, and a detached
+    /// parameters window opened with no node selected: an empty pane.
+    pub fn seed_detached_window(&mut self, channel: &std::path::Path) {
+        if let Err(e) = self.load_sync_channel(channel, false) {
+            eprintln!("Failed to read the sync channel at startup: {e:?}");
+        }
+        self.sync_trackball_view_from_camera();
+    }
+
     /// 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
@@ -10404,6 +10514,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         // 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;
+        // A detached pane has no 3D canvas to resolve a camera for, but the
+        // sync channel gave it the main window's: see the balls from that.
+        if self.detached_pane.is_some() {
+            trackball_moved = self.sync_trackball_view_from_camera();
+        }
 
         // 3D canvas: stage the scene into the renderer's backdrop when the
         // viewport is visible and its inputs changed; unstaged frames reuse the
@@ -10429,62 +10544,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             }
 
             if cw > 0 && ch > 0 {
-                // The Default Camera: the fixed eye ray from the viewport's own
-                // pivot (`Viewport3D::pivot`, the origin until Frame All moves
-                // it). Also what a NAMED camera that is not in this directory
-                // resolves to — a camera node applies where it lives.
-                let mut pivot = self.viewport().pivot;
-                let mut camera_pos = pivot + Vec3::new(2.5, 1.8, 2.5);
-                let mut rx = 0.0f32;
-                let mut ry = 0.0f32;
-                let mut rz = 0.0f32;
-                if self.active_camera != "Default Camera" {
-                    if let Some(node) = self.current_dir().children.iter().find(|c| c.node_type == "camera" && c.name == self.active_camera) {
-                        let mut cx = 2.5f32;
-                        let mut cy = 1.8f32;
-                        let mut cz = 2.5f32;
-                        for p in &node.params {
-                            if p.name == "Position" {
-                                let parts: Vec<&str> = p.text()
-                                    .split(|c| c == ':' || c == ',' || c == ' ')
-                                    .filter(|s| !s.is_empty())
-                                    .collect();
-                                if parts.len() >= 3 {
-                                    if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
-                                        cx = vx;
-                                        cy = vy;
-                                        cz = vz;
-                                    }
-                                }
-                            } else if p.name == "Rotation" {
-                                let parts: Vec<&str> = p.text()
-                                    .split(|c| c == ':' || c == ',' || c == ' ')
-                                    .filter(|s| !s.is_empty())
-                                    .collect();
-                                if parts.len() >= 3 {
-                                    if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
-                                        rx = vx;
-                                        ry = vy;
-                                        rz = vz;
-                                    }
-                                }
-                            } else if p.name == "Pivot" {
-                                let parts: Vec<&str> = p.text()
-                                    .split(|c| c == ':' || c == ',' || c == ' ')
-                                    .filter(|s| !s.is_empty())
-                                    .collect();
-                                if parts.len() >= 3 {
-                                    if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
-                                        pivot = Vec3::new(vx, vy, vz);
-                                    }
-                                }
-                            }
-                        }
-                        camera_pos = Vec3::new(cx, cy, cz);
-                    }
-                }
-
-                trackball_moved = self.sync_trackball_view(camera_pos, Vec3::new(rx, ry, rz), pivot);
+                let (camera_pos, rotation, pivot) = self.active_camera_pose();
+                let (rx, ry, rz) = (rotation.x, rotation.y, rotation.z);
+                trackball_moved = self.sync_trackball_view_from_camera();
 
                 let rt_mode = self.viewport().rt_mode;
                 let viewport_changed = self.viewport_dirty
diff --git a/src/application.rs b/src/application.rs
index 027583d..c926671 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -91,11 +91,7 @@ impl State {
     /// window wrote it. Returns true when a reload happened.
     fn poll_shared_project(&mut self) -> bool {
         let mut redraw = false;
-        let syncing = self.is_detached_network
-            || self.detached_circular_network
-            || self.detached_pane.is_some()
-            || self.detached_panes.iter().any(|d| *d);
-        if !syncing {
+        if !self.syncing_windows() {
             return false;
         }
 
@@ -139,12 +135,7 @@ impl State {
     }
 
     pub(crate) fn autosave_on_exit(&mut self) {
-        if self.needs_autosave
-            && (self.is_detached_network
-                || self.detached_circular_network
-                || self.detached_pane.is_some()
-                || self.detached_panes.iter().any(|d| *d))
-        {
+        if self.needs_autosave && self.syncing_windows() {
             let _ = self.save_to_file(&Self::default_project_path());
         }
     }
@@ -167,6 +158,9 @@ impl Application for State {
             state.detached_pane = Some(idx);
             state.rebuild_positions();
             state.apply_layout();
+            // The main window wrote the channel just before it started this
+            // one: take it whole, selection and camera included.
+            state.seed_detached_window(&Self::default_project_path());
         }
         state.event_sender = Some(sender.clone());
         // One MCP server per project: the detached windows are satellites of the
diff --git a/src/main.rs b/src/main.rs
index c7ce68b..6d89c97 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13155,4 +13155,87 @@ mod tests {
         assert!(orbited, "an orbit moves the view");
         assert!(Vec3::from(ball_view(&state)[2]).distance(toward) > 0.05);
     }
+
+    /// A detached parameters window is a working satellite: it opens on
+    /// the main window's selection and camera, the main window writes the
+    /// sync channel when its selection or its camera changes, and the
+    /// detached window's trackballs turn with a viewport it cannot see.
+    /// Until 2026-09-29 the window never read the channel at startup, so
+    /// it opened with nothing selected and an empty pane, and with a pane
+    /// other than the circular network detached neither window asked for
+    /// an autosave again.
+    #[test]
+    fn a_detached_params_window_follows_the_selection_and_the_camera() {
+        use cce_ui::widget::ParametersBg;
+        let dir = std::env::temp_dir().join(format!("cce-designer-detached-camera-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        fs::create_dir_all(&dir).unwrap();
+        let channel = dir.join("default_project.json");
+        let ball_view = |state: &State| {
+            let pane: &ParametersBg = state.slots.param.inner();
+            pane.float3s.iter().flatten().next().expect("a float3 row").view()
+        };
+        let same = |a: [[f32; 3]; 3], b: [[f32; 3]; 3]| (0..3).all(|i| (0..3).all(|k| (a[i][k] - b[i][k]).abs() < 1e-4));
+
+        let mut main = State::new(false);
+        main.resize(1600.0, 900.0, 1.0);
+        main.rebuild_positions();
+        main.apply_layout();
+        main.focused_pane = LEFT_MENUBAR_IDX;
+        main.param_editor = crate::slots::CONTENT_IDX;
+        main.set_active_camera("Default Camera");
+        let mut redraw = false;
+        main.apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: Some("pull1".into()), x: 5.0, y: 8.0 }, &mut redraw).unwrap();
+        let pull = main.current_dir().children.iter().position(|c| c.name == "pull1").unwrap();
+        for (name, value) in [("Input", "sphere1"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", "0.00:0.60:0.00")] {
+            main.apply_action(McpAction::SetParam { slot: pull, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+        }
+        main.apply_action(McpAction::Select { slot: pull }, &mut redraw).unwrap();
+        main.viewport_mut().rotation_y = 0.6;
+        main.sync_trackball_view_from_camera();
+        assert!(!main.syncing_windows() && !main.needs_autosave, "nothing detached: nothing to tell");
+
+        // Detach: the channel is written, the child is started on it.
+        main.detached_panes[crate::slots::PARAM_IDX] = true;
+        assert!(main.syncing_windows());
+        main.save_to_file(&channel).expect("the main window writes the channel");
+        let mut child = State::new(false);
+        child.detached_pane = Some(crate::slots::PARAM_IDX);
+        child.resize(640.0, 400.0, 1.0);
+        child.rebuild_positions();
+        child.apply_layout();
+        assert_eq!(child.param_mut().node_params().len(), 0, "a new state has nothing selected");
+        child.seed_detached_window(&channel);
+        assert_eq!(child.param_editor_selected(), Some(pull), "it opens on the main window's selection");
+        assert!(child.param_mut().node_params().iter().any(|r| r.0 == "Value" && r.2.ends_with(":trackball")), "with its rows");
+        assert!(same(ball_view(&child), ball_view(&main)), "and sees the ball from the main window's camera");
+        assert!(!child.needs_autosave, "a detached window has no camera to tell of");
+
+        // The main window's selection and camera each ask for an autosave…
+        let sphere = main.current_dir().children.iter().position(|c| c.name == "sphere1").unwrap();
+        main.needs_autosave = false;
+        main.apply_custom_event(crate::app::CustomEvent::RunAction(McpAction::Select { slot: sphere }));
+        assert!(main.needs_autosave, "a selection change is written for the detached window");
+        main.apply_custom_event(crate::app::CustomEvent::RunAction(McpAction::Select { slot: pull }));
+        main.needs_autosave = false;
+        let before = ball_view(&main);
+        main.orbit_camera_by(150.0, 40.0);
+        assert!(main.sync_trackball_view_from_camera(), "the orbit moved the view");
+        assert!(main.needs_autosave, "and asks for the write that carries it");
+        assert!(!same(ball_view(&main), before));
+
+        // …and the detached window, reloading what was written, follows.
+        main.save_to_file(&channel).expect("autosave");
+        assert!(!same(ball_view(&child), ball_view(&main)), "not before it reloads");
+        child.load_sync_channel(&channel, false).expect("the detached window reloads");
+        child.sync_trackball_view_from_camera();
+        assert!(same(ball_view(&child), ball_view(&main)), "the detached ball turned with the viewport");
+
+        // A detached window's own change is written too, for the main one.
+        child.needs_autosave = false;
+        child.apply_custom_event(crate::app::CustomEvent::RunAction(McpAction::SetParam { slot: pull, name: "Value".into(), value: "0.10:0.20:0.30".into() }));
+        assert!(child.needs_autosave);
+
+        let _ = fs::remove_dir_all(&dir);
+    }
 }
diff --git a/src/project.rs b/src/project.rs
index 12dc290..34f0a52 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -460,6 +460,13 @@ impl State {
             self.apply_default_view_from_project(proj.view_state.default_view);
         }
 
+        // The graph is given the loaded nodes BEFORE the selection: it
+        // resolves an index to a node's id against the nodes it holds, so a
+        // selection set first was dropped whenever the load brought a node
+        // the graph had not seen — a detached window reloading the node the
+        // main one had just added and selected showed nothing.
+        self.sync_grid_settings();
+        self.sync_nodes();
         let sel = proj.view_state.selected_node;
         self.graph_mut().set_selected_node(sel);
         if sel.is_some() {
@@ -528,6 +535,13 @@ impl State {
         self.current_path = proj.view_state.current_path;
         self.apply_default_view_from_project(proj.view_state.default_view);
 
+        // The graph is given the loaded nodes BEFORE the selection: it
+        // resolves an index to a node's id against the nodes it holds, so a
+        // selection set first was dropped whenever the load brought a node
+        // the graph had not seen — a detached window reloading the node the
+        // main one had just added and selected showed nothing.
+        self.sync_grid_settings();
+        self.sync_nodes();
         let sel = proj.view_state.selected_node;
         self.graph_mut().set_selected_node(sel);
         if sel.is_some() {
diff --git a/src/window.rs b/src/window.rs
index dc3fa2d..a31e821 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -571,7 +571,7 @@ impl State {
 
             if changed {
                 state.update_window_title();
-                if state.is_detached_network || state.detached_circular_network {
+                if state.syncing_windows() {
                     state.needs_autosave = true;
                 }
                 result = true;
@@ -604,7 +604,7 @@ impl State {
         }
         if needs_redraw {
             self.update_window_title();
-            if self.is_detached_network || self.detached_circular_network {
+            if self.syncing_windows() {
                 self.needs_autosave = true;
             }
         }