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

commit8ca854c9ef34f903186b243b45809bf08d450536
parent6391a20df4
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-24 22:10
feat: projects save and restore their display settings

The grid and guides, wireframe, shading, opacity, points, colours and the
rest of the viewport and render settings now ride the project file as
view_state.display, and a load applies them over the live state (main
window only, before the Default Camera view). state.kdl still holds them
as the last-used look, which New and older saves without the block open
with. A display change marks the project unsaved.

This reverses the 2026-09-23 decision that display settings were
app-wide only; the scene-file test that asserted the wireframe did not
travel now asserts that it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      |  27 ++++++++++++--
 src/app.rs     | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/main.rs    |  81 +++++++++++++++++++++++++++++++++++------
 src/project.rs |  10 +++++
 4 files changed, 215 insertions(+), 16 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bbdff4e..61c4d4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -225,9 +225,11 @@ is the introspection surface.
 ### There are no meta nodes (retired 2026-09-23)
 
 Two different things were called `meta`, and both are gone. What replaced
-them is the one rule worth remembering: **a display setting belongs to the
-view, so it is a live field on `State`, persisted to `state.kdl`, and
-reached from the command palette.** Never a node.
+them is the one rule worth remembering: **a display setting is a live
+field on `State`, persisted to `state.kdl`, and reached from the command
+palette.** Never a node. (Since 2026-09-24 a project ALSO carries the
+display settings it was saved with — see "Display settings ride the
+project file" below; that is a snapshot in the view state, not a node.)
 
 **The root `meta` node (nee Session)** was a permanent, undeletable root
 subnet holding four utility subnets — `main`, `view`, `guides`, `render` —
@@ -343,6 +345,25 @@ files migrate on load. Scroll behavior (`scroll_speed`, `inertial_scroll`,
 `scroll_friction`) is intentionally absent: it is config-owned
 (`input.inertial` in config.kdl) and must not be shadowed by app state.
 
+**Display settings ride the project file too (since 2026-09-24).** Every
+save writes `ProjectViewState::display` — a `DisplaySettings`, the viewport
+and render blocks of `DesignSettings` without the startup pointer, taken by
+`State::display_settings` (which `save_settings` builds from as well) — and
+both `load_from_file` paths apply it through `apply_display_settings`, before
+the Default Camera view so a camera node's own Square Aspect and pivot
+still win. The apply sets every field, regenerates the baked meshes, relays
+the two pane-shaped ones (network plate, circular pane), re-checks the
+menubar marks, and saves state.kdl, so state.kdl holds the LAST-USED look:
+what New and an older save (no block, which changes nothing) open with.
+Main window only, as the pane state is: a detached window has no viewport
+and reloads the sync channel on every write. A display change dirties the
+project (`pane_layout_json` includes the block). `State::new` seeds only
+the tree, camera, pan and path from the bundled file, as before, so the
+suite does not read the block out of the versioned `default_project.json`.
+This reverses the 2026-09-23 position that a display preference should
+survive opening someone else's scene — the user's call;
+`a_project_keeps_its_display_settings` is the test.
+
 **The path honors `$XDG_CONFIG_HOME`**, resolved through
 `cce_ui::config::cce_config_dir()` like every other app in the workspace —
 this one hardcoded `$HOME/.config` until 2026-09-23 and was the only holdout.
diff --git a/src/app.rs b/src/app.rs
index 24b8ca8..bf35786 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -322,6 +322,28 @@ pub struct ProjectViewState {
     /// directory.
     #[serde(default)]
     pub default_view: Option<DefaultCameraView>,
+    /// The display settings the project was saved with — the grid and the
+    /// other guides, the wireframe, shading, opacity, points and colours:
+    /// everything `DesignSettings` persists but the startup pointer. A load
+    /// applies them over the live state (`State::apply_display_settings`),
+    /// so a project opens looking the way it was left. Absent in saves from
+    /// before 2026-09-24 keeps the live settings, as every block here does.
+    ///
+    /// These were deliberately app-wide from 2026-09-23 — "a display
+    /// setting belongs to the view" — and came back into the file by the
+    /// user's choice the next day. state.kdl still holds them too, as the
+    /// last-used look: what a new project and an older save open with.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub display: Option<DisplaySettings>,
+}
+
+/// The display half of `DesignSettings` — see [`ProjectViewState::display`].
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct DisplaySettings {
+    #[serde(default)]
+    pub viewport: ViewportSettings,
+    #[serde(default)]
+    pub render: RenderSettings,
 }
 
 /// See [`ProjectViewState::default_view`].
@@ -2186,6 +2208,9 @@ impl State {
             self.floating_spreadsheet_inset_left.round(),
             self.floating_spreadsheet_inset_right.round(),
         );
+        // The display settings ride the file now, so changing one is an
+        // edit the title's asterisk should show.
+        let display = serde_json::to_string(&vs.display).unwrap_or_default();
         serde_json::to_string(&(
             vs.collapsed_panes,
             splitters,
@@ -2194,6 +2219,7 @@ impl State {
             vs.params_pin,
             vs.spreadsheet_pin,
             plates,
+            display,
         ))
         .unwrap_or_default()
     }
@@ -2210,8 +2236,10 @@ impl State {
 
 
 
-    pub fn save_settings(&mut self) {
-        let settings = DesignSettings {
+    /// The live display settings — what state.kdl and a project's
+    /// `display` block both carry.
+    pub fn display_settings(&self) -> DisplaySettings {
+        DisplaySettings {
             viewport: ViewportSettings {
                 bg_color: self.viewport().bg_color,
                 square: self.square_viewport,
@@ -2245,6 +2273,14 @@ impl State {
                 group_marker_scale: self.group_marker_scale,
                 smooth_shading: self.smooth_shading,
             },
+        }
+    }
+
+    pub fn save_settings(&mut self) {
+        let display = self.display_settings();
+        let settings = DesignSettings {
+            viewport: display.viewport,
+            render: display.render,
             default_project: self.default_project_setting.clone(),
         };
         settings.save();
@@ -2256,6 +2292,79 @@ impl State {
 
 
 
+    /// Put a project's display settings onto the live state: every field
+    /// `display_settings` reads, then the regeneration the dialog's apply
+    /// runs (the viewport meshes bake sizes and colours in), the layout for
+    /// the two pane-shaped ones, the menus' checkmarks, and state.kdl — so
+    /// the last-used look follows the project that was opened.
+    ///
+    /// Main window only, like the pane state: a detached window has no
+    /// viewport, and it reloads the sync channel on every write, so letting
+    /// it apply and persist would race the main window's own state.kdl.
+    pub(crate) fn apply_display_settings(&mut self, d: &DisplaySettings) {
+        if self.is_detached_network || self.detached_pane.is_some() {
+            return;
+        }
+        let (v, r) = (&d.viewport, &d.render);
+        {
+            let vp = self.viewport_mut();
+            vp.bg_color = v.bg_color;
+            vp.show_grid = v.show_grid_enabled;
+            vp.show_cube = v.show_cube_enabled;
+            vp.show_origin = v.show_origin_enabled;
+            vp.show_camera_pivot = v.show_camera_pivot_enabled;
+            vp.grid_color = v.grid_color;
+            vp.rt_mode = v.rt_mode;
+        }
+        self.grid_color = v.grid_color;
+        self.square_viewport = v.square;
+        self.camera_pivot_size = v.camera_pivot_size;
+        self.origin_size = v.origin_size;
+        self.grid_thickness = v.grid_thickness;
+        self.network_plate = v.network_plate;
+        self.circular_network_pane = self.is_detached_network || v.circular_pane;
+        self.show_point_markers = v.show_point_markers;
+        self.show_point_numbers = v.show_point_numbers;
+        self.show_point_normals = v.show_point_normals;
+        self.point_marker_size = v.point_marker_size;
+        self.point_marker_color = v.point_marker_color;
+        if let Some(u) = cce_ui::units::Unit::parse(&v.world_unit) {
+            self.world_unit = u;
+        }
+        self.wireframe = r.wireframe;
+        self.wire_single_color = r.wire_single_color;
+        self.wire_color = r.wire_color;
+        self.wire_width = r.wire_width;
+        self.geo_opacity = r.geo_opacity;
+        self.render_points = r.render_points;
+        self.point_size = r.point_size;
+        self.point_color = r.point_color;
+        self.group_marker_scale = r.group_marker_scale;
+        self.smooth_shading = r.smooth_shading;
+
+        // The checkmarks the guide and pane toggles keep in step by hand.
+        let (sg, sc, so, cp) = {
+            let vp = self.viewport();
+            (vp.show_grid, vp.show_cube, vp.show_origin, vp.show_camera_pivot)
+        };
+        self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 0, sg);
+        self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 1, sc);
+        self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 2, so);
+        self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 3, cp);
+        let cnp = self.circular_network_pane;
+        self.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, cnp);
+
+        self.update_grid_geometry();
+        self.update_origin_geometry();
+        self.update_pivot_geometry();
+        self.update_viewport_bg_geometry();
+        self.sync_grid_settings();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.viewport_dirty = true;
+        self.save_settings();
+    }
+
     // The engine owns the renderer, so geometry changes stage CPU-side here
     // and flush to the GPU meshes in `stage_renderer`.
 
diff --git a/src/main.rs b/src/main.rs
index 62b9aa2..a1989b6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1384,6 +1384,66 @@ mod tests {
         assert_eq!(m.match_command(&plain, &Key::Named(NamedKey::ArrowDown)), Some("play_pause_reverse"));
     }
 
+    /// A project carries its display settings: saved with the grid off,
+    /// smooth shading, half opacity and a centimetre world unit, it opens
+    /// that way whatever the session had since — and a save from before the
+    /// block existed leaves the live settings alone. Changing one dirties
+    /// the project, since the file now holds it.
+    #[test]
+    fn a_project_keeps_its_display_settings() {
+        let dir = std::env::temp_dir()
+            .join(format!("cce-designer-display-{}", std::process::id()))
+            .join("look");
+        let _ = fs::remove_dir_all(&dir);
+        let mut state = State::new(false);
+        state.viewport_mut().show_grid = false;
+        state.viewport_mut().show_cube = true;
+        state.smooth_shading = true;
+        state.wireframe = true;
+        state.geo_opacity = 0.5;
+        state.grid_thickness = 0.07;
+        state.world_unit = cce_ui::units::Unit::Cm;
+        state.point_color = [0.1, 0.9, 0.2];
+        state.save_to_file(&dir).expect("save");
+        assert!(!state.has_unsaved_changes());
+
+        // The session moves on.
+        state.run_command("toggle_grid");
+        assert!(state.viewport().show_grid);
+        assert!(state.has_unsaved_changes(), "a display change is an edit to the file");
+        state.run_command("toggle_cube");
+        state.smooth_shading = false;
+        state.wireframe = false;
+        state.geo_opacity = 1.0;
+        state.grid_thickness = 0.03;
+        state.world_unit = cce_ui::units::Unit::Mm;
+        state.point_color = [1.0, 1.0, 1.0];
+
+        state.load_from_file(&dir).expect("load");
+        assert!(!state.viewport().show_grid, "the grid comes back off");
+        assert!(state.viewport().show_cube);
+        assert!(state.smooth_shading && state.wireframe);
+        assert!((state.geo_opacity - 0.5).abs() < 1e-6);
+        assert!((state.grid_thickness - 0.07).abs() < 1e-6);
+        assert_eq!(state.world_unit, cce_ui::units::Unit::Cm);
+        assert_eq!(state.point_color, [0.1, 0.9, 0.2]);
+        assert!(!state.scene_smooth_verts.is_empty(), "the scene was rebuilt smooth");
+        assert!(!state.has_unsaved_changes(), "a fresh load is clean");
+        assert_eq!(state.command_toggle_state("toggle_grid"), Some(false), "the palette's switch agrees");
+
+        // An older save has no display block: the live settings stand.
+        let state_json = dir.join("state.json");
+        let mut v: serde_json::Value = serde_json::from_str(&fs::read_to_string(&state_json).unwrap()).unwrap();
+        v["view_state"].as_object_mut().unwrap().remove("display");
+        fs::write(&state_json, serde_json::to_string(&v).unwrap()).unwrap();
+        state.run_command("toggle_grid");
+        assert!(state.viewport().show_grid);
+        state.load_from_file(&dir).expect("load an older save");
+        assert!(state.viewport().show_grid, "no block, no change");
+
+        let _ = fs::remove_dir_all(dir.parent().unwrap());
+    }
+
     /// Smooth shading bakes the raster pass's own light, so on a PLANE —
     /// where every point normal is the face normal — it gives exactly the
     /// flat shader's factor at every corner: switching modes changes how
@@ -4727,16 +4787,15 @@ mod tests {
         assert!(state.viewport().zoom < 1.0, "a 0.25 sphere frames closer than the stock view: zoom {}", state.viewport().zoom);
     }
 
-    /// What the scene file carries, and what it no longer does.
+    /// The scene file carries the Default Camera VIEW (square aspect, pivot
+    /// marker, orbit/zoom/pivot) — where you were standing in this scene —
+    /// and, since 2026-09-24, the display settings with it.
     ///
-    /// It used to carry the viewport DISPLAY settings — the Render node's
-    /// wireframe state and colour, the Guides node's grid and origin, Main's
-    /// background — because the nodes holding them rode `fs_root` into the
-    /// file. That made a preference part of the project: opening someone
-    /// else's scene reset how you looked at geometry. Those settings persist
-    /// to `state.kdl` now, and the scene file keeps what is genuinely the
-    /// project's: the Default Camera VIEW (square aspect, pivot marker,
-    /// orbit/zoom/pivot), which is where you were standing in this scene.
+    /// From 2026-09-23 to 24 the display settings were app-wide only, on the
+    /// argument that opening someone else's scene should not reset how you
+    /// look at geometry; this test asserted the wireframe did NOT travel.
+    /// The user chose the other way: a project opens looking the way it was
+    /// left. `a_project_keeps_its_display_settings` covers the whole block.
     #[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()));
@@ -4751,7 +4810,7 @@ mod tests {
         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 display setting, deliberately NOT expected to travel.
+        // A display setting, which travels with the project now.
         a.wireframe = true;
         a.save_to_file(&dir).expect("save");
 
@@ -4763,7 +4822,7 @@ mod tests {
         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));
-        assert!(!b.wireframe, "a display preference rode the project file");
+        assert!(b.wireframe, "the wireframe travels with the project");
         let _ = std::fs::remove_dir_all(&dir);
     }
 
diff --git a/src/project.rs b/src/project.rs
index c13058d..8069207 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -181,6 +181,7 @@ impl State {
                 zoom: self.viewport().zoom,
                 pivot: self.viewport().pivot.to_array(),
             }),
+            display: Some(self.display_settings()),
         }
     }
 
@@ -400,6 +401,11 @@ impl State {
             // quiet while the migration reads it.
             self.last_applied_wire_color = None;
             self.migrate_meta_settings_node();
+            // Before the default view, whose camera-node rule has the last
+            // word on the square aspect and the pivot marker.
+            if let Some(d) = &proj.view_state.display {
+                self.apply_display_settings(d);
+            }
             self.apply_pane_state_from_project(&proj.view_state);
             self.set_active_camera(proj.view_state.active_camera);
             self.pan_x = proj.view_state.pan.0;
@@ -457,6 +463,10 @@ impl State {
         // As in the default-project branch.
         self.last_applied_wire_color = None;
         self.migrate_meta_settings_node();
+        // As in the default-project branch.
+        if let Some(d) = &proj.view_state.display {
+            self.apply_display_settings(d);
+        }
         self.apply_pane_state_from_project(&proj.view_state);
         self.set_active_camera(proj.view_state.active_camera);
         self.pan_x = proj.view_state.pan.0;