graphic design tool
git clone https://git.lucas.co/cce-designer.git
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.