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

commitfbb83d9e4915bd4b8395ddfcf55ab0cd64d04ac9
parent3f31a1d470
authorLucas Galante <[email protected]>
date2026-08-25 10:54
feat: Session becomes the root meta node; Guides gains Point Marker Size

The session-wide settings container is now typed and named 'meta' — the
root network's counterpart of every node's per-node meta child, still a
subnet holding Main/View/Guides/Render. ensure_menubar_subnets retypes an
old save's 'session' container in place (children and params intact) on
load; the delete gates, in_settings_dir, the settings reader, and the MCP
delete refusal all accept meta (keeping legacy session for pre-migration
robustness). ensure_meta_on already skips meta-typed nodes, so the
settings tree stays free of nested per-node metas by construction.

Guides gains 'Point Marker Size' (spinbox, thousandths of a world unit,
default 20 = 0.02): it drives the per-node meta Point Markers overlay
through State::meta_marker_size, decoupled from the Render node's Point
Size, and a change re-collects the overlays immediately.

 CLAUDE.md      | 29 +++++++++++++++++------------
 src/app.rs     | 10 +++++++---
 src/main.rs    | 54 +++++++++++++++++++++++++++++++++++++++++-------------
 src/project.rs | 41 +++++++++++++++++++++++++++++++----------
 src/render.rs  |  2 +-
 src/window.rs  |  7 +++++--
 6 files changed, 102 insertions(+), 41 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c9eba88..ecb0a4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,18 +162,23 @@ The `zcce_inspector_v1` integration (window-position tracking + widget-state
 streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
 is the introspection surface.
 
-### The Session node
-
-Session-wide settings live under one permanent root node: `Session` (node type
-`session`) contains the Main/View/Guides/Render utility subnets that used to
-sit flat in `/`. `ensure_menubar_subnets` creates it and MIGRATES root-level
-settings nodes from older saves into it (moved, not recreated — params
-survive). It cannot be deleted: `delete_node` refuses the `session` type (the
-one gate every deletion route funnels through), the context menu omits Delete,
-and the graph draws it without a geometry toggle. `State::session_node()` /
-`in_settings_dir()` are the accessors — the latter walks the whole
-`current_path`, since a first-segment check stopped working the day the
-settings nodes gained a parent.
+### The root meta node (nee Session)
+
+Session-wide settings live under one permanent root node: `meta` (node type
+`meta` — retyped/renamed from the old `Session`/`session` on load, children
+intact) contains the Main/View/Guides/Render utility subnets that used to
+sit flat in `/`. It is the root network's counterpart of every node's
+per-node `meta` child, but still a subnet. `ensure_menubar_subnets` creates
+it and MIGRATES older saves into it (root-level settings nodes moved, not
+recreated — params survive; a `session`-typed container retypes in place).
+It cannot be deleted: `delete_node` refuses the `meta` (and legacy
+`session`) type — the one gate every deletion route funnels through — the
+context menu omits Delete, and the graph draws it without a geometry
+toggle. `State::session_node()` / `in_settings_dir()` are the accessors —
+the latter walks the whole `current_path`, since a first-segment check
+stopped working the day the settings nodes gained a parent. Guides holds
+"Point Marker Size" (thousandths of a world unit), driving the per-node
+meta Point Markers overlay via `State::meta_marker_size`.
 
 ### The meta node (per-node preferences)
 
diff --git a/src/app.rs b/src/app.rs
index c37cc1b..0dfaba8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1083,6 +1083,9 @@ pub struct State {
     /// triangles, drawn as a wire pass over the scene fill.
     pub meta_wire_verts: Vec<Vertex3D>,
     pub meta_wire_count: u32,
+    /// World-unit radius of the meta "Point Markers" overlay — the Guides
+    /// subnet's "Point Marker Size" control (stored there in thousandths).
+    pub meta_marker_size: f32,
     /// The raster scene's model-view-projection and the viewport pane rect in
     /// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
     pub last_scene_mvp: Option<Mat4>,
@@ -1553,11 +1556,11 @@ impl State {
     /// settings nodes (Main/View/Guides/Render). `ensure_menubar_subnets`
     /// guarantees it exists, so `None` only before the first ensure.
     pub fn session_node(&self) -> Option<&FsNode> {
-        self.fs_root.children.iter().find(|c| c.node_type == "session")
+        self.fs_root.children.iter().find(|c| c.node_type == "meta")
     }
 
     pub fn session_node_mut(&mut self) -> Option<&mut FsNode> {
-        self.fs_root.children.iter_mut().find(|c| c.node_type == "session")
+        self.fs_root.children.iter_mut().find(|c| c.node_type == "meta")
     }
 
     /// Is the network currently inside a settings directory (the Session node
@@ -1569,7 +1572,7 @@ impl State {
         for &idx in &self.current_path {
             match node.children.get(idx) {
                 Some(child) => {
-                    if matches!(child.node_type.as_str(), "utility" | "session") {
+                    if matches!(child.node_type.as_str(), "utility" | "session" | "meta") {
                         return true;
                     }
                     node = child;
@@ -3262,6 +3265,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             meta_number_labels: Vec::new(),
             meta_wire_verts: Vec::new(),
             meta_wire_count: 0,
+            meta_marker_size: 0.02,
             last_scene_mvp: None,
             last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
             last_viewport_rt_mode: false,
diff --git a/src/main.rs b/src/main.rs
index db73fbc..6b52605 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -463,25 +463,26 @@ mod tests {
         }
     }
 
-    /// The settings nodes live inside the permanent Session node now; tests
-    /// that need Main resolve it through there.
+    /// The settings nodes live inside the permanent root meta node (nee
+    /// Session) now; tests that need Main resolve it through there.
     fn session_and_main(state: &State) -> (usize, usize) {
-        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node");
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta node");
         let m_idx = state.fs_root.children[s_idx].children.iter().position(|c| c.name == "Main").expect("Main inside Session");
         (s_idx, m_idx)
     }
 
-    /// The Session node: exists at root, typed "session", holds exactly the
-    /// four settings nodes, and refuses deletion through the one gate every
-    /// deletion route funnels into.
+    /// The root meta node (nee Session): exists at root, typed "meta" but
+    /// still a subnet, holds exactly the four settings nodes, and refuses
+    /// deletion through the one gate every deletion route funnels into.
     #[test]
     fn test_session_node_exists_and_cannot_be_deleted() {
         let mut state = State::new(false);
         state.ensure_menubar_subnets();
 
-        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node at root");
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta node");
         let session = &state.fs_root.children[s_idx];
-        assert_eq!(session.name, "Session");
+        assert_eq!(session.name, "meta");
+        assert!(session.is_enterable(), "the root meta stays a subnet");
         let names: Vec<&str> = session.children.iter().map(|c| c.name.as_str()).collect();
         for expected in ["Main", "View", "Guides", "Render"] {
             assert!(names.contains(&expected), "Session is missing {expected}: {names:?}");
@@ -495,9 +496,36 @@ mod tests {
         }
 
         let before = state.fs_root.children.len();
-        assert!(!state.delete_node(s_idx), "delete_node deleted the Session node");
-        assert_eq!(state.fs_root.children.len(), before, "Session vanished anyway");
-        assert!(state.fs_root.children[s_idx].node_type == "session");
+        assert!(!state.delete_node(s_idx), "delete_node deleted the root meta node");
+        assert_eq!(state.fs_root.children.len(), before, "root meta vanished anyway");
+        assert!(state.fs_root.children[s_idx].node_type == "meta");
+
+        // An old save's "session"-typed container retypes to meta in place,
+        // children intact.
+        state.fs_root.children[s_idx].node_type = "session".to_string();
+        state.fs_root.children[s_idx].name = "Session".to_string();
+        state.ensure_menubar_subnets();
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta")
+            .expect("session retyped to meta");
+        assert_eq!(state.fs_root.children[s_idx].name, "meta");
+        let names: Vec<&str> =
+            state.fs_root.children[s_idx].children.iter().map(|c| c.name.as_str()).collect();
+        for expected in ["Main", "View", "Guides", "Render"] {
+            assert!(names.contains(&expected), "retype lost {expected}: {names:?}");
+        }
+
+        // Guides carries the Point Marker Size control (thousandths), and
+        // applying the settings drives the overlay size.
+        {
+            let guides = state.fs_root.children[s_idx].children.iter_mut()
+                .find(|c| c.name == "Guides").unwrap();
+            let p = guides.params.iter_mut().find(|p| p.name == "Point Marker Size")
+                .expect("Guides has Point Marker Size");
+            assert_eq!(p.default, "20", "default = 0.02 world units");
+            p.default = "50".to_string();
+        }
+        state.apply_settings_from_menubar_subnets();
+        assert!((state.meta_marker_size - 0.05).abs() < 1e-6);
     }
 
     /// An old save carries Main/View/Guides/Render at the root with the user's
@@ -513,7 +541,7 @@ mod tests {
         // synced toggles are rewritten from app state by design, so only a
         // foreign param can distinguish MOVED (probe survives) from RECREATED
         // (probe gone).
-        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").unwrap();
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").unwrap();
         let mut session = state.fs_root.children.remove(s_idx);
         for mut child in session.children.drain(..) {
             if child.name == "Guides" {
@@ -532,7 +560,7 @@ mod tests {
         }
 
         state.ensure_menubar_subnets();
-        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session recreated");
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta recreated");
         let guides = state.fs_root.children[s_idx].children.iter().find(|c| c.name == "Guides").expect("Guides migrated in");
         let v = guides.params.iter().find(|p| p.name == "migration probe").map(|p| p.default.as_str());
         assert_eq!(v, Some("survived"), "migration recreated Guides instead of moving it");
diff --git a/src/project.rs b/src/project.rs
index 973173d..5453216 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -401,11 +401,13 @@ impl State {
         // Retain only the Main utility subnet, removing the rest
         self.fs_root.children.retain(|c| c.name != "Network" && c.name != "Viewport" && c.name != "Parameters" && c.name != "Spreadsheet");
 
-        // The Session node: the permanent root container for the session-wide
-        // settings nodes (Main/View/Guides/Render). Older saves carried the
-        // four at the root — they are MOVED in, params intact, so an old
-        // project's values survive as the seeds. The node itself is
-        // undeletable (delete_node refuses the "session" type).
+        // The root meta node: the permanent root container for the
+        // session-wide settings nodes (Main/View/Guides/Render) — the root
+        // network's counterpart of every node's per-node `meta` child, and
+        // still a subnet. It began life as the "Session" node; older saves
+        // carry it typed "session" (or the four settings nodes flat at the
+        // root) and are migrated — retyped/renamed, params intact. The node
+        // itself is undeletable (delete_node refuses the "meta" type).
         let mut migrated: Vec<FsNode> = Vec::new();
         {
             let mut idx = 0;
@@ -420,16 +422,22 @@ impl State {
                 }
             }
         }
-        let session_idx = match self.fs_root.children.iter().position(|c| c.node_type == "session" || c.name == "Session") {
+        let session_idx = match self
+            .fs_root
+            .children
+            .iter()
+            .position(|c| matches!(c.node_type.as_str(), "session" | "meta") || c.name == "Session")
+        {
             Some(i) => {
-                self.fs_root.children[i].node_type = "session".to_string();
+                self.fs_root.children[i].node_type = "meta".to_string();
+                self.fs_root.children[i].name = "meta".to_string();
                 i
             }
             None => {
                 self.fs_root.children.push(FsNode {
                     id: crate::app::generate_node_id(),
-                    name: "Session".to_string(),
-                    node_type: "session".to_string(),
+                    name: "meta".to_string(),
+                    node_type: "meta".to_string(),
                     children: vec![],
                     params: vec![],
                     geometry_visible: true,
@@ -679,6 +687,10 @@ impl State {
         ensure_param(guides_node, "Origin Guide Size", "spinbox", &origin_size_seed, &[], Some(1.0), Some(50.0), Some(1.0));
         let grid_color_seed = migrated_grid_color.unwrap_or_else(|| color_to_hex(vp_grid_color));
         ensure_param(guides_node, "Grid Color", "color", &grid_color_seed, &[], None, None, None);
+        // Size of the per-node meta "Point Markers" overlay, in thousandths
+        // (the Grid Thickness convention): 20 = 0.02 world units.
+        let marker_size_seed = ((self.meta_marker_size * 1000.0).round() as i32).to_string();
+        ensure_param(guides_node, "Point Marker Size", "spinbox", &marker_size_seed, &[], Some(5.0), Some(100.0), Some(1.0));
         for p in guides_node.params.iter_mut() {
             match p.name.as_str() {
                 "Show Grid Guide" => set_toggle(p, vp_show_grid),
@@ -808,7 +820,7 @@ impl State {
         let session_params = |root: &FsNode, name: &str| -> Option<Vec<ParamDef>> {
             root.children
                 .iter()
-                .find(|c| c.node_type == "session")
+                .find(|c| c.node_type == "meta")
                 .and_then(|s| s.children.iter().find(|c| c.name == name))
                 .map(|n| n.params.clone())
         };
@@ -821,6 +833,15 @@ impl State {
                     "Grid Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.grid_thickness = val / 1000.0; }
                     "Origin Guide Size" => if let Ok(val) = p.default.parse::<f32>() { self.origin_size = val / 10.0; }
                     "Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().grid_color = col; }
+                    "Point Marker Size" => if let Ok(val) = p.default.parse::<f32>() {
+                        let size = val / 1000.0;
+                        if (size - self.meta_marker_size).abs() > 1e-6 {
+                            self.meta_marker_size = size;
+                            // The marker geometry bakes the radius in, so a
+                            // size change re-collects the overlays.
+                            self.rebuild_scene_geometry();
+                        }
+                    }
                     _ => {}
                 }
             }
diff --git a/src/render.rs b/src/render.rs
index c0d5669..6e4c13b 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -858,7 +858,7 @@ impl State {
         let mut sim_cache = std::mem::take(&mut self.sim_cache);
         let (markers, labels, wires) = {
             let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
-            collect_meta_overlays(&self.fs_root, self.point_size, &mut sim)
+            collect_meta_overlays(&self.fs_root, self.meta_marker_size, &mut sim)
         };
         self.sim_cache = sim_cache;
         self.meta_marker_verts = markers;
diff --git a/src/window.rs b/src/window.rs
index 4b5f157..9e6954c 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -813,9 +813,12 @@ impl State {
             }
             McpAction::DeleteNode { slot } => {
                 if slot < state.current_dir().children.len()
-                    && state.current_dir().children[slot].node_type == "session"
+                    && matches!(
+                        state.current_dir().children[slot].node_type.as_str(),
+                        "session" | "meta"
+                    )
                 {
-                    return Err("The Session node is permanent and cannot be deleted".to_string());
+                    return Err("Meta nodes are permanent and cannot be deleted".to_string());
                 }
                 if state.delete_node(slot) {
                     // delete_node clears/shifts the selection; the param pane