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

commit9b1376a416ae3343cac457e438c5d512097e9fa5
parent0ceb72585c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 18:06
refactor: the generators' normal attribute is N, not Norm

The Sphere, Box, Plane and their kin wrote Norm where the Normal node, the
exporter and a wrangle's @N already said N. Project format 2 migrates an
older save once: attribute rows naming Norm, Norm in an Attributes list,
and @Norm in wrangle code. migrate_param_refs is a step of the new
migrate_format, which every load path calls.

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

 CLAUDE.md         | 14 ++++++--
 src/app.rs        | 81 ++++++++++++++++++++++++++++++++++++++++-----
 src/export_cli.rs |  2 +-
 src/geometry.rs   | 16 ++++-----
 src/main.rs       | 99 +++++++++++++++++++++++++++++++++++++++++--------------
 src/project.rs    |  4 +--
 src/render.rs     |  2 +-
 src/shapes.rs     |  8 ++---
 src/thumbnail.rs  |  2 +-
 9 files changed, 176 insertions(+), 52 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 80e1f28..cb079a0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -992,9 +992,17 @@ into a choice its option name, into a spinbox an integer.
 whole value had to be one reference, nothing else). `Project::format` is
 the version that tells the two apart: 0 (absent) loads through
 `migrate_param_refs`, which turns each old reference into an expression
-with `../` added to a bare name, and saves as 1 — beside
-`sanitize_node_names` on every load path, and never twice, since a bare
-name in a format-1 file is the node's own parameter. Templates go through
+with `../` added to a bare name — beside `sanitize_node_names` on every
+load path, and never twice, since a bare name in a format-1 file is the
+node's own parameter. It is a step of `Project::migrate_format`, which
+takes a file through every step it is behind. **Format 2** (2026-10-01):
+the generators' normal attribute is `N` — the Sphere, Box, Plane and
+their kin wrote `Norm`, where the Normal node, the exporter and a
+wrangle's `@N` already said `N` — and `migrate_norm_to_n` rewrites what
+names `Norm` in an older save: an attribute row, a name in a comma list of
+attributes (`Attributes`), `@Norm` in a wrangle's Code (not `@Normal`).
+Once, by the version, so an attribute someone names `Norm` afterwards is
+theirs (`a_save_naming_norm_names_n`). Templates go through
 `infer_template_exprs` instead: a default that READS as a reference is one
 (`embryo.json` says `chf("../Radius")` now). The same inference applies to a
 value typed into a plain row or scripted through `set_param`: a reference
diff --git a/src/app.rs b/src/app.rs
index cabac52..f7cad64 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -367,14 +367,16 @@ pub struct Project {
     /// The file format this project was saved in, so a load can tell an old
     /// meaning from a new one. 0 (absent) is every save before 2026-09-24,
     /// when a bare `ch("Name")` meant the PARENT's parameter; 1 is Houdini's
-    /// semantics, where it means the node's own — `migrate_param_refs` is
-    /// the step between them, and it must not run twice.
+    /// semantics, where it means the node's own; 2 (2026-10-01) is the
+    /// generators' normal attribute called `N` where it was `Norm`.
+    /// `migrate_format` takes a file through each step it is behind, and a
+    /// step must not run twice.
     #[serde(default)]
     pub format: u32,
 }
 
 /// The format `Project` saves in — see its `format` field.
-pub const PROJECT_FORMAT: u32 = 1;
+pub const PROJECT_FORMAT: u32 = 2;
 
 /// One entry in a node's right-click context menu, parallel to the visible
 /// labels shown via `context_menu::show`.
