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

commit03c551dc3e52624f3961450c2c551697a6e80bbb
parent81f6b9d8b1
authorLucas Galante <[email protected]>
date2026-09-21 13:54
Scene file: the viewport settings load from the file, and the Default Camera view is saved

The Render and Guides nodes were always in the saved tree, so the
wireframe state, its colour, the grid and the rest were in the file —
and lost on every load: ensure_menubar_subnets re-seeds those params
from live state (so a chord-flipped toggle shows on the node), which on
a load stamped the preferences file's values over the file's before
apply_settings_from_menubar_subnets could read them.

On load the file's node values now land on the live state FIRST, then
the nodes are re-seeded from it and read back. Main's Background Color
is mirrored from the live viewport on save the way the toggles are.

The Default Camera view — the settings with no node to live on when no
camera node is active: square aspect, the pivot marker and its size,
and the view's orbit, zoom and pivot — is saved as
view_state.default_view. Absent in older files keeps the live values;
a camera node's own square/pivot params still win when it is active in
the current directory. The preferences file still seeds a new project.

Tested: a project saved with wireframe, colour, thickness, grid,
origin, background, square aspect, pivot marker and orbit loads into a
fresh state with different live values and every one lands. Verified in
a scale-2 shadow: save, fresh launch, load — the state reads back.

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

 src/app.rs     | 19 +++++++++++++++++++
 src/main.rs    | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 122 insertions(+)

diff --git a/src/app.rs b/src/app.rs
index 6646ce6..642d890 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -284,6 +284,25 @@ pub struct ProjectViewState {
     /// older saves keeps the live geometry.
     #[serde(default)]
     pub plates: Option<PlateGeometry>,
+    /// The Default Camera view — the viewport settings that have no node
+    /// to live on when no camera node is active: the square aspect, the
+    /// camera-pivot marker and its size, and the view itself (orbit, zoom,
+    /// pivot). Absent in older saves keeps the live values. A named camera's
+    /// own params still win over these when it is active and in the
+    /// directory (`apply_settings_from_menubar_subnets`).
+    #[serde(default)]
+    pub default_view: Option<DefaultCameraView>,
+}
+
+/// See [`ProjectViewState::default_view`].
+#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
+pub struct DefaultCameraView {
+    pub square: bool,
+    pub show_pivot: bool,
+    pub pivot_size: f32,
+    pub rotation: (f32, f32),
+    pub zoom: f32,
+    pub pivot: [f32; 3],
 }
 
 /// The user-dragged plate edges of the floating layout, each as a fraction
diff --git a/src/main.rs b/src/main.rs
index 8d15be2..c7a3d27 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4127,6 +4127,60 @@ mod tests {
         assert!(state.viewport().zoom < 1.0, "a 0.25 sphere frames closer than the stock view: zoom {}", state.viewport().zoom);
     }
 
