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