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

commit0806eb6f0ec730676b842f9853325ed0defd135b
parent9b1376a416
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 18:08
refactor: the generators' texture coordinates are uv, not UV

Project format 3 migrates an older save once, as format 2 did for N: an
attribute row naming UV, UV in an Attributes list, @UV in wrangle code; a
choice row (the Sphere's Method option UV) is untouched. The Norm step and
this one share Project::rename_attribute.

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

 CLAUDE.md       | 13 ++++++++-----
 src/app.rs      | 51 +++++++++++++++++++++++++++++----------------------
 src/geometry.rs | 12 ++++++------
 src/main.rs     | 40 +++++++++++++++++++++++++++++-----------
 src/shapes.rs   |  8 ++++----
 5 files changed, 76 insertions(+), 48 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cb079a0..742c9e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -998,11 +998,14 @@ 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
+wrangle's `@N` already said `N`. **Format 3** (the same day): their
+texture coordinates are `uv`, where they were `UV`. Both are
+`Project::rename_attribute` steps, which rewrite what names the old name
+in an older save: an attribute row, a name in a comma list of attributes
+(`Attributes`), `@old` in a wrangle's Code as a whole name (not `@Normal`,
+not `@UVW`) — and never a choice row, so the Sphere's Method keeps its
+`UV` option. Once, by the version, so an attribute someone names `Norm` or
+`UV` afterwards is theirs (`a_save_naming_norm_or_uv_names_n_or_uv`). 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 f7cad64..7afb033 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -368,7 +368,8 @@ pub struct Project {
     /// 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; 2 (2026-10-01) is the
-    /// generators' normal attribute called `N` where it was `Norm`.
+    /// generators' normal attribute called `N` where it was `Norm`, and 3
+    /// (the same day) their texture coordinates `uv` where they were `UV`.
     /// `migrate_format` takes a file through each step it is behind, and a
     /// step must not run twice.
     #[serde(default)]
@@ -376,7 +377,7 @@ pub struct Project {
 }
 
 /// The format `Project` saves in — see its `format` field.
-pub const PROJECT_FORMAT: u32 = 2;
+pub const PROJECT_FORMAT: u32 = 3;
 
 /// One entry in a node's right-click context menu, parallel to the visible
 /// labels shown via `context_menu::show`.
@@ -1204,7 +1205,10 @@ impl Project {
             self.migrate_param_refs();
         }
         if self.format < 2 {
-            self.migrate_norm_to_n();
+            self.rename_attribute("Norm", "N");
+        }
+        if self.format < 3 {
+            self.rename_attribute("UV", "uv");
         }
         self.format = PROJECT_FORMAT;
     }
@@ -1227,38 +1231,41 @@ impl Project {
         walk(&mut self.root);
     }
 
-    /// 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) {
+    /// A step that renames an attribute the generators write, `old` to
+    /// `new` — format 1 → 2 `Norm` → `N` (as the Normal node, the exporter
+    /// and a wrangle's `@N` already named it), 2 → 3 `UV` → `uv` (the
+    /// lowercase every other built-in name has) — and what names it in a
+    /// save follows: a parameter naming an attribute
+    /// (`ParamKind::Attribute`) that says `old`, a name in a comma list of
+    /// attributes (Transfer's and the Remesh's `Attributes`), and `@old` in
+    /// a wrangle's Code. A choice row is not touched (the Sphere's Method
+    /// keeps its `UV` option). Once, by the version — an attribute someone
+    /// names `old` after this is theirs.
+    fn rename_attribute(&mut self, old: &str, new: &str) {
+        fn walk(node: &mut FsNode, old: &str, new: &str) {
             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"))
+                let renamed = if p.kind() == ParamKind::Attribute && text.trim() == old {
+                    Some(new.to_string())
+                } else if p.name == "Attributes" && text.split(',').any(|a| a.trim() == old) {
+                    Some(text.split(',').map(|a| if a.trim() == old { a.replace(old, new) } else { a.to_string() }).collect::<Vec<_>>().join(","))
+                } else if p.kind() == ParamKind::Code && text.contains(&format!("@{old}")) {
+                    Some(rename_at_attribute(&text, old, new))
                 } else {
                     None
                 };
-                if let Some(new) = new.filter(|n| *n != text) {
-                    p.set_text(new);
+                if let Some(renamed) = renamed.filter(|n| *n != text) {
+                    p.set_text(renamed);
                 }
             }
             for c in &mut node.children {
-                walk(c);
+                walk(c, old, new);
             }
         }
-        walk(&mut self.root);
+        walk(&mut self.root, old, new);
     }
 }
 
