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

commit17c3237bc7b8bfb6009e13b4ddc06ec86bc925d6
parentcab92b8398
authorLucas Galante <[email protected]>
date2026-09-21 15:22
feat: node names are lowercase, as Houdini's are

`sanitize_node_name` lowercases as well as stripping whitespace, so minting
gives `sphere1` and `camera1`, and the load-time migration brings older
saves along. The app's own nodes follow the same rule — the root meta
node's utility subnets are `main`, `view`, `guides` and `render`, and every
lookup names them so — because a path convention with exceptions is two
conventions. The template merge matches an instance to its template
case-insensitively. The versioned project files carry the new names.

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

 CLAUDE.md            | 29 +++++++++-------
 default_project.json |  6 ++--
 project.json         |  6 ++--
 src/app.rs           | 35 +++++++++++--------
 src/dialog.rs        | 20 +++++------
 src/main.rs          | 98 ++++++++++++++++++++++++++--------------------------
 src/project.rs       | 18 +++++-----
 src/window.rs        |  4 +--
 8 files changed, 114 insertions(+), 102 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8903272..88a4b2a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -234,7 +234,7 @@ is the introspection surface.
 
 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
+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
@@ -916,18 +916,23 @@ templates (Sphere/Plane/Extrude) refresh their children's `Code` outright —
 values.** A kernel hand-edited inside a template instance reverts on load;
 custom kernels belong in bare OpenCL nodes, which the merge never touches.
 Native nodes match their template by type, subnet instances by name
-("Sphere3" → "Sphere") plus a full child name/type match; the merge never
+("sphere3" → "Sphere", case-insensitively) plus a full child name/type match; the merge never
 injects or deletes children and never rewrites files on disk.
 
-### Node names carry no whitespace
-
-A node's name is a segment of its path — `/Sphere1/opencl1` is how the
-breadcrumb, the MCP tools and every `Input` wire name it — so names do not
-carry spaces (since 2026-09-21). `sanitize_node_name` (src/app.rs) is the
-rule: the conventional space between a template name and its index goes
-("Sphere 1" → "Sphere1", which is also what minting now produces), any
-other whitespace becomes an underscore ("My Region" → "My_Region"), and
-empty comes back as `node`. It runs at every entry point — minting, the
+### Node names are lowercase and carry no whitespace
+
+A node's name is a segment of its path — `/sphere1/opencl1` is how the
+breadcrumb, the MCP tools and every `Input` wire name it — so names are
+lowercase, as Houdini's are, and carry no spaces (since 2026-09-21).
+`sanitize_node_name` (src/app.rs) is the rule: the conventional space
+between a template name and its index goes ("Sphere 1" → "sphere1", which
+is also what minting now produces), any other whitespace becomes an
+underscore ("My Region" → "my_region"), the whole thing is lowercased, and
+empty comes back as `node`. The app's OWN nodes follow it — the root meta
+node's utility subnets are `main`, `view`, `guides` and `render`, and every
+lookup names them so — because a path convention with exceptions is two
+conventions. The template merge matches an instance to its template
+case-insensitively ("sphere3" → "Sphere"). It runs at every entry point — minting, the
 `add_node` name override, `rename_node` — and as a LOAD-TIME MIGRATION on
 every load path, `Project::sanitize_node_names`, called before the template
 merge in all five places a project is deserialized (the two `load_from_file`
@@ -939,7 +944,7 @@ value was one of the old names (`Input`, `With`, `Rest`, `Target`, `Source`,
 `Collider` — any of them, since it matches values rather than a list), and
 maps the view state's active camera, the one reference outside the tree. A
 sanitized name that lands on a sibling's ("Sphere 1" beside a hand-named
-"Sphere1") steps aside with a `_2` suffix rather than leaving two nodes one
+"sphere1") steps aside with a `_2` suffix rather than leaving two nodes one
 name and every wire to them ambiguous.
 
 ## Repo hygiene
