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

commitef239d947c8d7d70b90a027ab1f230111476b550
parent89bc11628f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 17:40
feat(develop): Direction names the attribute points move along

Direction is an attribute picker, N by default, replacing the
Normal/Attribute choice and its Source row. N on an input without an N
attribute is the surface normals, so N still means "along the normal".
Format 8 carries a save across: Normal becomes N, Attribute becomes what
Source named. The user's project exports the same mesh before and after.

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

 CLAUDE.md          | 13 +++++++-
 nodes/develop.json |  3 +-
 src/app.rs         | 39 +++++++++++++++++++++--
 src/geometry.rs    | 30 +++++++++++-------
 src/main.rs        | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 5 files changed, 159 insertions(+), 19 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cb7d9c2..78d2150 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1225,7 +1225,18 @@ and Distance, which are left alone. Checked on the user's project (four
 Attribute nodes, none a Remap): the old build and the new export the same
 mesh at frames 1 and 30. **Format 7** (the same day): Composite's Length
 is the length of Name — see "Composite writes a Result" under "Pull
-arrows". Templates go through
+arrows". **Format 8** (the same day): Develop's **Direction** is an
+`attribute` row naming the vector points move along, `N` by default,
+where it was a Normal / Attribute choice beside a Source row
+(`Project::migrate_develop_direction`: Normal → `N`, Attribute → what
+Source named, the Source row dropped). `N` on an input that carries none
+is the surface's point normals, as a wrangle's `@N` reads it, so `N` is
+"along the normal" either way; any other name the input lacks is an error
+on the node. Checked on the user's project: the old build and the new
+export the same mesh at frames 1 and 30, and the develop node alone.
+`develop_moves_along_the_attribute_its_direction_names` and
+`an_older_develop_direction_becomes_an_attribute_name` are the tests.
+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/nodes/develop.json b/nodes/develop.json
index 6a8eef1..c35926b 100644
--- a/nodes/develop.json
+++ b/nodes/develop.json
@@ -7,8 +7,7 @@
   { "name": "input", "label": "Input", "type": "node", "default": "", "description": "The node whose surface is displaced by a growth attribute." },
   { "name": "attribute", "label": "Attribute", "type": "attribute", "default": "growth", "description": "Name of the point attribute giving how far each point moves; its value times Scale is the distance. Empty leaves the geometry unchanged." },
   { "name": "scale", "label": "Scale", "type": "slider", "default": "0.10", "min": -2.0, "max": 2.0, "step": 0.01, "description": "Multiplier on the attribute's value, giving the displacement in world units per unit of attribute. Negative moves points the opposite way." },
-  { "name": "direction", "label": "Direction", "type": "choice:Normal,Attribute", "default": "Normal", "description": "Normal moves each point along its surface normal. Attribute moves it along a vector attribute named by Source instead." },
-  { "name": "source", "label": "Source", "type": "attribute", "default": "", "description": "Name of the vector point attribute used as the direction when Direction is Attribute. Its length also scales the move." },
+  { "name": "direction", "label": "Direction", "type": "attribute", "default": "N", "description": "The vector point attribute each point moves along; its length also scales the move. N is the surface normal, worked out from the surface when the input carries no N." },
   { "name": "group", "label": "Group", "type": "group", "default": "", "description": "Point group to displace; other points stay put. Empty displaces every point.", "group": "where" }
  ]
 }