+    /// The viewport settings live in the scene file: the Render node's
+    /// wireframe state and colour, the Guides node's grid and origin, Main's
+    /// background, and the Default Camera view (square aspect, pivot marker,
+    /// orbit/zoom/pivot). A fresh State whose live values differ takes the
+    /// file's on load. Before this, `ensure_menubar_subnets` re-seeded the
+    /// nodes from live state on load and the file's values were lost.
+    #[test]
+    fn viewport_settings_round_trip_through_the_scene_file() {
+        let dir = std::env::temp_dir().join(format!("cce-designer-vp-settings-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+
+        let mut a = State::new(false);
+        a.ensure_menubar_subnets();
+        // The Default Camera is active: a camera NODE's own Square Aspect and
+        // pivot params would override the saved view's, by design.
+        a.active_camera = "Default Camera".to_string();
+        a.wireframe = true;
+        a.wire_single_color = true;
+        a.wire_color = [0.0, 0.0, 0.0, 1.0];
+        a.wire_width = 3.0;
+        a.viewport_mut().show_grid = false;
+        a.viewport_mut().show_origin = true;
+        a.viewport_mut().bg_color = [0.1, 0.2, 0.3];
+        a.square_viewport = true;
+        a.viewport_mut().show_camera_pivot = true;
+        a.viewport_mut().rotation_y = 0.7;
+        a.viewport_mut().zoom = 0.4;
+        a.viewport_mut().pivot = Vec3::new(3.0, 0.5, -2.0);
+        a.save_to_file(&dir).expect("save");
+
+        let mut b = State::new(false);
+        b.ensure_menubar_subnets();
+        assert!(!b.wireframe && !b.wire_single_color, "a fresh state starts without wires");
+        b.load_from_file(&dir).expect("load");
+        assert!(b.wireframe, "Show Wireframe loads from the file");
+        assert!(b.wire_single_color, "Wire Single Color loads from the file");
+        assert_eq!(b.wire_color, [0.0, 0.0, 0.0, 1.0]);
+        assert!((b.wire_width - 3.0).abs() < 1e-4);
+        assert!(!b.viewport().show_grid, "Show Grid loads from the file");
+        assert!(b.viewport().show_origin, "Show Origin loads from the file");
+        let bg = b.viewport().bg_color;
+        assert!((bg[0] - 0.1).abs() < 0.01 && (bg[1] - 0.2).abs() < 0.01 && (bg[2] - 0.3).abs() < 0.01, "background {bg:?}");
+        assert!(b.square_viewport, "Square Aspect loads from the file");
+        assert!(b.viewport().show_camera_pivot, "the pivot marker loads from the file");
+        assert!((b.viewport().rotation_y - 0.7).abs() < 1e-4);
+        assert!((b.viewport().zoom - 0.4).abs() < 1e-4);
+        assert_eq!(b.viewport().pivot, Vec3::new(3.0, 0.5, -2.0));
+        // And the nodes agree with the live state after the load.
+        let render = b.fs_root.children.iter().find(|c| c.node_type == "meta").unwrap()
+            .children.iter().find(|c| c.name == "Render").unwrap();
+        assert_eq!(render.params.iter().find(|p| p.name == "Show Wireframe").unwrap().default, "true");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
     /// The wireframe toggle is a palette row that flips the live flag AND
     /// the Render node's "Show Wireframe" switch. The node matters: it is
     /// what `apply_settings_from_menubar_subnets` reads back on every
diff --git a/src/project.rs b/src/project.rs
index 6293869..5434b51 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -142,9 +142,41 @@ impl State {
             params_pin: Self::pin_name(self.params_pin),
             spreadsheet_pin: Self::pin_name(self.spreadsheet_pin),
             plates,
+            default_view: Some(crate::app::DefaultCameraView {
+                square: self.square_viewport,
+                show_pivot: self.viewport().show_camera_pivot,
+                pivot_size: self.camera_pivot_size,
+                rotation: (self.viewport().rotation_x, self.viewport().rotation_y),
+                zoom: self.viewport().zoom,
+                pivot: self.viewport().pivot.to_array(),
+            }),
         }
     }
 
+    /// The saved Default Camera view onto the live state — after the
+    /// active camera and the path are known. The orbit, zoom and pivot are
+    /// the view and always restore; the square aspect, pivot marker and its
+    /// size are a camera NODE's own params when one is active in the
+    /// current directory (`apply_settings_from_menubar_subnets` reads them
+    /// off it), so those restore only for a view with no node.
+    fn apply_default_view_from_project(&mut self, view: Option<crate::app::DefaultCameraView>) {
+        let Some(v) = view else { return };
+        let active = self.active_camera.clone();
+        let has_node = active != "Default Camera"
+            && self.current_dir().children.iter().any(|c| c.node_type == "camera" && c.name == active);
+        if !has_node {
+            self.square_viewport = v.square;
+            self.camera_pivot_size = v.pivot_size;
+            self.viewport_mut().show_camera_pivot = v.show_pivot;
+        }
+        let vp = self.viewport_mut();
+        vp.rotation_x = v.rotation.0;
+        vp.rotation_y = v.rotation.1;
+        vp.zoom = v.zoom.clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
+        vp.pivot = glam::Vec3::from_array(v.pivot);
+        vp.reset_velocity();
+    }
+
     /// A pin as its saved pane name.
     fn pin_name(pin: Option<usize>) -> Option<String> {
         match pin {
@@ -341,6 +373,13 @@ impl State {
             crate::app::ensure_meta_children(&mut proj.root);
             let saved_pane_vis = Self::project_pane_visibility(&proj.root);
             self.fs_root = proj.root;
+            // The project's viewport settings — the Guides and Render
+            // nodes' values, Main's background — onto the live state FIRST:
+            // `ensure_menubar_subnets` re-seeds those params from live state
+            // (so a chord-flipped toggle shows on the node), which on a load
+            // stamped the preferences file's values over the file's and lost
+            // them before the apply below could read them (2026-09-21).
+            self.apply_settings_from_menubar_subnets();
             self.ensure_menubar_subnets();
             self.apply_settings_from_menubar_subnets();
             self.apply_pane_state_from_project(&saved_pane_vis, &proj.view_state);
@@ -352,6 +391,7 @@ impl State {
             self.last_frame_pan_x = self.pan_x;
             self.last_frame_pan_y = self.pan_y;
             self.current_path = proj.view_state.current_path;
+            self.apply_default_view_from_project(proj.view_state.default_view);
 
             let sel = proj.view_state.selected_node;
             self.graph_mut().set_selected_node(sel);
@@ -396,6 +436,9 @@ impl State {
         crate::app::ensure_meta_children(&mut proj.root);
         let saved_pane_vis = Self::project_pane_visibility(&proj.root);
         self.fs_root = proj.root;
+        // As in the default-project branch: the file's viewport settings
+        // land on the live state before ensure re-seeds the nodes from it.
+        self.apply_settings_from_menubar_subnets();
         self.ensure_menubar_subnets();
         self.apply_settings_from_menubar_subnets();
         self.apply_pane_state_from_project(&saved_pane_vis, &proj.view_state);
@@ -407,6 +450,7 @@ impl State {
         self.last_frame_pan_x = self.pan_x;
         self.last_frame_pan_y = self.pan_y;
         self.current_path = proj.view_state.current_path;
+        self.apply_default_view_from_project(proj.view_state.default_view);
 
         let sel = proj.view_state.selected_node;
         self.graph_mut().set_selected_node(sel);
@@ -733,6 +777,11 @@ impl State {
 
         ensure_param(main_node, "Ray Traced Preview", "toggle", bool_str(vp_rt_mode), &[], None, None, None);
         ensure_param(main_node, "Background Color", "color", &color_to_hex(vp_bg_color), &[], None, None, None);
+        // Mirrors the live value, as the toggles do: a background set on the
+        // viewport rather than through the node still reaches the saved tree.
+        if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Background Color") {
+            p.default = color_to_hex(vp_bg_color);
+        }
 
         // The Style section is retired — DE chrome is config-owned, not
         // per-project: the wall and edge relief curves are