diff --git a/default_project.json b/default_project.json
index 8a34ce7..eac2d71 100644
--- a/default_project.json
+++ b/default_project.json
@@ -5,7 +5,7 @@
     "type": "node",
     "children": [
       {
-        "name": "Camera1",
+        "name": "camera1",
         "type": "camera",
         "children": [],
         "params": [
@@ -46,7 +46,7 @@
         ]
       },
       {
-        "name": "Sphere1",
+        "name": "sphere1",
         "type": "node",
         "position": [
           4.0,
@@ -137,7 +137,7 @@
     ]
   },
   "view_state": {
-    "active_camera": "Camera1",
+    "active_camera": "camera1",
     "pan": [
       0.0,
       0.0
diff --git a/project.json b/project.json
index 41346d8..c89250c 100644
--- a/project.json
+++ b/project.json
@@ -5,7 +5,7 @@
     "type": "node",
     "children": [
       {
-        "name": "Camera1",
+        "name": "camera1",
         "type": "camera",
         "children": [],
         "params": [
@@ -46,7 +46,7 @@
         ]
       },
       {
-        "name": "Sphere1",
+        "name": "sphere1",
         "type": "node",
         "position": [
           4.0,
@@ -137,7 +137,7 @@
     ]
   },
   "view_state": {
-    "active_camera": "Camera1",
+    "active_camera": "camera1",
     "pan": [
       0.0,
       122.9747
diff --git a/src/app.rs b/src/app.rs
index 4e0cdd2..e85e250 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -562,24 +562,28 @@ pub fn meta_pref(node: &FsNode, name: &str) -> bool {
 ///
 /// Matching is conservative: native nodes (group, attribute, scatter, …)
 /// match their template by node type exactly; subnet instances match by name
-/// ("Sphere3" → "Sphere" — and "Sphere_3", should someone type one — so a
+/// ("sphere3" → "Sphere" — and "sphere_3", should someone type one — so a
 /// renamed instance simply keeps its saved
 /// shape), and only merge when EVERY template child is present by name and
 /// type — a hand-built subnet that happens to share the name is left alone,
 /// and nothing is ever injected or deleted. Simnet children (the user's sim
 /// chain) are out of scope by construction: simnet is a native type.
-/// A node name as this app will keep it: no whitespace.
+/// A node name as this app will keep it: lowercase, no whitespace.
 ///
-/// A node's name is a segment of its path — `/Sphere1/opencl1` is how the
+/// A node's name is a segment of its path — `/sphere1/opencl1` is how the
 /// breadcrumb, the MCP tools and every `Input` wire name it — and a path
 /// with spaces in it is a path that has to be quoted everywhere it goes.
 /// So names do not carry them. The one space that was CONVENTIONAL, the one
 /// between a template's name and its index ("Sphere 1"), simply goes, so a
 /// migrated save reads like a fresh one; any other whitespace becomes an
-/// underscore, so "My Region" keeps its two words. Empty comes back as
+/// underscore, so "My Region" keeps its two words. And the whole thing is
+/// lowercased, as Houdini names its nodes (`sphere1`, `camera1`) — the
+/// app's own utility nodes included (`/meta/guides`), since a path
+/// convention with exceptions is two conventions. Empty comes back as
 /// `node`, since a node with no name has no path at all.
 pub fn sanitize_node_name(name: &str) -> String {
-    let trimmed = name.trim();
+    let lowered = name.to_lowercase();
+    let trimmed = lowered.trim();
     // "Sphere 1" → "Sphere1": drop the whitespace between a base and a
     // trailing run of digits.
     let digits = trimmed.trim_end_matches(|c: char| c.is_ascii_digit());
@@ -689,9 +693,12 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
                 .name
                 .trim_end_matches(|c: char| c.is_ascii_digit())
                 .trim_end_matches(|c: char| c == '_' || c.is_whitespace());
+            // Case-insensitively: the template is "Sphere", its instances
+            // are "sphere1".
             templates.iter().map(|t| &t.node).find(|t| {
                 t.node_type.eq_ignore_ascii_case("node")
-                    && (t.name == node.name || (!base.is_empty() && t.name == base))
+                    && (t.name.eq_ignore_ascii_case(&node.name)
+                        || (!base.is_empty() && t.name.eq_ignore_ascii_case(base)))
             })
         } else {
             templates.iter().map(|t| &t.node).find(|t| {
@@ -1646,7 +1653,7 @@ impl State {
 
         let main_node = self
             .session_node_mut()
-            .and_then(|s| s.children.iter_mut().find(|c| c.name == "Main"));
+            .and_then(|s| s.children.iter_mut().find(|c| c.name == "main"));
         if let Some(main_node) = main_node {
             if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Open") {
                 p.options = opts;
@@ -2906,14 +2913,14 @@ impl State {
     /// so a command that changed the live state leaves the node agreeing
     /// with it. Nothing when the project has no Render node yet.
     pub(crate) fn write_render_toggle(&mut self, name: &str, val: bool) {
-        self.write_meta_toggle("Render", name, val);
+        self.write_meta_toggle("render", name, val);
     }
 
     /// The Guides node's counterpart: what Show Grid, Show Cube and Show
     /// Origin write, for the same reason Show Wireframe writes the Render
     /// node — see [`State::write_meta_toggle`].
     pub(crate) fn write_guides_toggle(&mut self, name: &str, val: bool) {
-        self.write_meta_toggle("Guides", name, val);
+        self.write_meta_toggle("guides", name, val);
     }
 
     /// Write a toggle's value onto the utility subnet that OWNS it.
@@ -2964,10 +2971,10 @@ impl State {
         let dir = self.param_editor_dir_mut();
         let Some(child) = dir.children.get_mut(slot_idx) else { return };
         let live: &[(&str, bool)] = match child.name.as_str() {
-            "Main" => &live_main,
-            "View" => &live_view,
-            "Guides" => &live_guides,
-            "Render" => &live_render,
+            "main" => &live_main,
+            "view" => &live_view,
+            "guides" => &live_guides,
+            "render" => &live_render,
             _ => return,
         };
         for &(name, on) in live {
@@ -5839,7 +5846,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // The Main node owns this one, as Guides owns the guide
                 // toggles above: without the write, the next parameter edit
                 // put the pane back the way the node said.
-                self.write_meta_toggle("Main", "Circular Pane", val);
+                self.write_meta_toggle("main", "Circular Pane", val);
                 self.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
                 self.rebuild_positions();
                 self.apply_layout();
diff --git a/src/dialog.rs b/src/dialog.rs
index b35d870..ff2d113 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -903,28 +903,28 @@ impl Setting {
 /// table.
 pub const SETTINGS: &[Setting] = &[
     Setting::section("Viewport"),
-    Setting::row("Background Color", Owner::Subnet("Main", "Background Color")),
+    Setting::row("Background Color", Owner::Subnet("main", "Background Color")),
     Setting::row("Square Aspect", Owner::Command("toggle_square_viewport")),
     Setting::section("Wireframe"),
-    Setting::row("Wireframe Color", Owner::Subnet("Render", "Wire Color")),
+    Setting::row("Wireframe Color", Owner::Subnet("render", "Wire Color")),
     // The colour applies only in single-colour mode (off, the wires carry
     // the geometry's vertex colours and the colour row sets their alpha
     // alone) — so the switch sits beside the colour, or a colour set here
     // looks ignored.
-    Setting::row("Wireframe Single Color", Owner::Subnet("Render", "Wire Single Color")),
+    Setting::row("Wireframe Single Color", Owner::Subnet("render", "Wire Single Color")),
     Setting::section("Grid"),
-    Setting::row("Show Grid", Owner::Subnet("Guides", "Show Grid Guide")),
-    Setting::row("Grid Color", Owner::Subnet("Guides", "Grid Color")),
-    Setting::row("Grid Thickness", Owner::Subnet("Guides", "Grid Thickness")),
+    Setting::row("Show Grid", Owner::Subnet("guides", "Show Grid Guide")),
+    Setting::row("Grid Color", Owner::Subnet("guides", "Grid Color")),
+    Setting::row("Grid Thickness", Owner::Subnet("guides", "Grid Thickness")),
     Setting::section("Guides"),
-    Setting::row("Show Origin Axes", Owner::Subnet("Guides", "Show Origin Axes")),
-    Setting::row("Origin Size", Owner::Subnet("Guides", "Origin Guide Size")),
-    Setting::row("Show Reference Cube", Owner::Subnet("Guides", "Show Reference Cube")),
+    Setting::row("Show Origin Axes", Owner::Subnet("guides", "Show Origin Axes")),
+    Setting::row("Origin Size", Owner::Subnet("guides", "Origin Guide Size")),
+    Setting::row("Show Reference Cube", Owner::Subnet("guides", "Show Reference Cube")),
     Setting::section("Camera"),
     Setting::row("Show Camera Pivot", Owner::Command("toggle_camera_pivot")),
     Setting::row("Camera Pivot Size", Owner::ActiveCamera("Camera Pivot Size")),
     Setting::section("Network"),
-    Setting::row("Show Network Plate", Owner::Subnet("View", "Show Network Plate")),
+    Setting::row("Show Network Plate", Owner::Subnet("view", "Show Network Plate")),
 ];
 
 fn setting_by_label(label: &str) -> Option<&'static Setting> {
diff --git a/src/main.rs b/src/main.rs
index 0e9288c..c92ba68 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -473,29 +473,29 @@ mod tests {
     // ----- Node names carry no spaces -----
 
     /// A node's name is a path segment, so the conventional "Sphere 1"
-    /// becomes "Sphere1" and any other whitespace an underscore.
+    /// becomes "sphere1" and any other whitespace an underscore, all lowercase.
     #[test]
     fn node_names_are_sanitized_of_whitespace() {
         use crate::app::sanitize_node_name;
-        assert_eq!(sanitize_node_name("Sphere 1"), "Sphere1");
-        assert_eq!(sanitize_node_name("Camera 12"), "Camera12");
-        assert_eq!(sanitize_node_name("Sphere1"), "Sphere1");
-        assert_eq!(sanitize_node_name("My Region"), "My_Region");
-        assert_eq!(sanitize_node_name("  My   Region 2 "), "My_Region2");
+        assert_eq!(sanitize_node_name("Sphere 1"), "sphere1");
+        assert_eq!(sanitize_node_name("Camera 12"), "camera12");
+        assert_eq!(sanitize_node_name("Sphere1"), "sphere1");
+        assert_eq!(sanitize_node_name("My Region"), "my_region");
+        assert_eq!(sanitize_node_name("  My   Region 2 "), "my_region2");
         assert_eq!(sanitize_node_name("mold\tshell"), "mold_shell");
         assert_eq!(sanitize_node_name(""), "node");
         assert_eq!(sanitize_node_name("   "), "node");
 
         // Minting and both MCP entry points go through it.
         let mut state = State::new(false);
-        assert_eq!(state.get_lowest_unused_name("Sphere"), "Sphere2", "Sphere1 is taken by the default project");
+        assert_eq!(state.get_lowest_unused_name("Sphere"), "sphere2", "sphere1 is taken by the default project");
         let mut redraw = false;
         state.apply_action(crate::app::McpAction::AddNode { template_name: "Plane".into(), name: Some("my plane".into()), x: 5.0, y: 5.0 }, &mut redraw).unwrap();
         let slot = state.current_dir().children.iter().position(|c| c.name == "my_plane").expect("the added node, sanitized");
         state.apply_action(crate::app::McpAction::RenameNode { slot, new_name: "flat one 3".into() }, &mut redraw).unwrap();
         assert_eq!(state.current_dir().children[slot].name, "flat_one3");
         state.apply_action(crate::app::McpAction::AddNode { template_name: "Plane".into(), name: None, x: 6.0, y: 6.0 }, &mut redraw).unwrap();
-        assert!(state.current_dir().children.iter().any(|c| c.name == "Plane1"), "a minted name has no space");
+        assert!(state.current_dir().children.iter().any(|c| c.name == "plane1"), "a minted name is lowercase with no space");
     }
 
     /// Loading an older save renames its nodes and follows every reference:
@@ -507,8 +507,8 @@ mod tests {
         let mut proj: Project = serde_json::from_str(&content).unwrap();
         // Age the file: put the spaces back, add a consumer wired to the
         // sphere by its old name, and a sibling already holding the new one.
-        let sphere = proj.root.children.iter().position(|c| c.name == "Sphere1").unwrap();
-        let camera = proj.root.children.iter().position(|c| c.name == "Camera1").unwrap();
+        let sphere = proj.root.children.iter().position(|c| c.name == "sphere1").unwrap();
+        let camera = proj.root.children.iter().position(|c| c.name == "camera1").unwrap();
         proj.root.children[sphere].name = "Sphere 1".into();
         proj.root.children[camera].name = "Camera 1".into();
         proj.view_state.active_camera = "Camera 1".into();
@@ -520,7 +520,7 @@ mod tests {
         group.params = vec![ParamDef { name: "Input".into(), label: "Input".into(), param_type: "text".into(), default: "Sphere 1".into(), options: vec![], min: None, max: None, step: None, show_when: String::new() }];
         let mut clash = group.clone();
         clash.id = "c".into();
-        clash.name = "Sphere1".into();
+        clash.name = "sphere1".into();
         clash.params[0].default = "Camera 1".into();
         proj.root.children.push(group);
         proj.root.children.push(clash);
@@ -528,16 +528,16 @@ mod tests {
         proj.sanitize_node_names();
 
         let names: Vec<&str> = proj.root.children.iter().map(|c| c.name.as_str()).collect();
-        assert!(names.contains(&"Camera1"));
-        assert!(names.contains(&"My_Region"));
-        assert!(names.contains(&"Sphere1"), "the hand-named sibling keeps its name");
-        assert!(names.contains(&"Sphere1_2"), "the migrated sphere steps aside from it: {names:?}");
+        assert!(names.contains(&"camera1"));
+        assert!(names.contains(&"my_region"));
+        assert!(names.contains(&"sphere1"), "the hand-named sibling keeps its name");
+        assert!(names.contains(&"sphere1_2"), "the migrated sphere steps aside from it: {names:?}");
         let by_name = |n: &str| proj.root.children.iter().find(|c| c.name == n).unwrap();
-        assert_eq!(by_name("My_Region").params[0].default, "Sphere1_2", "the wire followed the rename");
-        assert_eq!(by_name("Sphere1").params[0].default, "Camera1");
-        assert_eq!(proj.view_state.active_camera, "Camera1");
+        assert_eq!(by_name("my_region").params[0].default, "sphere1_2", "the wire followed the rename");
+        assert_eq!(by_name("sphere1").params[0].default, "camera1");
+        assert_eq!(proj.view_state.active_camera, "camera1");
         // The template children inside the sphere were never spaced and are untouched.
-        assert!(by_name("Sphere1_2").children.iter().any(|c| c.name == "opencl1"));
+        assert!(by_name("sphere1_2").children.iter().any(|c| c.name == "opencl1"));
 
         // A clean file is left exactly alone.
         let before = serde_json::to_string(&proj).unwrap();
@@ -777,7 +777,7 @@ mod tests {
     /// 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 == "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");
+        let m_idx = state.fs_root.children[s_idx].children.iter().position(|c| c.name == "main").expect("main inside Session");
         (s_idx, m_idx)
     }
 
@@ -828,13 +828,13 @@ mod tests {
         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"] {
+        for expected in ["main", "view", "guides", "render"] {
             assert!(names.contains(&expected), "Session is missing {expected}: {names:?}");
         }
         // None of the four remain at root.
         for c in &state.fs_root.children {
             assert!(
-                !(c.node_type == "utility" && matches!(c.name.as_str(), "Main" | "View" | "Guides" | "Render")),
+                !(c.node_type == "utility" && matches!(c.name.as_str(), "main" | "view" | "guides" | "render")),
                 "settings node '{}' still at root", c.name
             );
         }
@@ -854,7 +854,7 @@ mod tests {
         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"] {
+        for expected in ["main", "view", "guides", "render"] {
             assert!(names.contains(&expected), "retype lost {expected}: {names:?}");
         }
 
@@ -862,7 +862,7 @@ mod tests {
         // applying the settings drives the overlay size.
         {
             let guides = state.fs_root.children[s_idx].children.iter_mut()
-                .find(|c| c.name == "Guides").unwrap();
+                .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");
@@ -902,7 +902,7 @@ mod tests {
         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" {
+            if child.name == "guides" {
                 child.params.push(crate::app::ParamDef {
                     name: "migration probe".to_string(),
                     label: String::new(),
@@ -920,7 +920,7 @@ mod tests {
 
         state.ensure_menubar_subnets();
         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 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");
     }
@@ -1002,12 +1002,12 @@ mod tests {
             .fs_root
             .children
             .iter()
-            .position(|c| c.name == "Sphere1")
-            .expect("default project has Sphere1");
+            .position(|c| c.name == "sphere1")
+            .expect("default project has sphere1");
         state.current_path2 = vec![sphere];
         state.sync_nodes();
         assert!(state.current_path.is_empty(), "primary path must not follow");
-        assert_eq!(state.path_names_at(&state.current_path2), vec!["Sphere1".to_string()]);
+        assert_eq!(state.path_names_at(&state.current_path2), vec!["sphere1".to_string()]);
 
         state.current_path2 = vec![99];
         state.sync_nodes();
@@ -1031,8 +1031,8 @@ mod tests {
         let mut state = State::new(false);
         state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
 
-        let sphere = state.fs_root.children.iter().position(|c| c.name == "Sphere1").unwrap();
-        let camera = state.fs_root.children.iter().position(|c| c.name == "Camera1").unwrap();
+        let sphere = state.fs_root.children.iter().position(|c| c.name == "sphere1").unwrap();
+        let camera = state.fs_root.children.iter().position(|c| c.name == "camera1").unwrap();
 
         // Pane 1 selects the sphere; the spreadsheet pins to pane 1.
         state.graph_mut().set_selected_node(Some(sphere));
@@ -1102,8 +1102,8 @@ mod tests {
             .fs_root
             .children
             .iter()
-            .position(|c| c.name == "Sphere1")
-            .expect("default project has Sphere1");
+            .position(|c| c.name == "sphere1")
+            .expect("default project has sphere1");
         a.current_path2 = vec![sphere];
         a.save_to_file(&dir).expect("save");
 
@@ -1299,12 +1299,12 @@ mod tests {
         let content = fs::read_to_string(&path).expect("failed to read default project");
         let proj: Project = serde_json::from_str(&content).expect("failed to deserialize project");
         assert_eq!(proj.name, "Default Project");
-        assert_eq!(proj.view_state.active_camera, "Camera1");
+        assert_eq!(proj.view_state.active_camera, "camera1");
         assert_eq!(proj.root.name, "root");
         assert_eq!(proj.root.children.len(), 2);
-        assert_eq!(proj.root.children[0].name, "Camera1");
+        assert_eq!(proj.root.children[0].name, "camera1");
         assert_eq!(proj.root.children[0].position, (1.0, 1.0));
-        assert_eq!(proj.root.children[1].name, "Sphere1");
+        assert_eq!(proj.root.children[1].name, "sphere1");
         assert_eq!(proj.root.children[1].position, (4.0, 2.0));
     }
 
@@ -1355,7 +1355,7 @@ mod tests {
         use glam::Vec3;
         let mut vp = crate::viewport_3d::Viewport3D::new();
         let inner = vp.as_any_mut().downcast_mut::<crate::viewport_3d::Viewport3D>().unwrap();
-        inner.active_camera = "Camera1".to_string();
+        inner.active_camera = "camera1".to_string();
         let pos = Vec3::new(2.5, 1.8, 2.5);
         let piv = Vec3::ZERO;
         let (_, v1, _) = inner.get_matrices(1.0, Some(pos), Some(Vec3::new(23.62, -58.83, 0.0)), Some(piv));
@@ -4268,7 +4268,7 @@ mod tests {
         assert!(shown.iter().any(|(k, _, t)| k == "Wireframe Color" && t == "rgba"), "{shown:?}");
         assert!(shown.iter().any(|(k, _, t)| k == "Wireframe Single Color" && t == "toggle"), "{shown:?}");
         let s = crate::dialog::SETTINGS.iter().find(|s| s.label == "Wireframe Color").unwrap();
-        assert_eq!(s.owner, Some(crate::dialog::Owner::Subnet("Render", "Wire Color")));
+        assert_eq!(s.owner, Some(crate::dialog::Owner::Subnet("render", "Wire Color")));
 
         // Editing both rows reaches the live state: the colour AND the switch
         // that makes the wire pass use it (off, the wires carry the
@@ -4294,8 +4294,8 @@ mod tests {
         use crate::geometry::Vertex3D;
         let mut state = State::new(false);
         // The root holds Camera 1; a subnet holds no camera at all.
-        state.active_camera = "Camera1".to_string();
-        let sub = state.current_dir().children.iter().position(|c| c.name == "Sphere1").expect("Sphere1 at the root");
+        state.active_camera = "camera1".to_string();
+        let sub = state.current_dir().children.iter().position(|c| c.name == "sphere1").expect("sphere1 at the root");
         state.current_path.push(sub);
         state.on_path_changed();
         assert!(!state.current_dir().children.iter().any(|c| c.node_type == "camera"), "no camera in the subnet");
@@ -4371,7 +4371,7 @@ mod tests {
         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();
+            .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);
     }
@@ -4388,13 +4388,13 @@ mod tests {
         assert!(!state.wire_single_color);
         let render_param = |state: &State, name: &str| -> String {
             state.fs_root.children.iter().find(|c| c.node_type == "meta").unwrap()
-                .children.iter().find(|c| c.name == "Render").unwrap()
+                .children.iter().find(|c| c.name == "render").unwrap()
                 .params.iter().find(|p| p.name == name).unwrap().default.clone()
         };
         // An edit through the node, as the params pane and the dialog make it.
         {
             let meta = state.fs_root.children.iter_mut().find(|c| c.node_type == "meta").unwrap();
-            let render = meta.children.iter_mut().find(|c| c.name == "Render").unwrap();
+            let render = meta.children.iter_mut().find(|c| c.name == "render").unwrap();
             render.params.iter_mut().find(|p| p.name == "Wire Color").unwrap().default = "#000000ff".to_string();
         }
         state.apply_settings_from_menubar_subnets();
@@ -4437,7 +4437,7 @@ mod tests {
                 .children
                 .iter()
                 .find(|c| c.node_type == "meta")
-                .and_then(|s| s.children.iter().find(|c| c.name == "Render"))
+                .and_then(|s| s.children.iter().find(|c| c.name == "render"))
                 .and_then(|n| n.params.iter().find(|p| p.name == "Show Wireframe"))
                 .map(|p| p.default.clone())
                 .expect("a Render node with a Show Wireframe toggle")
@@ -5096,7 +5096,7 @@ mod tests {
         let view = state.fs_root.children[session]
             .children
             .iter()
-            .position(|c| c.name == "View")
+            .position(|c| c.name == "view")
             .expect("View node");
         let plate_row = |state: &State| {
             state.fs_root.children[session].children[view]
@@ -5135,7 +5135,7 @@ mod tests {
         let guides_value = |state: &State, name: &str| -> String {
             state
                 .session_node()
-                .and_then(|s| s.children.iter().find(|c| c.name == "Guides"))
+                .and_then(|s| s.children.iter().find(|c| c.name == "guides"))
                 .and_then(|g| g.params.iter().find(|p| p.name == name))
                 .map(|p| p.default.clone())
                 .expect("the Guides param")
@@ -5163,7 +5163,7 @@ mod tests {
 
             // And a real edit through the action path, on an unrelated node.
             let mut redraw = false;
-            let sphere = state.current_dir().children.iter().position(|c| c.name.starts_with("Sphere")).expect("a sphere");
+            let sphere = state.current_dir().children.iter().position(|c| c.name.starts_with("sphere")).expect("a sphere");
             state
                 .apply_action(crate::app::McpAction::SetParam { slot: sphere, name: "Radius".into(), value: "0.7".into() }, &mut redraw)
                 .expect("set a sphere param");
@@ -8125,7 +8125,7 @@ mod tests {
             .expect("meta")
             .children
             .iter()
-            .find(|c| c.name == "Guides")
+            .find(|c| c.name == "guides")
             .expect("Guides");
         let p = guides.params.iter().find(|p| p.name == "Show Grid Guide").expect("the param");
         assert_eq!(p.default == "true", !was, "and so did its owner");
@@ -8196,7 +8196,7 @@ mod tests {
         assert!(!state.dialog_visible(), "the pick closes the dialog");
         assert_eq!(state.current_dir().children.len(), before + 1);
         let added = state.current_dir().children.last().expect("the new node");
-        assert!(added.name.starts_with("Box"), "added {}", added.name);
+        assert!(added.name.starts_with("box"), "added {}", added.name);
         assert_eq!(added.position, (3.0, 2.0), "placed at the grid cursor");
     }
 
diff --git a/src/project.rs b/src/project.rs
index a26ee53..384f864 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -250,7 +250,7 @@ impl State {
         root.children
             .iter()
             .find(|c| c.node_type == "meta")
-            .and_then(|m| m.children.iter().find(|c| c.name == "View"))
+            .and_then(|m| m.children.iter().find(|c| c.name == "view"))
             .map(|v| {
                 v.params
                     .iter()
@@ -672,7 +672,7 @@ impl State {
             while idx < self.fs_root.children.len() {
                 let c = &self.fs_root.children[idx];
                 if c.node_type == "utility"
-                    && matches!(c.name.as_str(), "Main" | "View" | "Guides" | "Render")
+                    && matches!(c.name.as_str(), "main" | "view" | "guides" | "render")
                 {
                     migrated.push(self.fs_root.children.remove(idx));
                 } else {
@@ -715,7 +715,7 @@ impl State {
         let session = &mut self.fs_root.children[session_idx];
 
         // 1. Main subnet
-        let main_node = find_or_create_subnet(session, "Main", "utility", (0.0, 0.0));
+        let main_node = find_or_create_subnet(session, "main", "utility", (0.0, 0.0));
         main_node.children.clear();
 
         ensure_param(main_node, "File", "section", "", &[], None, None, None);
@@ -905,7 +905,7 @@ impl State {
         // syncs this mirror before cloning the tree, and load_from_file reads
         // the loaded values (before this refresh clobbers them) and applies
         // the diffs via apply_pane_state_from_project.
-        let view_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "View", "utility", (0.0, 2.0));
+        let view_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "view", "utility", (0.0, 2.0));
         view_node.children.clear();
         ensure_param(view_node, "Panes", "section", "", &[], None, None, None);
         ensure_param(view_node, "Show Network Pane", "toggle", bool_str(show_network), &[], None, None, None);
@@ -955,7 +955,7 @@ impl State {
         // migrated off Main). The utility column keeps one empty cell between
         // nodes: Main (0,0), View (0,2), Guides (0,4), Render (0,6); older
         // saves parked at prior defaults slide to the spaced slots.
-        let guides_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "Guides", "utility", (0.0, 4.0));
+        let guides_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "guides", "utility", (0.0, 4.0));
         if guides_node.position == (0.0, 1.0) || guides_node.position == (0.0, 2.0) {
             guides_node.position = (0.0, 4.0);
         }
@@ -999,7 +999,7 @@ impl State {
         // Main. Toggles reflect live state so a reopened project shows real
         // switches. Two rows below Guides (the spaced column); older saves
         // parked at the prior defaults slide down.
-        let render_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "Render", "utility", (0.0, 6.0));
+        let render_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "render", "utility", (0.0, 6.0));
         if render_node.position == (0.0, 1.0) || render_node.position == (0.0, 2.0) || render_node.position == (0.0, 4.0) {
             render_node.position = (0.0, 6.0);
         }
@@ -1117,7 +1117,7 @@ impl State {
                 .and_then(|s| s.children.iter().find(|c| c.name == name))
                 .map(|n| n.params.clone())
         };
-        if let Some(params) = session_params(&self.fs_root, "Guides") {
+        if let Some(params) = session_params(&self.fs_root, "guides") {
             for p in &params {
                 match p.name.as_str() {
                     "Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
@@ -1152,7 +1152,7 @@ impl State {
                 }
             }
         }
-        if let Some(params) = session_params(&self.fs_root, "Main") {
+        if let Some(params) = session_params(&self.fs_root, "main") {
             for p in &params {
                 match p.name.as_str() {
                     // Network Settings
@@ -1201,7 +1201,7 @@ impl State {
             }
         }
 
-        if let Some(params) = session_params(&self.fs_root, "Render") {
+        if let Some(params) = session_params(&self.fs_root, "render") {
             let before = self.wire_color;
             for p in &params {
                 match p.name.as_str() {
diff --git a/src/window.rs b/src/window.rs
index 081ea5a..c2cd64a 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -343,7 +343,7 @@ impl State {
                         2 => {
                             state.circular_network_pane = !state.circular_network_pane;
                             let val = state.circular_network_pane;
-                            state.write_meta_toggle("Main", "Circular Pane", val);
+                            state.write_meta_toggle("main", "Circular Pane", val);
                             state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
                             state.rebuild_positions();
                             state.apply_layout();
@@ -974,7 +974,7 @@ impl State {
             McpAction::ToggleCircularPane => {
                 state.circular_network_pane = !state.circular_network_pane;
                 let val = state.circular_network_pane;
-                state.write_meta_toggle("Main", "Circular Pane", val);
+                state.write_meta_toggle("main", "Circular Pane", val);
                 state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
                 state.rebuild_positions();
                 state.apply_layout();