@@ -1185,6 +1187,10 @@ impl Project {
 }
 
 impl Project {
+    /// Take a loaded project through every format step it is behind, then
+    /// call it current. Runs beside `sanitize_node_names` on every load
+    /// path; a step must not run twice, which is what the version is for.
+    ///
     /// Format 0 → 1: every parameter that was a pre-expression reference —
     /// the whole value `ch("Name")`, `chf` / `chi` / `chb`, with `../` per
     /// level — becomes an expression with Houdini's semantics. A bare name
@@ -1193,10 +1199,18 @@ impl Project {
     /// path, and on a format-1 file does nothing, which is what the version
     /// is for: a bare name in a NEW file is the node's own parameter and
     /// must not be rewritten.
-    pub fn migrate_param_refs(&mut self) {
-        if self.format >= PROJECT_FORMAT {
-            return;
+    pub fn migrate_format(&mut self) {
+        if self.format < 1 {
+            self.migrate_param_refs();
+        }
+        if self.format < 2 {
+            self.migrate_norm_to_n();
         }
+        self.format = PROJECT_FORMAT;
+    }
+
+    /// Format 0 → 1, as above.
+    fn migrate_param_refs(&mut self) {
         fn walk(node: &mut FsNode) {
             for p in &mut node.params {
                 if !p.is_expr() {
@@ -1211,8 +1225,59 @@ impl Project {
             }
         }
         walk(&mut self.root);
-        self.format = PROJECT_FORMAT;
     }
+
+    /// Format 1 → 2: the generators' normal attribute is `N`, as the Normal
+    /// node, the exporter and a wrangle's `@N` already named it, where the
+    /// Sphere, Box and Plane wrote `Norm`. What names it in a save follows:
+    /// a parameter naming an attribute (`ParamKind::Attribute`) that says
+    /// `Norm`, a name in a comma list of attributes (Transfer's and the
+    /// Remesh's `Attributes`), and `@Norm` in a wrangle's Code. Once, by
+    /// the version — an attribute someone names `Norm` after this is theirs.
+    fn migrate_norm_to_n(&mut self) {
+        fn walk(node: &mut FsNode) {
+            for p in &mut node.params {
+                if p.is_expr() {
+                    continue;
+                }
+                let text = p.text().to_string();
+                let new = if p.kind() == ParamKind::Attribute && text.trim() == "Norm" {
+                    Some("N".to_string())
+                } else if p.name == "Attributes" && text.split(',').any(|a| a.trim() == "Norm") {
+                    Some(text.split(',').map(|a| if a.trim() == "Norm" { a.replace("Norm", "N") } else { a.to_string() }).collect::<Vec<_>>().join(","))
+                } else if p.kind() == ParamKind::Code && text.contains("@Norm") {
+                    Some(rename_at_attribute(&text, "Norm", "N"))
+                } else {
+                    None
+                };
+                if let Some(new) = new.filter(|n| *n != text) {
+                    p.set_text(new);
+                }
+            }
+            for c in &mut node.children {
+                walk(c);
+            }
+        }
+        walk(&mut self.root);
+    }
+}
+
+/// `code` with every `@old` that is a whole name (not `@Normal` for
+/// `@Norm`) written `@new`.
+fn rename_at_attribute(code: &str, old: &str, new: &str) -> String {
+    let pat = format!("@{old}");
+    let renamed = format!("@{new}");
+    let mut out = String::with_capacity(code.len());
+    let mut rest = code;
+    while let Some(i) = rest.find(&pat) {
+        let after = &rest[i + pat.len()..];
+        let whole = !after.chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_');
+        out.push_str(&rest[..i]);
+        out.push_str(if whole { &renamed } else { &pat });
+        rest = after;
+    }
+    out.push_str(rest);
+    out
 }
 
 /// A template's parameters whose defaults READ as expressions become ones:
@@ -7428,7 +7493,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             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();
-                    proj.migrate_param_refs();
+                    proj.migrate_format();
                     merge_template_defs(&mut proj.root, &node_templates);
                     loaded_project = Some(proj);
                 }
diff --git a/src/export_cli.rs b/src/export_cli.rs
index 7a0ba11..09940d8 100644
--- a/src/export_cli.rs
+++ b/src/export_cli.rs
@@ -32,7 +32,7 @@ pub fn run(
         .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();
-    proj.migrate_param_refs();
+    proj.migrate_format();
     crate::app::merge_template_defs(&mut proj.root, &templates);
 
     let mut ocl_error = None;
diff --git a/src/geometry.rs b/src/geometry.rs
index 97289af..e5e109a 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -290,7 +290,7 @@ pub fn smooth_lit_vertices(d: &Detail) -> Vec<Vertex3D> {
 /// express neither: it emitted `lat_steps * lon_steps * 6` loose corners, of
 /// which the two pole bands were zero-area triangles.
 ///
-/// `Norm` and `UV` are POINT attributes, computed from the surface normal
+/// `N` and `UV` are POINT attributes, computed from the surface normal
 /// exactly as before. Both are pure functions of the normal, so a welded point
 /// has one answer — including at the seam, where the old per-corner UVs
 /// already agreed because they were derived from the normal rather than from
@@ -362,7 +362,7 @@ pub fn sphere_detail(center: Vec3, radius: f32, lat_steps: usize, lon_steps: usi
         .map(|n| [0.35 + n.x.abs() * 0.35, 0.45 + n.y.abs() * 0.35, 0.85])
         .collect();
     let points = d.points_mut();
-    let _ = points.insert("Norm", AttribData::Float3(norms));
+    let _ = points.insert("N", AttribData::Float3(norms));
     let _ = points.insert("UV", AttribData::Float2(uvs));
     let _ = points.insert(CD, AttribData::Float3(cds));
     d
@@ -415,7 +415,7 @@ pub fn box_detail(start: Vec3, end: Vec3, thickness: f32) -> Detail {
 
     // Wound counter-clockwise seen from outside, like the sphere and the
     // template meshes. The soup's order was the reverse, which meant every
-    // face of a native box wound against the `Norm` attribute the same
+    // face of a native box wound against the `N` attribute the same
     // function attached to it — the geometry and its own normal disagreed.
     let faces: [([u32; 4], Vec3); 6] = [
         ([3, 2, 1, 0], -dir), // start cap
@@ -434,7 +434,7 @@ pub fn box_detail(start: Vec3, end: Vec3, thickness: f32) -> Detail {
         norms.extend(std::iter::repeat(normal.to_array()).take(4));
     }
     let (num_verts, num_points) = (d.num_verts(), d.num_points());
-    let _ = d.verts_mut().insert("Norm", AttribData::Float3(norms));
+    let _ = d.verts_mut().insert("N", AttribData::Float3(norms));
     let _ = d
         .verts_mut()
         .insert("UV", AttribData::Float2(vec![[0.0, 0.0]; num_verts]));
@@ -6719,13 +6719,13 @@ mod tests {
         // Three faces meet at every corner with three different normals, so
         // the normal cannot live on the point. This is what the vertex class
         // is for.
-        assert!(d.verts().has("Norm"));
-        assert!(!d.points().has("Norm"));
+        assert!(d.verts().has("N"));
+        assert!(!d.points().has("N"));
         assert_eq!(d.point_prims(0).len(), 3);
         assert_eq!(d.topology().valence(0), 3);
 
         let normals: Vec<[f32; 3]> = (0..d.num_verts())
-            .filter_map(|v| match d.verts().value("Norm", v) {
+            .filter_map(|v| match d.verts().value("N", v) {
                 Some(crate::detail::AttribValue::Float3(n)) => Some(n),
                 _ => None,
             })
@@ -6733,7 +6733,7 @@ mod tests {
         assert_eq!(normals.len(), 24);
         let corner_0_normals: Vec<[f32; 3]> = (0..d.num_prims())
             .filter(|&p| d.prim_points(p).contains(&0))
-            .filter_map(|p| match d.verts().value("Norm", d.prim_verts(p).start) {
+            .filter_map(|p| match d.verts().value("N", d.prim_verts(p).start) {
                 Some(crate::detail::AttribValue::Float3(n)) => Some(n),
                 _ => None,
             })
diff --git a/src/main.rs b/src/main.rs
index e5d95d8..258c787 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -665,7 +665,7 @@ mod tests {
 
         proj.sanitize_node_names();
 
-        proj.migrate_param_refs();
+        proj.migrate_format();
 
         let names: Vec<&str> = proj.root.children.iter().map(|c| c.name.as_str()).collect();
         assert!(names.contains(&"camera1"));
@@ -682,10 +682,57 @@ mod tests {
         // A clean file is left exactly alone.
         let before = serde_json::to_string(&proj).unwrap();
         proj.sanitize_node_names();
-        proj.migrate_param_refs();
+        proj.migrate_format();
         assert_eq!(serde_json::to_string(&proj).unwrap(), before);
     }
 
+    /// Format 1 → 2: the generators' normal attribute is `N`, and what names
+    /// `Norm` in a save follows it — an attribute row, a name in a comma
+    /// list of attributes, `@Norm` in a wrangle (not `@Normal`) — once: a
+    /// format-2 file naming `Norm` is left alone, since that attribute is
+    /// someone's own.
+    #[test]
+    fn a_save_naming_norm_names_n() {
+        use crate::app::{FsNode, ParamDef, Project, PROJECT_FORMAT};
+        let node = |name: &str, ty: &str, params: Vec<ParamDef>| FsNode {
+            id: name.into(),
+            name: name.into(),
+            node_type: ty.into(),
+            children: vec![],
+            params,
+            geometry_visible: true,
+            bypassed: false,
+            position: (0.0, 0.0),
+            inputs: 1,
+            outputs: 1,
+        };
+        let mut root = node("root", "subnet", vec![]);
+        root.children = vec![
+            node("vis", "visualize", vec![ParamDef::new("Attribute", "attribute", "Norm")]),
+            node("xfer", "transfer", vec![ParamDef::new("Attributes", "text", "Cd, Norm,UV")]),
+            node("w", "wrangle", vec![ParamDef::new("Code", "code", "@P += @Norm * 0.1; @Normal = 1;")]),
+            node("keep", "visualize", vec![ParamDef::new("Attribute", "attribute", "Normx")]),
+        ];
+        let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 1 };
+        proj.migrate_format();
+        assert_eq!(proj.format, PROJECT_FORMAT);
+        let text = |proj: &Project, i: usize| proj.root.children[i].params[0].text().to_string();
+        assert_eq!(text(&proj, 0), "N");
+        assert_eq!(text(&proj, 1), "Cd, N,UV");
+        assert_eq!(text(&proj, 2), "@P += @N * 0.1; @Normal = 1;");
+        assert_eq!(text(&proj, 3), "Normx", "only the whole name");
+
+        // Once: a format-2 file's Norm is its own attribute.
+        let mut again = proj.clone();
+        again.root.children[0].params[0].set_text("Norm".to_string());
+        again.migrate_format();
+        assert_eq!(text(&again, 0), "Norm");
+
+        // And the generators write N.
+        let s = crate::geometry::sphere_detail(glam::Vec3::ZERO, 1.0, 4, 6);
+        assert!(s.points().has("N") && !s.points().has("Norm"));
+    }
+
     #[test]
     fn test_dock_swap_repositions_plates() {
         use crate::app::Dock;
@@ -3653,7 +3700,7 @@ mod tests {
             let r = (pos[0].powi(2) + (pos[1] - 0.55).powi(2) + pos[2].powi(2)).sqrt();
             assert!((r - 0.5).abs() < 1e-4, "point {pos:?} is {r} from the centre");
         }
-        assert!(geom.points().has("Norm") && geom.points().has("UV") && geom.points().has("Cd"));
+        assert!(geom.points().has("N") && geom.points().has("UV") && geom.points().has("Cd"));
     }
 
     /// The native curve node: a Catmull-Rom strip through the "Points"
@@ -4444,7 +4491,7 @@ mod tests {
         };
         let attr_ty = row("Attribute Name");
         assert!(attr_ty.starts_with("textpick:"), "got {attr_ty}");
-        for expected in ["Norm", "UV", "Pos", "Col"] {
+        for expected in ["N", "UV", "Pos", "Col"] {
             assert!(attr_ty.contains(expected), "{expected} missing from {attr_ty}");
         }
         assert_eq!(row("Group"), "textpick:group1");
@@ -5777,8 +5824,8 @@ mod tests {
         // A box line fans to 36 renderer vertices: 6 faces * 2 triangles * 3 corners.
         assert_eq!(detail_vertices(&d).len(), 36);
 
-        // Norm and UV ride the vertices, one per corner.
-        assert!(d.verts().has("Norm"));
+        // N and UV ride the vertices, one per corner.
+        assert!(d.verts().has("N"));
         assert!(d.verts().has("UV"));
     }
 
@@ -7680,7 +7727,7 @@ mod tests {
         state.rebuild_positions();
         state.apply_layout();
         state.rebuild_scene_geometry();
-        assert!(state.scene_attributes.iter().any(|a| a.name == "Norm"), "{:?}", state.scene_attributes);
+        assert!(state.scene_attributes.iter().any(|a| a.name == "N"), "{:?}", state.scene_attributes);
         let colours = |state: &State| state.rt_sphere_verts.iter().map(|v| v.color).collect::<Vec<_>>();
         let plain = colours(&state);
         let nodes_before = serde_json::to_string(&state.fs_root).unwrap();
@@ -7699,8 +7746,8 @@ mod tests {
         let has = |state: &State, f: &str| state.slots.dialog.rows.iter().any(|r| r.id == field(f));
         assert!(has(&state, "ramp") && has(&state, "opacity") && !has(&state, "scale"), "Ramp's rows");
 
-        // On Norm, a Ramp recolours the scene, which gained no node.
-        state.set_visualizer_field(0, "attribute", "Norm", true);
+        // On N, a Ramp recolours the scene, which gained no node.
+        state.set_visualizer_field(0, "attribute", "N", true);
         assert_ne!(colours(&state), plain, "the ramp is on the scene");
         assert_eq!(serde_json::to_string(&state.fs_root).unwrap(), nodes_before, "no node in the graph");
 
@@ -7726,7 +7773,7 @@ mod tests {
         assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
         let id = format!("{VIS_ROW_PREFIX}0");
         let row = state.slots.dialog.rows.iter().find(|r| r.id == id).expect("its row").clone();
-        assert!(row.label.starts_with("Norm — Vector"), "{}", row.label);
+        assert!(row.label.starts_with("N — Vector"), "{}", row.label);
         assert_eq!(row.toggle(), Some(true));
 
         // Kept with the display settings.
@@ -7759,7 +7806,7 @@ mod tests {
         let mut state = State::new(false);
         state.rebuild_scene_geometry();
         let base = state.scene_base.clone().expect("a scene");
-        let mut v = crate::visualizer::Visualizer::new("Norm");
+        let mut v = crate::visualizer::Visualizer::new("N");
         v.ramp = "Heat".into();
         let mut by_vis = base.clone();
         crate::visualizer::apply_all(&[v.clone()], &mut by_vis);
@@ -9244,14 +9291,14 @@ mod tests {
             format: 0,
         };
         proj.root.children[0].children[0].params[0].set_expr(false);
-        proj.migrate_param_refs();
+        proj.migrate_format();
         let r = &proj.root.children[0].children[0].params[0];
         assert_eq!(r.text(), "chf(\"../Size\")");
         assert!(r.is_expr());
         assert_eq!(proj.format, crate::app::PROJECT_FORMAT);
         // A NEW file's bare name is the node's own parameter and stays.
         proj.root.children[0].children[0].params[0].set_text("chf(\"Radius\")");
-        proj.migrate_param_refs();
+        proj.migrate_format();
         assert_eq!(proj.root.children[0].children[0].params[0].text(), "chf(\"Radius\")");
     }
 
@@ -10732,7 +10779,11 @@ mod tests {
         );
         let (g, err) = eval_node(&root, "normal 1");
         assert!(err.is_none(), "{err:?}");
-        assert!(!g.points().has("N"), "the points' is not written");
+        // The points' N is the sphere's own, as it came in: Vertices writes
+        // the vertices and leaves it alone.
+        let (input, _) = eval_node(&root, "sphere 1");
+        let pn = |d: &crate::detail::Detail| (0..d.num_points()).map(|p| d.points().value("N", p).map(|v| v.as_vec3())).collect::<Vec<_>>();
+        assert_eq!(pn(&g), pn(&input), "the points' is not written");
         let n = crate::geometry::own_vertex_normals(&g).expect("a normal a vertex");
         assert_eq!(n.len(), g.num_verts());
         let of_points = crate::geometry::point_normals(&g);
@@ -11006,7 +11057,7 @@ mod tests {
                     &[
                         ("Input", "points 1"),
                         ("From", "sphere 1"),
-                        ("Attributes", "Norm"),
+                        ("Attributes", "N"),
                         ("Maximum Distance", "0.00"),
                     ],
                 ),
@@ -11015,9 +11066,9 @@ mod tests {
         let (g, err) = eval_node(&root, "transfer 1");
         assert!(err.is_none(), "{err:?}");
         // No limit: everything finds a nearest source point however far.
-        assert!(g.points().has("Norm"));
+        assert!(g.points().has("N"));
         assert!(
-            (0..g.num_points()).any(|p| g.points().value("Norm", p).unwrap().as_vec3() != Vec3::ZERO),
+            (0..g.num_points()).any(|p| g.points().value("N", p).unwrap().as_vec3() != Vec3::ZERO),
             "nothing was transferred"
         );
 
@@ -11035,9 +11086,9 @@ mod tests {
             .unwrap()
             .set_text("0.01");
         let (g, _) = eval_node(&limited, "transfer 1");
-        assert!(g.points().has("Norm"), "the column exists even where nothing was near");
+        assert!(g.points().has("N"), "the column exists even where nothing was near");
         assert!(
-            (0..g.num_points()).all(|p| g.points().value("Norm", p).unwrap().as_vec3() == Vec3::ZERO),
+            (0..g.num_points()).all(|p| g.points().value("N", p).unwrap().as_vec3() == Vec3::ZERO),
             "something transferred from out of range"
         );
 
@@ -13218,7 +13269,7 @@ mod tests {
         let mut proj: crate::app::Project = serde_json::from_str(&std::fs::read_to_string(&file).expect("reads")).expect("parses");
         let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
         proj.sanitize_node_names();
-        proj.migrate_param_refs();
+        proj.migrate_format();
         crate::app::merge_template_defs(&mut proj.root, &templates);
 
         fn find<'a>(n: &'a mut FsNode, ty: &str) -> Option<&'a mut FsNode> {
@@ -13293,7 +13344,7 @@ mod tests {
         let mut proj: crate::app::Project = serde_json::from_str(&std::fs::read_to_string(&file).expect("reads")).expect("parses");
         let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
         proj.sanitize_node_names();
-        proj.migrate_param_refs();
+        proj.migrate_format();
         crate::app::merge_template_defs(&mut proj.root, &templates);
         fn simnet(n: &mut FsNode) -> Option<&mut FsNode> {
             if n.node_type == "simnet" {
@@ -16681,7 +16732,7 @@ mod tests {
     /// presents it as a control as wide as its target — over the wide
     /// span around its value (`value_row_span`) — a slider for one, the float group with two, three
     /// or four rows for more: Modify on Pos (the pull node), on an input
-    /// Float3 (Norm) or Float2 (UV), Create by its Type. A single number is
+    /// Float3 (N) or Float2 (UV), Create by its Type. A single number is
     /// spread over the components, as the node spreads it, and the pane
     /// writing it back unchanged is not an edit. A text that fits no width,
     /// an attribute the input lacks and an expression keep the text box;
@@ -16724,7 +16775,7 @@ mod tests {
         let set = |state: &mut State, name: &str, val: &str| {
             state.fs_root.children[1].params.iter_mut().find(|p| p.name == name).unwrap().set_text(val.to_string());
         };
-        set(&mut state, "Attribute Name", "Norm");
+        set(&mut state, "Attribute Name", "N");
         assert_eq!(value_row(&mut state), wide, "Modify on an input Float3");
         set(&mut state, "Attribute Name", "UV");
         assert_eq!(value_row(&mut state), "text", "three numbers do not fit an input Float2");
@@ -16787,7 +16838,7 @@ mod tests {
         let rows = state.param_mut().node_params();
         assert!(rows.iter().all(|r| r.0 != "Value"), "no Value row");
         let from = rows.iter().find(|r| r.0 == "From Attribute").expect("a From Attribute row");
-        assert!(from.2.starts_with("textpick:") && from.2.contains("Norm"), "{}", from.2);
+        assert!(from.2.starts_with("textpick:") && from.2["textpick:".len()..].split(',').any(|a| a == "N"), "{}", from.2);
         set(&mut state, "Value From", "Constant");
 
         // The parameter itself never changed kind: it is text in the node.
diff --git a/src/project.rs b/src/project.rs
index 44e794a..f1a2129 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -448,7 +448,7 @@ impl State {
         let content = fs::read_to_string(path)?;
         let mut proj: Project = serde_json::from_str(&content)?;
         proj.sanitize_node_names();
-        proj.migrate_param_refs();
+        proj.migrate_format();
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
         self.fs_root = proj.root;
         // Another window's edits, or the bundled file: not this one's to undo.
@@ -530,7 +530,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();
-        proj.migrate_param_refs();
+        proj.migrate_format();
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
         self.fs_root = proj.root;
         self.edit_history.clear();
diff --git a/src/render.rs b/src/render.rs
index 8ea245a..0ed2527 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1437,7 +1437,7 @@ pub(crate) fn scene_point_overlays(
         // Smooth point normals: for each point, the normalized sum of the
         // face normals of the primitives touching it. Template meshes wind
         // CCW seen from outside (the raster culling convention), so the
-        // plain cross(B-A, C-A) points outward. The kernel outputs' Norm
+        // plain cross(B-A, C-A) points outward. The kernel outputs' N
         // attribute is a default up-vector — useless here.
         //
         // The soup had to reconstruct "which triangles touch this point" by
diff --git a/src/shapes.rs b/src/shapes.rs
index 2fe52c2..ab50fed 100644
--- a/src/shapes.rs
+++ b/src/shapes.rs
@@ -113,7 +113,7 @@ pub fn sphere_node_detail(target: &FsNode, legacy_center: Option<Vec3>) -> Detai
     d
 }
 
-/// `Norm`, `UV` and `Cd` from the surface normal, as the sphere has always
+/// `N`, `UV` and `Cd` from the surface normal, as the sphere has always
 /// carried them. Colour is the kernel's: the SIGNED normal folded into
 /// 0..1, world-anchored so a point keeps its colour as the sphere turns.
 fn finish_sphere(d: &mut Detail, center: Vec3, colored: bool) {
@@ -128,7 +128,7 @@ fn finish_sphere(d: &mut Detail, center: Vec3, colored: bool) {
         .map(|n| if colored { [0.5 + n.x * 0.5, 0.5 + n.y * 0.5, 0.5 + n.z * 0.5] } else { DEFAULT_COLOR })
         .collect();
     let points = d.points_mut();
-    let _ = points.insert("Norm", AttribData::Float3(norms));
+    let _ = points.insert("N", AttribData::Float3(norms));
     let _ = points.insert("UV", AttribData::Float2(uvs));
     let _ = points.insert(CD, AttribData::Float3(cds));
 }
@@ -251,7 +251,7 @@ pub fn cuboid_detail(center: Vec3, half: Vec3, color: [f32; 3]) -> Detail {
         d.add_prim(quad);
         norms.extend(std::iter::repeat(normal.to_array()).take(4));
     }
-    let _ = d.verts_mut().insert("Norm", AttribData::Float3(norms));
+    let _ = d.verts_mut().insert("N", AttribData::Float3(norms));
     let _ = d.verts_mut().insert("UV", AttribData::Float2(vec![[0.0, 0.0]; 24]));
     let _ = d.points_mut().insert(CD, AttribData::Float3(vec![color; 8]));
     d
@@ -326,7 +326,7 @@ pub fn plane_node_detail(target: &FsNode) -> Detail {
     }
     let n = d.num_points();
     let points = d.points_mut();
-    let _ = points.insert("Norm", AttribData::Float3(vec![[0.0, 1.0, 0.0]; n]));
+    let _ = points.insert("N", AttribData::Float3(vec![[0.0, 1.0, 0.0]; n]));
     let _ = points.insert("UV", AttribData::Float2(uvs));
     let _ = points.insert(CD, AttribData::Float3(cds));
     d
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 7a7dbbb..325440f 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -29,7 +29,7 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
     // scene shows what opening it would show.
     let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
     proj.sanitize_node_names();
-    proj.migrate_param_refs();
+    proj.migrate_format();
     crate::app::merge_template_defs(&mut proj.root, &templates);
 
     let mut ocl_error = None;