diff --git a/src/app.rs b/src/app.rs
index 85a74e0..785bc8f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -398,7 +398,8 @@ pub struct Project {
     /// (`context::wrap_root_geometry`); 6 (2026-10-06) a range's two ends as
     /// one `float2` (`Project::migrate_range_rows`); 7 (the same day)
     /// Composite's Length as the length of Name, not of Source B
-    /// (`Project::migrate_composite_length`).
+    /// (`Project::migrate_composite_length`); 8 (the same day) Develop's
+    /// Direction as an attribute name (`Project::migrate_develop_direction`).
     /// `migrate_format` takes a file through each step it is behind, and a
     /// step must not run twice.
     #[serde(default)]
@@ -406,7 +407,7 @@ pub struct Project {
 }
 
 /// The format `Project` saves in — see its `format` field.
-pub const PROJECT_FORMAT: u32 = 7;
+pub const PROJECT_FORMAT: u32 = 8;
 
 /// One entry in a node's right-click context menu, parallel to the visible
 /// labels shown via `context_menu::show`.
@@ -1401,9 +1402,43 @@ impl Project {
         if self.format < 7 {
             self.migrate_composite_length();
         }
+        if self.format < 8 {
+            self.migrate_develop_direction();
+        }
         self.format = PROJECT_FORMAT;
     }
 
+    /// Format 7 → 8: Develop's Direction names the vector attribute points
+    /// move along, where it was a choice — Normal, or Attribute with the
+    /// attribute in a Source row. Normal becomes `N` (the normal, worked out
+    /// when the input carries no N, as before), Attribute becomes what Source
+    /// named, its text and expression flag both, and the Source row goes. A
+    /// node from before the choice, with neither row, is left to the merge,
+    /// which gives it `N`: what it moved along.
+    fn migrate_develop_direction(&mut self) {
+        fn walk(node: &mut FsNode) {
+            if node.node_type.eq_ignore_ascii_case("develop") {
+                if let Some(si) = node.params.iter().position(|p| p.name == "source") {
+                    let source = node.params.remove(si);
+                    if let Some(d) = node.params.iter_mut().find(|p| p.name == "direction") {
+                        let by_attr = !d.is_expr() && d.text().trim().eq_ignore_ascii_case("attribute");
+                        if by_attr && !source.text().trim().is_empty() {
+                            d.set_text(source.text().to_string());
+                            d.set_expr(source.is_expr());
+                        } else {
+                            d.set_text("N".to_string());
+                            d.set_expr(false);
+                        }
+                    }
+                }
+            }
+            for c in &mut node.children {
+                walk(c);
+            }
+        }
+        walk(&mut self.root);
+    }
+
     /// Format 6 → 7: Composite's Length is the length of NAME, where it was
     /// the length of Source B written into Name (or into Result, for the
     /// day Result existed before this). So a saved Length node computes
diff --git a/src/geometry.rs b/src/geometry.rs
index 11f5d42..770457f 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -3801,25 +3801,31 @@ pub(crate) fn apply_develop(geom: &mut Detail, target: &FsNode, ocl_error: &mut
     let scale = node_param_f32(target, "scale", 0.1);
     let group = node_param_str(target, "group", "");
     let group = group.trim().to_string();
-    let by_attr = node_param_str(target, "direction", "Normal").eq_ignore_ascii_case("attribute");
-    let src = node_param_str(target, "source", "");
-    let src = src.trim().to_string();
+    // Direction names a vector point attribute, N for the normal (since
+    // 2026-10-06; it was a Normal/Attribute choice beside a Source row).
+    // Empty is N. An input without an N attribute moves along the normals
+    // of its surface, as a wrangle's `@N` reads them.
+    let dir = node_param_str(target, "direction", "N");
+    let dir = match dir.trim() {
+        "" => "N".to_string(),
+        d => d.to_string(),
+    };
 
-    // Normals come off the geometry as it arrives, so every point is displaced
+    // Read off the geometry as it arrives, so every point is displaced
     // along the surface it had BEFORE the displacement — otherwise the points
     // computed late would be following a surface the earlier ones had already
     // moved, and the result would depend on point order.
-    let dirs: Vec<Vec3> = if by_attr {
+    let dirs: Vec<Vec3> = if geom.points().has(&dir) {
         (0..geom.num_points())
-            .map(|p| {
-                geom.points()
-                    .value(&src, p)
-                    .map(|v| v.as_vec3())
-                    .unwrap_or(Vec3::ZERO)
-            })
+            .map(|p| geom.points().value(&dir, p).map(|v| v.as_vec3()).unwrap_or(Vec3::ZERO))
             .collect()
-    } else {
+    } else if dir == "N" {
         point_normals(geom)
+    } else {
+        if ocl_error.is_none() {
+            *ocl_error = Some(format!("Develop '{}': no point attribute named '{}' for the direction", target.name, dir));
+        }
+        return;
     };
 
     for p in 0..geom.num_points() {
diff --git a/src/main.rs b/src/main.rs
index 2945ebc..f88ebbb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5557,7 +5557,7 @@ mod tests {
         assert_eq!(strength.text(), "1.00");
         for (ty, name) in [
             ("attribute", "attribute_name"), ("attribute", "source_b"), ("visualize", "attribute"), ("neighbour", "attribute"),
-            ("neighbour", "direction"), ("neighbour", "source"), ("distance", "direction"), ("develop", "source"),
+            ("neighbour", "direction"), ("neighbour", "source"), ("distance", "direction"), ("develop", "direction"),
             ("copy", "scale_attribute"), ("normal", "attribute"), ("suture", "counter"), ("time", "attribute"),
         ] {
             assert_eq!(kind(ty, name), K::Attribute, "{ty}'s {name}");
@@ -13151,7 +13151,7 @@ mod tests {
             name: "Develop 1".into(),
             node_type: "develop".into(),
             children: vec![],
-            params: [("attribute", "growth"), ("scale", "0.50"), ("direction", "Normal")]
+            params: [("attribute", "growth"), ("scale", "0.50"), ("direction", "N")]
                 .into_iter()
                 .map(|(name, default)| crate::app::ParamDef::new(name, "text", default))
                 .collect(),
@@ -13180,6 +13180,95 @@ mod tests {
         assert_eq!(out.ids(), sphere.ids());
     }
 
+    /// Develop's Direction names the attribute points move along: N is the
+    /// normal, worked out when the input carries none; any other vector
+    /// attribute is followed, its length scaling the move; a name the input
+    /// lacks is an error and moves nothing.
+    #[test]
+    fn develop_moves_along_the_attribute_its_direction_names() {
+        let mut sphere = sphere_detail(Vec3::ZERO, 1.0, 8, 12);
+        sphere.points_mut().create("growth", AttribValue::Float(1.0));
+        sphere.points_mut().create("up", AttribValue::Float3([0.0, 2.0, 0.0]));
+        let run = |geom: &Detail, dir: &str| {
+            let node = crate::app::FsNode {
+                id: "d".into(),
+                name: "develop1".into(),
+                node_type: "develop".into(),
+                children: vec![],
+                params: [("attribute", "growth"), ("scale", "0.50"), ("direction", dir)]
+                    .into_iter()
+                    .map(|(name, v)| crate::app::ParamDef::new(name, "text", v))
+                    .collect(),
+                geometry_visible: true,
+                bypassed: false,
+                position: (0.0, 0.0),
+                inputs: 1,
+                outputs: 1,
+            };
+            let mut out = geom.clone();
+            let mut err = None;
+            crate::geometry::apply_develop(&mut out, &node, &mut err);
+            (out, err)
+        };
+        // Along `up`, length 2: every point one unit up.
+        let (out, err) = run(&sphere, "up");
+        assert!(err.is_none(), "{err:?}");
+        for p in 0..out.num_points() {
+            assert!((out.pos(p) - sphere.pos(p) - Vec3::new(0.0, 1.0, 0.0)).length() < 1e-5);
+        }
+        // N on an input without one: the surface normals, outward.
+        let mut bare = sphere.clone();
+        bare.points_mut().remove("N");
+        assert!(!bare.points().has("N"));
+        let (out, err) = run(&bare, "N");
+        assert!(err.is_none(), "{err:?}");
+        assert!((0..out.num_points()).all(|p| (out.pos(p).length() - 1.5).abs() < 0.05));
+        // An N the input carries is followed as it is.
+        let mut with_n = sphere.clone();
+        with_n.points_mut().remove("N");
+        with_n.points_mut().create("N", AttribValue::Float3([1.0, 0.0, 0.0]));
+        let (out, err) = run(&with_n, "N");
+        assert!(err.is_none(), "{err:?}");
+        assert!((out.pos(0) - with_n.pos(0) - Vec3::new(0.5, 0.0, 0.0)).length() < 1e-5);
+        // A name the input lacks moves nothing and says so.
+        let (out, err) = run(&sphere, "nope");
+        assert!(err.is_some_and(|e| e.contains("nope")));
+        assert_eq!(out.pos(3), sphere.pos(3));
+    }
+
+    /// Format 8: an older Develop's Normal / Attribute choice and its Source
+    /// row become one Direction naming the attribute.
+    #[test]
+    fn an_older_develop_direction_becomes_an_attribute_name() {
+        use crate::app::{FsNode, ParamDef, Project};
+        let develop = |id: &str, dir: &str, src: &str| FsNode {
+            id: id.into(),
+            name: id.into(),
+            node_type: "develop".into(),
+            children: vec![],
+            params: vec![ParamDef::new("direction", "choice:Normal,Attribute", dir), ParamDef::new("source", "attribute", src)],
+            geometry_visible: true,
+            bypassed: false,
+            position: (0.0, 0.0),
+            inputs: 1,
+            outputs: 1,
+        };
+        let mut root = develop("root", "", "");
+        root.node_type = "subnet".into();
+        root.params.clear();
+        root.children = vec![develop("by_n", "Normal", "vel"), develop("by_attr", "Attribute", "vel"), develop("attr_empty", "Attribute", "")];
+        let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 7 };
+        proj.migrate_format();
+        let dir = |i: usize| {
+            let n = &proj.root.children[i];
+            assert!(!n.params.iter().any(|p| p.name == "source"), "the Source row goes");
+            n.params.iter().find(|p| p.name == "direction").unwrap().text().to_string()
+        };
+        assert_eq!(dir(0), "N", "Normal is the normal");
+        assert_eq!(dir(1), "vel", "Attribute is what Source named");
+        assert_eq!(dir(2), "N", "an Attribute naming nothing moved along nothing; N is the sane reading");
+    }
+
     fn phase3_node(ty: &str, params: &[(&str, &str)]) -> FsNode {
         FsNode {
             id: format!("id-{ty}"),