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

commitcab92b83986529b2807175c2293ba83a1c2061a6
parent8bb28a1e92
authorLucas Galante <[email protected]>
date2026-09-21 15:17
feat: node names carry no whitespace

A node's name is a segment of its path, and a path with spaces has to be
quoted everywhere it goes. `sanitize_node_name` is the rule: the
conventional space between a template name and its index goes ("Sphere 1"
becomes "Sphere1", which is also what minting now produces), other
whitespace becomes an underscore, empty becomes `node`. It runs at every
entry point — minting, add_node's name override, rename_node — and as a
load-time migration on all five load paths, which follows every wire (any
sibling parameter holding an old name) and the view state's active camera,
and steps aside with a suffix when a sanitized name lands on a sibling's.
The template merge trims underscores as well as digits when matching an
instance to its template. The versioned project files carry the new names.

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

 CLAUDE.md            |  25 ++++++++++-
 default_project.json |   6 +--
 project.json         |   6 +--
 src/app.rs           | 116 +++++++++++++++++++++++++++++++++++++++++++++++++--
 src/export_cli.rs    |   1 +
 src/main.rs          | 101 ++++++++++++++++++++++++++++++++++++++------
 src/project.rs       |   2 +
 src/thumbnail.rs     |   1 +
 src/window.rs        |   4 +-
 9 files changed, 237 insertions(+), 25 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 845b5c5..8903272 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -916,9 +916,32 @@ 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
-("Sphere 3" → "Sphere") plus a full child name/type match; the merge never
+("Sphere3" → "Sphere") 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
+`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`
+branches, `State::new`, the thumbnail and the export CLI).
+
+The migration follows references, because wires are by name: within each
+level it renames the children, then rewrites any sibling parameter whose
+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
+name and every wire to them ambiguous.
+
 ## Repo hygiene
 
 `scratch/` holds ad-hoc debug scripts/logs and `screenshot*.png` at the root are