diff --git a/src/geometry.rs b/src/geometry.rs
index e5e109a..50cd726 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.
 ///
-/// `N` 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
@@ -363,7 +363,7 @@ pub fn sphere_detail(center: Vec3, radius: f32, lat_steps: usize, lon_steps: usi
         .collect();
     let points = d.points_mut();
     let _ = points.insert("N", AttribData::Float3(norms));
-    let _ = points.insert("UV", AttribData::Float2(uvs));
+    let _ = points.insert("uv", AttribData::Float2(uvs));
     let _ = points.insert(CD, AttribData::Float3(cds));
     d
 }
@@ -437,7 +437,7 @@ pub fn box_detail(start: Vec3, end: Vec3, thickness: f32) -> Detail {
     let _ = d.verts_mut().insert("N", AttribData::Float3(norms));
     let _ = d
         .verts_mut()
-        .insert("UV", AttribData::Float2(vec![[0.0, 0.0]; num_verts]));
+        .insert("uv", AttribData::Float2(vec![[0.0, 0.0]; num_verts]));
     // A distinct color for lines.
     let _ = d
         .points_mut()
@@ -9500,14 +9500,14 @@ mod simnet_tests {
         }
 
         // Scale By: each point by its own value of the attribute — the
-        // sphere's UV, whose first component runs around it — times Strength.
-        let root = pull_of(vec![("Strength", "0.50"), ("Scale By", "UV")], "Add", "0.00:0.06:0.00");
+        // sphere's uv, whose first component runs around it — times Strength.
+        let root = pull_of(vec![("Strength", "0.50"), ("Scale By", "uv")], "Add", "0.00:0.06:0.00");
         let base = eval(&root, "Sphere 1");
         let (d, err) = moved_by(&root, 1);
         assert!(err.is_none(), "{err:?}");
         let mut weights = Vec::new();
         for (p, moved) in d.iter().enumerate() {
-            let w = base.points().value("UV", p).expect("the sphere carries UV").as_f32();
+            let w = base.points().value("uv", p).expect("the sphere carries uv").as_f32();
             assert!(moved.distance(up * 0.5 * w) < 1e-5, "point {p} weighs {w}: {moved:?}");
             weights.push(w);
         }
diff --git a/src/main.rs b/src/main.rs
index 258c787..405115c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -686,13 +686,14 @@ mod tests {
         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
+    /// Format 1 → 2 and 2 → 3: the generators' normal attribute is `N`
+    /// and their texture coordinates `uv`, and what names `Norm` / `UV` in
+    /// a save follows them — 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() {
+    fn a_save_naming_norm_or_uv_names_n_or_uv() {
         use crate::app::{FsNode, ParamDef, Project, PROJECT_FORMAT};
         let node = |name: &str, ty: &str, params: Vec<ParamDef>| FsNode {
             id: name.into(),
@@ -718,7 +719,7 @@ mod tests {
         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, 1), "Cd, N,uv", "every step a file is behind: N, then uv");
         assert_eq!(text(&proj, 2), "@P += @N * 0.1; @Normal = 1;");
         assert_eq!(text(&proj, 3), "Normx", "only the whole name");
 
@@ -731,6 +732,23 @@ mod tests {
         // 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"));
+
+        // Format 2 → 3: UV is uv, the same way; a choice row keeps its UV
+        // (the Sphere's Method), and a format-2 file takes only this step.
+        let mut root = node("root", "subnet", vec![]);
+        root.children = vec![
+            node("vis", "visualize", vec![ParamDef::new("Attribute", "attribute", "UV")]),
+            node("w", "wrangle", vec![ParamDef::new("Code", "code", "@P.y = @UV.x; @UVW = 1;")]),
+            node("ball", "sphere", vec![ParamDef::new("Method", "choice:UV,Icosphere,Cube", "UV")]),
+            node("n", "visualize", vec![ParamDef::new("Attribute", "attribute", "Norm")]),
+        ];
+        let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 2 };
+        proj.migrate_format();
+        assert_eq!(text(&proj, 0), "uv");
+        assert_eq!(text(&proj, 1), "@P.y = @uv.x; @UVW = 1;");
+        assert_eq!(text(&proj, 2), "UV", "a choice is not an attribute");
+        assert_eq!(text(&proj, 3), "Norm", "a format-2 file is past the N step");
+        assert!(s.points().has("uv") && !s.points().has("UV"));
     }
 
     #[test]
@@ -3700,7 +3718,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("N") && 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"
@@ -4102,7 +4120,7 @@ mod tests {
             let mid = pts.iter().map(|&q| g.pos(q as usize)).sum::<Vec3>() / pts.len() as f32;
             assert!(n.dot(mid - centre) > 0.0, "primitive {pr} faces inward");
         }
-        assert!(g.points().has("Cd") && g.points().has("UV"), "point attributes ride to the top");
+        assert!(g.points().has("Cd") && g.points().has("uv"), "point attributes ride to the top");
 
         let root2 = ref_node("root", "root", "node", vec![], vec![plane, extrude("false")]);
         let g2 = eval(&root2, &root2.children[1]).0.unwrap();
@@ -4491,7 +4509,7 @@ mod tests {
         };
         let attr_ty = row("Attribute Name");
         assert!(attr_ty.starts_with("textpick:"), "got {attr_ty}");
-        for expected in ["N", "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");
@@ -5824,9 +5842,9 @@ mod tests {
         // A box line fans to 36 renderer vertices: 6 faces * 2 triangles * 3 corners.
         assert_eq!(detail_vertices(&d).len(), 36);
 
-        // N and UV ride the vertices, one per corner.
+        // N and uv ride the vertices, one per corner.
         assert!(d.verts().has("N"));
-        assert!(d.verts().has("UV"));
+        assert!(d.verts().has("uv"));
     }
 
     /// The reference cube guide is gone, and nothing that used to carry it
@@ -16732,7 +16750,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 (N) 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;
@@ -16777,7 +16795,7 @@ mod tests {
         };
         set(&mut state, "Attribute Name", "N");
         assert_eq!(value_row(&mut state), wide, "Modify on an input Float3");
-        set(&mut state, "Attribute Name", "UV");
+        set(&mut state, "Attribute Name", "uv");
         assert_eq!(value_row(&mut state), "text", "three numbers do not fit an input Float2");
         set(&mut state, "Value", "1:2");
         assert_eq!(value_row(&mut state), "float2:-10:10:soft", "Modify on an input Float2: 2 needs ±10");
diff --git a/src/shapes.rs b/src/shapes.rs
index ab50fed..79750dc 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
 }
 
-/// `N`, `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) {
@@ -129,7 +129,7 @@ fn finish_sphere(d: &mut Detail, center: Vec3, colored: bool) {
         .collect();
     let points = d.points_mut();
     let _ = points.insert("N", AttribData::Float3(norms));
-    let _ = points.insert("UV", AttribData::Float2(uvs));
+    let _ = points.insert("uv", AttribData::Float2(uvs));
     let _ = points.insert(CD, AttribData::Float3(cds));
 }
 
@@ -252,7 +252,7 @@ pub fn cuboid_detail(center: Vec3, half: Vec3, color: [f32; 3]) -> Detail {
         norms.extend(std::iter::repeat(normal.to_array()).take(4));
     }
     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.verts_mut().insert("uv", AttribData::Float2(vec![[0.0, 0.0]; 24]));
     let _ = d.points_mut().insert(CD, AttribData::Float3(vec![color; 8]));
     d
 }
@@ -327,7 +327,7 @@ pub fn plane_node_detail(target: &FsNode) -> Detail {
     let n = d.num_points();
     let points = d.points_mut();
     let _ = points.insert("N", AttribData::Float3(vec![[0.0, 1.0, 0.0]; n]));
-    let _ = points.insert("UV", AttribData::Float2(uvs));
+    let _ = points.insert("uv", AttribData::Float2(uvs));
     let _ = points.insert(CD, AttribData::Float3(cds));
     d
 }