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