diff --git a/default_project.json b/default_project.json
index 223e8c1..8a34ce7 100644
--- a/default_project.json
+++ b/default_project.json
@@ -5,7 +5,7 @@
     "type": "node",
     "children": [
       {
-        "name": "Camera 1",
+        "name": "Camera1",
         "type": "camera",
         "children": [],
         "params": [
@@ -46,7 +46,7 @@
         ]
       },
       {
-        "name": "Sphere 1",
+        "name": "Sphere1",
         "type": "node",
         "position": [
           4.0,
@@ -137,7 +137,7 @@
     ]
   },
   "view_state": {
-    "active_camera": "Camera 1",
+    "active_camera": "Camera1",
     "pan": [
       0.0,
       0.0
diff --git a/project.json b/project.json
index 1b466ca..41346d8 100644
--- a/project.json
+++ b/project.json
@@ -5,7 +5,7 @@
     "type": "node",
     "children": [
       {
-        "name": "Camera 1",
+        "name": "Camera1",
         "type": "camera",
         "children": [],
         "params": [
@@ -46,7 +46,7 @@
         ]
       },
       {
-        "name": "Sphere 1",
+        "name": "Sphere1",
         "type": "node",
         "position": [
           4.0,
@@ -137,7 +137,7 @@
     ]
   },
   "view_state": {
-    "active_camera": "Camera 1",
+    "active_camera": "Camera1",
     "pan": [
       0.0,
       122.9747
diff --git a/src/app.rs b/src/app.rs
index 2a21217..4e0cdd2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -562,11 +562,112 @@ 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
-/// ("Sphere 3" → "Sphere", so a renamed instance simply keeps its saved
+/// ("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'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
+/// `node`, since a node with no name has no path at all.
+pub fn sanitize_node_name(name: &str) -> String {
+    let trimmed = name.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());
+    let (base, index) = trimmed.split_at(digits.len());
+    let base = if index.is_empty() { base } else { base.trim_end() };
+    let mut out = String::with_capacity(trimmed.len());
+    let mut in_space = false;
+    for c in base.chars() {
+        if c.is_whitespace() {
+            in_space = true;
+        } else {
+            if in_space {
+                out.push('_');
+                in_space = false;
+            }
+            out.push(c);
+        }
+    }
+    out.push_str(index);
+    if out.is_empty() {
+        "node".to_string()
+    } else {
+        out
+    }
+}
+
+impl Project {
+    /// Bring a loaded project's node names under [`sanitize_node_name`],
+    /// and follow every reference to a renamed node.
+    ///
+    /// Saves from before 2026-09-21 carry "Sphere 1" and "Camera 1", and
+    /// wires are BY NAME — a node's `Input` (and `With`, `Rest`, `Target`,
+    /// `Source`, `Collider`) holds the name of the node it reads — so a
+    /// rename that left the references alone would cut every wire in the
+    /// file. Names are unique within a level and references stay within a
+    /// level, so each level is handled on its own: rename its children, then
+    /// rewrite any sibling parameter whose value was one of the old names.
+    /// The view state's active camera is the one reference outside the tree.
+    /// Runs before the template merge on every load path, so what the merge
+    /// matches ("Sphere1" → "Sphere") is already the kept spelling.
+    pub fn sanitize_node_names(&mut self) {
+        fn walk(dir: &mut FsNode, renamed: &mut Vec<(String, String)>) {
+            // The names as loaded: a sanitized name must not land on a
+            // sibling's — "Sphere 1" next to a hand-named "Sphere1" — or two
+            // nodes share one name and every wire to them is ambiguous. A
+            // later sibling still carries its loaded name; an earlier one
+            // carries what this pass gave it.
+            let loaded: Vec<String> = dir.children.iter().map(|c| c.name.clone()).collect();
+            let mut taken: Vec<String> = Vec::new();
+            let mut map: Vec<(String, String)> = Vec::new();
+            for (i, child) in dir.children.iter_mut().enumerate() {
+                let mut name = sanitize_node_name(&child.name);
+                let clashes = |n: &str| taken.iter().any(|t| t == n) || loaded[i + 1..].iter().any(|t| t == n);
+                if name != child.name && clashes(&name) {
+                    let mut n = 2;
+                    while clashes(&format!("{name}_{n}")) {
+                        n += 1;
+                    }
+                    name = format!("{name}_{n}");
+                }
+                if name != child.name {
+                    map.push((child.name.clone(), name.clone()));
+                    child.name = name.clone();
+                }
+                taken.push(name);
+            }
+            if !map.is_empty() {
+                for child in &mut dir.children {
+                    for p in &mut child.params {
+                        if let Some((_, new)) = map.iter().find(|(old, _)| *old == p.default) {
+                            p.default = new.clone();
+                        }
+                    }
+                }
+                renamed.extend(map);
+            }
+            for child in &mut dir.children {
+                walk(child, &mut Vec::new());
+            }
+        }
+        let mut root_renamed = Vec::new();
+        walk(&mut self.root, &mut root_renamed);
+        if let Some((_, new)) = root_renamed.iter().find(|(old, _)| *old == self.view_state.active_camera) {
+            self.view_state.active_camera = new.clone();
+        }
+    }
+}
+
 pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
     // Legacy retypes, session->meta style: renamed native types are rewritten
     // in place (params and name intact) BEFORE matching, so old saves find the
@@ -584,7 +685,10 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
 
     fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
         if node.node_type.eq_ignore_ascii_case("node") {
-            let base = node.name.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end();
+            let base = node
+                .name
+                .trim_end_matches(|c: char| c.is_ascii_digit())
+                .trim_end_matches(|c: char| c == '_' || c.is_whitespace());
             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))
@@ -2283,11 +2387,16 @@ impl State {
         self.current_path2.truncate(valid);
     }
 
+    /// The name a new node gets: the template's name and the lowest free
+    /// index run together — `Sphere1`, not `Sphere 1`. A node's name is a
+    /// segment of its path, and a path with spaces in it is a path you have
+    /// to quote everywhere it goes (see [`sanitize_node_name`]).
     pub fn get_lowest_unused_name(&self, base_name: &str) -> String {
         let dir = self.current_dir();
+        let base = sanitize_node_name(base_name);
         let mut index = 1;
         loop {
-            let candidate = format!("{} {}", base_name, index);
+            let candidate = format!("{}{}", base, index);
             if !dir.children.iter().any(|c| c.name == candidate) {
                 return candidate;
             }
@@ -4029,6 +4138,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         if default_proj_path.exists() {
             if let Ok(content) = fs::read_to_string(&default_proj_path) {
                 if let Ok(mut proj) = serde_json::from_str::<Project>(&content) {
+                    proj.sanitize_node_names();
                     merge_template_defs(&mut proj.root, &node_templates);
                     ensure_meta_children(&mut proj.root);
                     loaded_project = Some(proj);
diff --git a/src/export_cli.rs b/src/export_cli.rs
index 3557016..e48f1bc 100644
--- a/src/export_cli.rs
+++ b/src/export_cli.rs
@@ -31,6 +31,7 @@ pub fn run(
     let mut proj: crate::app::Project = serde_json::from_str(&content)
         .map_err(|e| format!("parse {}: {e}", state_file.display()))?;
     let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
+    proj.sanitize_node_names();
     crate::app::merge_template_defs(&mut proj.root, &templates);
 
     let mut ocl_error = None;
diff --git a/src/main.rs b/src/main.rs
index 79e590b..0e9288c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -470,6 +470,81 @@ mod tests {
         assert_eq!(state.orbit_drag, Some((cx, cy)), "a press on the scene still orbits");
     }
 
+    // ----- 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.
+    #[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("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");
+        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");
+    }
+
+    /// Loading an older save renames its nodes and follows every reference:
+    /// the wires, and the active camera in the view state.
+    #[test]
+    fn loading_a_project_strips_spaces_and_rewires_references() {
+        use crate::app::{ParamDef, Project};
+        let content = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/default_project.json")).unwrap();
+        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();
+        proj.root.children[sphere].name = "Sphere 1".into();
+        proj.root.children[camera].name = "Camera 1".into();
+        proj.view_state.active_camera = "Camera 1".into();
+        let mut group = proj.root.children[sphere].clone();
+        group.id = "g".into();
+        group.name = "My Region".into();
+        group.node_type = "group".into();
+        group.children.clear();
+        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.params[0].default = "Camera 1".into();
+        proj.root.children.push(group);
+        proj.root.children.push(clash);
+
+        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:?}");
+        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");
+        // The template children inside the sphere were never spaced and are untouched.
+        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();
+        proj.sanitize_node_names();
+        assert_eq!(serde_json::to_string(&proj).unwrap(), before);
+    }
+
     #[test]
     fn test_dock_swap_repositions_plates() {
         use crate::app::Dock;
@@ -927,12 +1002,12 @@ mod tests {
             .fs_root
             .children
             .iter()
-            .position(|c| c.name == "Sphere 1")
-            .expect("default project has Sphere 1");
+            .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!["Sphere 1".to_string()]);
+        assert_eq!(state.path_names_at(&state.current_path2), vec!["Sphere1".to_string()]);
 
         state.current_path2 = vec![99];
         state.sync_nodes();
@@ -956,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 == "Sphere 1").unwrap();
-        let camera = state.fs_root.children.iter().position(|c| c.name == "Camera 1").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));
@@ -1027,8 +1102,8 @@ mod tests {
             .fs_root
             .children
             .iter()
-            .position(|c| c.name == "Sphere 1")
-            .expect("default project has Sphere 1");
+            .position(|c| c.name == "Sphere1")
+            .expect("default project has Sphere1");
         a.current_path2 = vec![sphere];
         a.save_to_file(&dir).expect("save");
 
@@ -1224,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, "Camera 1");
+        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, "Camera 1");
+        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, "Sphere 1");
+        assert_eq!(proj.root.children[1].name, "Sphere1");
         assert_eq!(proj.root.children[1].position, (4.0, 2.0));
     }
 
@@ -1280,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 = "Camera 1".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));
@@ -4219,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 = "Camera 1".to_string();
-        let sub = state.current_dir().children.iter().position(|c| c.name == "Sphere 1").expect("Sphere 1 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");
diff --git a/src/project.rs b/src/project.rs
index 34ed974..a26ee53 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -369,6 +369,7 @@ impl State {
         if path.file_name().map_or(false, |n| n == "default_project.json") {
             let content = fs::read_to_string(path)?;
             let mut proj: Project = serde_json::from_str(&content)?;
+            proj.sanitize_node_names();
             crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
             crate::app::ensure_meta_children(&mut proj.root);
             let saved_pane_vis = Self::project_pane_visibility(&proj.root);
@@ -435,6 +436,7 @@ impl State {
 
         let content = fs::read_to_string(&state_file_path)?;
         let mut proj: Project = serde_json::from_str(&content)?;
+        proj.sanitize_node_names();
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
         crate::app::ensure_meta_children(&mut proj.root);
         let saved_pane_vis = Self::project_pane_visibility(&proj.root);
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index bdec1ed..95d853c 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -28,6 +28,7 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
     // Same template merge the app applies on load, so a thumbnail of an old
     // scene shows what opening it would show.
     let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
+    proj.sanitize_node_names();
     crate::app::merge_template_defs(&mut proj.root, &templates);
 
     let mut ocl_error = None;
diff --git a/src/window.rs b/src/window.rs
index 79ded98..081ea5a 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -805,7 +805,7 @@ impl State {
                         let (nx, ny) = state.find_empty_cell(x, y, None);
                         node.position = (nx, ny);
                         if let Some(n) = name {
-                            node.name = n;
+                            node.name = crate::app::sanitize_node_name(&n);
                         } else {
                             node.name = state.get_lowest_unused_name(&node.name);
                         }
@@ -851,7 +851,7 @@ impl State {
             McpAction::RenameNode { slot, new_name } => {
                 let len = state.current_dir().children.len();
                 if slot < len {
-                    state.current_dir_mut().children[slot].name = new_name;
+                    state.current_dir_mut().children[slot].name = crate::app::sanitize_node_name(&new_name);
                     state.sync_nodes();
                     // Connections reference nodes by name (Input params), so a
                     // rename changes downstream evaluation.