graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(attribute): the attribute vocabulary — remap, clip, normalize, composite, promote, analysis, time
The Attribute node grows the operations that transform an attribute's values
rather than create or delete it, and two nodes join it:
- Remap fits a range onto another; Clip clamps into one. They read the same
From Min / From Max, so the two chain without renaming anything between them.
- Normalize MEASURES before it scales — sum, maximum or range onto a target —
which is what lets it sit in a solve whose range moves every frame, where a
Remap with hardcoded bounds cannot.
- Composite folds a second attribute in, componentwise, with the usual
arithmetic plus dot, distance and length. Division by zero yields the
numerator: an infinity here poisons every later frame of a solve.
- Promote moves an attribute between points and detail, by average, sum,
minimum, maximum or first.
- Analysis measures an attribute — or the mesh's edge lengths, the same
reduction over a different column, and the one a remesher steers by — and
writes min, max, sum, average, spread and count.
- Time runs 0 to 1 across a frame range, read off the evaluation so a scrub
moves it. A zero-length range reads as "elapsed" rather than dividing by
zero.
Analysis is decision 3 paying off. Houdini writes an <attr>_info DICTIONARY;
here those are six ordinary detail attributes named <attr>_min … <attr>_count,
so nothing had to learn to index a dict, Promote and Composite can read them
back with no special case, and the spreadsheet shows them for free.
Which it now does: detail attributes ride along as `d:` columns, constant down
the table, because that is what a detail attribute is. Without it Analysis
would write its answers somewhere nothing could show them.
The test worth reading is test_measure_then_remap_is_the_chain_the_detail_
class_exists_for: Analysis measures, Promote lifts the answer back to points,
Composite divides by it. A normalization that survives a moving range,
assembled out of the vocabulary rather than hardcoded into a node — which is
the whole argument for collapsing fifty operators into ten.
apply_attribute is split from its resolver, as apply_neighbour already was.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/analysis.json | 12 +
nodes/attribute.json | 47 +++-
nodes/time.json | 13 ++
shapeshifter.md | 13 +-
src/app.rs | 37 +++
src/geometry.rs | 637 ++++++++++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 7 +-
7 files changed, 759 insertions(+), 7 deletions(-)
diff --git a/nodes/analysis.json b/nodes/analysis.json
new file mode 100644
index 0000000..0331a01
--- /dev/null
+++ b/nodes/analysis.json
@@ -0,0 +1,12 @@
+{
+ "name": "Analysis",
+ "type": "analysis",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Source", "type": "choice:Attribute,Edge Lengths", "default": "Attribute" },
+ { "name": "Attribute", "type": "text", "default": "mass" },
+ { "name": "Group", "type": "text", "default": "" }
+ ]
+}
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 91be42a..693a6f0 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -11,7 +11,7 @@
},
{
"name": "Operation",
- "type": "choice:Create,Modify,Delete",
+ "type": "choice:Create,Modify,Delete,Remap,Clip,Normalize,Composite,Promote",
"default": "Create"
},
{
@@ -38,6 +38,51 @@
"name": "Group",
"type": "text",
"default": ""
+ },
+ {
+ "name": "From Min",
+ "type": "text",
+ "default": "0.00"
+ },
+ {
+ "name": "From Max",
+ "type": "text",
+ "default": "1.00"
+ },
+ {
+ "name": "To Min",
+ "type": "text",
+ "default": "0.00"
+ },
+ {
+ "name": "To Max",
+ "type": "text",
+ "default": "1.00"
+ },
+ {
+ "name": "Source B",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Combine Op",
+ "type": "choice:Add,Subtract,Multiply,Divide,Minimum,Maximum,Average,Difference,Dot,Distance,Length",
+ "default": "Add"
+ },
+ {
+ "name": "To Class",
+ "type": "choice:Detail,Point",
+ "default": "Detail"
+ },
+ {
+ "name": "Method",
+ "type": "choice:Average,Sum,Minimum,Maximum,First",
+ "default": "Average"
+ },
+ {
+ "name": "Target",
+ "type": "choice:Sum,Maximum,Range",
+ "default": "Maximum"
}
]
}
diff --git a/nodes/time.json b/nodes/time.json
new file mode 100644
index 0000000..dcd518f
--- /dev/null
+++ b/nodes/time.json
@@ -0,0 +1,13 @@
+{
+ "name": "Time",
+ "type": "time",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Attribute", "type": "text", "default": "t" },
+ { "name": "Start Frame", "type": "spinbox", "default": "1", "min": -9999.0, "max": 9999.0, "step": 1.0 },
+ { "name": "End Frame", "type": "spinbox", "default": "100", "min": -9999.0, "max": 9999.0, "step": 1.0 },
+ { "name": "Clamp", "type": "choice:true,false", "default": "true" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index c2429f9..cc4a8da 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -134,9 +134,15 @@ Spreadsheet, the meta overlays, `project.rs`.
> radius or global, componentwise on any attribute type. Per decision 1 the
> neighbourhood walk stays out of the kernel language.
>
-> Outstanding: the generator ABI (still a corner list out), the vector
-> steering modes (Align, Lead, Charge), and the rest of the attribute
-> vocabulary — Initialize, Remap, Clip, Composite, Promote, Analysis, Time.
+> The attribute vocabulary is in too. `attribute` grew Remap, Clip, Normalize,
+> Composite and Promote alongside Create/Modify/Delete; `analysis` writes min,
+> max, sum, average, spread and count to DETAIL attributes — five ordinary
+> attributes where Houdini writes an `<attr>_info` dictionary, which is
+> decision 3 paying off; `time` runs 0 to 1 across a frame range. The
+> spreadsheet shows detail attributes as `d:` columns.
+>
+> Outstanding: the generator ABI (still a corner list out) and the vector
+> steering modes (Align, Lead, Charge).
Widen the kernel ABI from `(in_pos, in_col, out_pos, out_col, params)` to
**named attribute buffers bound by the node**, plus the topology arrays as
@@ -263,6 +269,7 @@ Vector families already collapsed into one in September 2026.
| Node in cce-designer | Absorbs | From |
|---|---|---|
| `attribute` | Attribute Initialize, Constant, Clip, Remap, Combine, Composite, Promote, Select, Normalize, Weight | 10 → 1 |
+| | *(landed)* | |
| `neighbour` | Diffuse, Concentrate, Migrate, Bleed, Align, Lead, Charge — one Mode, one Neighbourhood | 7 → 1 |
| | *(first four landed; the vector steering modes remain)* | |
| `gradient` | Gradient, Rotate, Direction | 3 → 1 |
diff --git a/src/app.rs b/src/app.rs
index cdd72e9..448f209 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3200,6 +3200,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
headers.push(format!("g:{}", g));
}
+ // Detail attributes ride along as `d:` columns, constant down the table —
+ // which is what a detail attribute IS. Without this the Analysis node
+ // would write its answers somewhere nothing could show them.
+ let detail: Vec<(String, crate::detail::AttribType)> = geom
+ .detail()
+ .names()
+ .into_iter()
+ .filter_map(|n| geom.detail().get(n).map(|a| (n.to_string(), a.ty())))
+ .collect();
+ for (name, ty) in &detail {
+ match ty.components() {
+ 1 => headers.push(format!("d:{}", name)),
+ n => {
+ for c in ["x", "y", "z", "w"].iter().take(n) {
+ headers.push(format!("d:{}.{}", name, c));
+ }
+ }
+ }
+ }
+
let mut rows = Vec::new();
for p in 0..geom.num_points() {
let pos = geom.positions()[p];
@@ -3237,6 +3257,23 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
row.push(if geom.points().in_group(g, p) { "1".to_string() } else { String::new() });
}
+ for (name, ty) in &detail {
+ match geom.detail().value(name, 0) {
+ Some(crate::detail::AttribValue::Float(f)) => row.push(format!("{:.4}", f)),
+ Some(crate::detail::AttribValue::Int(i)) => row.push(i.to_string()),
+ Some(crate::detail::AttribValue::Float2(a)) => {
+ row.extend(a.iter().map(|v| format!("{:.4}", v)))
+ }
+ Some(crate::detail::AttribValue::Float3(a)) => {
+ row.extend(a.iter().map(|v| format!("{:.4}", v)))
+ }
+ Some(crate::detail::AttribValue::Float4(a)) => {
+ row.extend(a.iter().map(|v| format!("{:.4}", v)))
+ }
+ None => row.extend(std::iter::repeat("-".to_string()).take(ty.components())),
+ }
+ }
+
rows.push(row);
}
diff --git a/src/geometry.rs b/src/geometry.rs
index 8379a5b..7ad9c06 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -730,6 +730,10 @@ pub fn generate_single_node_geometry_with_errors(
resolve_relax_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("neighbour") {
resolve_neighbour_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("time") {
+ resolve_time_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("analysis") {
+ resolve_analysis_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("attribute") {
resolve_attribute_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("opencl") {
@@ -1274,6 +1278,146 @@ pub fn resolve_relax_geometry_with_errors(
Some(geom)
}
+/// The Analysis node: measure an attribute (or the mesh's edge lengths) and
+/// write the result to DETAIL attributes.
+///
+/// This is what the detail class is for, and why the port needed no dictionary
+/// type. Houdini's Analysis writes an `<attr>_info` dictionary holding min,
+/// max, sum, average and spread; here those are five ordinary detail
+/// attributes named `<attr>_min` … `<attr>_spread`, which the Attribute node's
+/// Promote and Remap can read back without anything learning to index a dict.
+///
+/// Measuring edge lengths instead of an attribute is the same reduction over a
+/// different column, and it is the measurement a remesher steers by — so it
+/// shares the node rather than getting one of its own.
+pub fn resolve_analysis_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_name = node_param_str(target, "Input", "");
+ if input_name.is_empty() {
+ return None;
+ }
+ let input_node = find_node_by_name(root, &input_name)?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ apply_analysis(&mut geom, target, ocl_error);
+ Some(geom)
+}
+
+pub(crate) fn apply_analysis(geom: &mut Detail, target: &FsNode, ocl_error: &mut Option<String>) {
+ let edges = node_param_str(target, "Source", "Attribute").eq_ignore_ascii_case("edge lengths");
+ let name = node_param_str(target, "Attribute", "").trim().to_string();
+ let group = node_param_str(target, "Group", "");
+ let group = group.trim().to_string();
+
+ // The column to reduce, and the name its answers hang off.
+ let (label, values) = if edges {
+ let lengths: Vec<f32> = geom
+ .edges()
+ .iter()
+ .map(|e| (geom.pos(e[1] as usize) - geom.pos(e[0] as usize)).length())
+ .collect();
+ ("edges".to_string(), lengths)
+ } else {
+ if name.is_empty() {
+ return;
+ }
+ if !geom.points().has(&name) {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!(
+ "Analysis '{}': no point attribute named '{}'",
+ target.name, name
+ ));
+ }
+ return;
+ }
+ let vals: Vec<f32> = (0..geom.num_points())
+ .filter(|&p| group.is_empty() || geom.points().in_group(&group, p))
+ .filter_map(|p| geom.points().value(&name, p))
+ .map(|v| v.as_f32())
+ .collect();
+ (name.clone(), vals)
+ };
+
+ // Nothing to measure is not an error — an empty group or a point cloud
+ // with no edges is a legitimate state mid-chain. The answers are zeroed so
+ // a downstream reader still finds the attributes it expects.
+ let n = values.len();
+ let (min, max, sum) = if n == 0 {
+ (0.0, 0.0, 0.0)
+ } else {
+ (
+ values.iter().copied().fold(f32::INFINITY, f32::min),
+ values.iter().copied().fold(f32::NEG_INFINITY, f32::max),
+ values.iter().sum::<f32>(),
+ )
+ };
+ let average = if n == 0 { 0.0 } else { sum / n as f32 };
+ let spread = max - min;
+
+ for (suffix, value) in [
+ ("min", min),
+ ("max", max),
+ ("sum", sum),
+ ("average", average),
+ ("spread", spread),
+ ("count", n as f32),
+ ] {
+ geom.detail_mut()
+ .create(&format!("{}_{}", label, suffix), AttribValue::Float(value));
+ }
+}
+
+/// The Time node: a detail attribute running 0 to 1 across a frame range.
+///
+/// The simplest node in the set and the one that makes a simnet's chain able
+/// to know where it is: everything time-varying downstream reads this rather
+/// than each node growing its own frame parameters. It reads the frame off the
+/// evaluation, so a scrub moves it and a cached solve does not.
+pub fn resolve_time_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_name = node_param_str(target, "Input", "");
+ if input_name.is_empty() {
+ return None;
+ }
+ let input_node = find_node_by_name(root, &input_name)?;
+ let frame = sim.frame;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ apply_time(&mut geom, target, frame);
+ Some(geom)
+}
+
+pub(crate) fn apply_time(geom: &mut Detail, target: &FsNode, frame: i32) {
+ let name = node_param_str(target, "Attribute", "t").trim().to_string();
+ if name.is_empty() {
+ return;
+ }
+ let start = node_param_f32(target, "Start Frame", 1.0);
+ let end = node_param_f32(target, "End Frame", 100.0);
+ let span = end - start;
+ // A zero-length range is 1.0 from its first frame on, not a division by
+ // zero: "the whole range has elapsed" is the only reading that composes.
+ let t = if span.abs() < 1e-9 {
+ if frame as f32 >= start { 1.0 } else { 0.0 }
+ } else {
+ (frame as f32 - start) / span
+ };
+ let t = if node_param_str(target, "Clamp", "true") == "true" {
+ t.clamp(0.0, 1.0)
+ } else {
+ t
+ };
+ geom.detail_mut().create(&name, AttribValue::Float(t));
+}
+
/// Which points a neighbourhood operator treats as a point's neighbours.
enum Hood {
/// Points reachable within N edge rings. The mesh's own connectivity, and
@@ -1594,10 +1738,16 @@ pub fn resolve_attribute_geometry_with_errors(
}
let input_node = find_node_by_name(root, &input_name)?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ apply_attribute(&mut geom, target, ocl_error);
+ Some(geom)
+}
+/// The Attribute operator itself, over geometry already in hand — split from
+/// the resolver for the same reason `apply_neighbour` is.
+pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mut Option<String>) {
let name = node_param_str(target, "Attribute Name", "attr1").trim().to_string();
if name.is_empty() {
- return Some(geom);
+ return;
}
let op = node_param_str(target, "Operation", "Create").to_lowercase();
let combine_mode = node_param_str(target, "Combine", "Set").to_lowercase();
@@ -1685,6 +1835,156 @@ pub fn resolve_attribute_geometry_with_errors(
}
}
}
+ // Fit a range onto another range, optionally through a clamp. The
+ // workhorse: a simulation attribute measured by Analysis is almost
+ // always remapped before anything reads it.
+ "remap" => {
+ let (f0, f1) = (
+ node_param_f32(target, "From Min", 0.0),
+ node_param_f32(target, "From Max", 1.0),
+ );
+ let (t0, t1) = (
+ node_param_f32(target, "To Min", 0.0),
+ node_param_f32(target, "To Max", 1.0),
+ );
+ let span = f1 - f0;
+ if span.abs() < 1e-9 {
+ fail = format!("From Min and From Max are both {}", f0);
+ } else {
+ edit_components(geom, &name, &affected, |v| {
+ t0 + (v - f0) / span * (t1 - t0)
+ });
+ }
+ }
+ // Clamp into a range. From Min / From Max name the bounds, so Remap
+ // and Clip read the same way and chain without renaming anything.
+ "clip" => {
+ let (lo, hi) = (
+ node_param_f32(target, "From Min", 0.0),
+ node_param_f32(target, "From Max", 1.0),
+ );
+ let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
+ edit_components(geom, &name, &affected, |v| v.clamp(lo, hi));
+ }
+ // Rescale so the attribute's sum, maximum or range hits To Max.
+ // Unlike Remap this MEASURES first, so it needs no knowledge of what
+ // the values happen to be — which is what makes it survive a
+ // simulation whose range moves every frame.
+ "normalize" => {
+ let vals: Vec<f32> = affected
+ .iter()
+ .filter_map(|&p| geom.points().value(&name, p))
+ .map(|v| v.as_f32())
+ .collect();
+ let goal = node_param_f32(target, "To Max", 1.0);
+ let measure = match node_param_str(target, "Target", "Maximum").to_lowercase().as_str() {
+ "sum" => vals.iter().sum::<f32>(),
+ "range" => {
+ let hi = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max);
+ let lo = vals.iter().copied().fold(f32::INFINITY, f32::min);
+ hi - lo
+ }
+ _ => vals.iter().copied().fold(f32::NEG_INFINITY, f32::max),
+ };
+ if !measure.is_finite() || measure.abs() < 1e-9 {
+ fail = format!("nothing to normalize: the measure is {}", measure);
+ } else {
+ let k = goal / measure;
+ edit_components(geom, &name, &affected, |v| v * k);
+ }
+ }
+ // Fold a second attribute into this one, componentwise. Dot, Distance
+ // and Length collapse to a scalar written into every component, since
+ // the destination keeps its own type.
+ "composite" => {
+ let b_name = node_param_str(target, "Source B", "");
+ let b_name = b_name.trim().to_string();
+ if !geom.points().has(&b_name) {
+ fail = format!("Source B '{}' is not a point attribute", b_name);
+ } else {
+ let op = node_param_str(target, "Combine Op", "Add").to_lowercase();
+ let Some(ty) = geom.points().get(&name).map(|a| a.ty()) else {
+ *ocl_error = Some(format!("Attribute '{}': '{}' is missing", target.name, name));
+ return;
+ };
+ let k = ty.components();
+ for &p in &affected {
+ let a = geom.points().value(&name, p).map(attrib_components).unwrap_or_default();
+ let b = geom.points().value(&b_name, p).map(attrib_components).unwrap_or_default();
+ let at = |v: &Vec<f32>, i: usize| v.get(i).copied().unwrap_or(0.0);
+ let out: Vec<f32> = match op.as_str() {
+ "dot" => {
+ let d: f32 = (0..k.max(b.len())).map(|i| at(&a, i) * at(&b, i)).sum();
+ vec![d; k]
+ }
+ "distance" => {
+ let d: f32 = (0..k.max(b.len()))
+ .map(|i| (at(&a, i) - at(&b, i)).powi(2))
+ .sum::<f32>()
+ .sqrt();
+ vec![d; k]
+ }
+ "length" => {
+ let d: f32 =
+ (0..b.len()).map(|i| at(&b, i).powi(2)).sum::<f32>().sqrt();
+ vec![d; k]
+ }
+ _ => (0..k)
+ .map(|i| {
+ let (x, y) = (at(&a, i), at(&b, i));
+ match op.as_str() {
+ "subtract" => x - y,
+ "multiply" => x * y,
+ // Division by zero yields the numerator
+ // rather than an infinity that poisons
+ // every later frame of a solve.
+ "divide" => if y.abs() < 1e-9 { x } else { x / y },
+ "minimum" => x.min(y),
+ "maximum" => x.max(y),
+ "average" => (x + y) * 0.5,
+ "difference" => (x - y).abs(),
+ _ => x + y,
+ }
+ })
+ .collect(),
+ };
+ let _ = geom.points_mut().set_value(&name, p, components_attrib(ty, &out));
+ }
+ }
+ }
+ // Move an attribute between classes. Point to Detail is the reduction
+ // Analysis does by hand; Detail to Point is how a measured constant
+ // gets back into per-point arithmetic.
+ "promote" => {
+ let to_detail =
+ node_param_str(target, "To Class", "Detail").eq_ignore_ascii_case("detail");
+ let method = node_param_str(target, "Method", "Average").to_lowercase();
+ if to_detail {
+ match geom.points().get(&name).map(|a| a.ty()) {
+ None => fail = format!("'{}' is not a point attribute", name),
+ Some(ty) => {
+ let k = ty.components();
+ let rows: Vec<Vec<f32>> = (0..geom.num_points())
+ .filter_map(|p| geom.points().value(&name, p))
+ .map(attrib_components)
+ .collect();
+ let reduced = reduce_rows(&rows, k, &method);
+ geom.detail_mut().create(&name, components_attrib(ty, &reduced));
+ }
+ }
+ } else {
+ match geom.detail().get(&name).map(|a| a.ty()) {
+ None => fail = format!("'{}' is not a detail attribute", name),
+ Some(ty) => {
+ let v = geom
+ .detail()
+ .value(&name, 0)
+ .unwrap_or(components_attrib(ty, &[0.0]));
+ geom.points_mut().create(&name, v);
+ }
+ }
+ }
+ }
// Create (the default).
_ => {
if builtin {
@@ -1718,7 +2018,40 @@ pub fn resolve_attribute_geometry_with_errors(
if !fail.is_empty() && ocl_error.is_none() {
*ocl_error = Some(format!("Attribute '{}': {}", target.name, fail));
}
- Some(geom)
+}
+
+/// Apply a scalar function to every component of `name` on the given points.
+///
+/// One place for the "same arithmetic, any width" shape that Remap, Clip and
+/// Normalize all have — a float takes it once, a vector takes it per
+/// component, an integer rounds on the way back.
+fn edit_components(geom: &mut Detail, name: &str, points: &[usize], f: impl Fn(f32) -> f32) {
+ let Some(ty) = geom.points().get(name).map(|a| a.ty()) else { return };
+ for &p in points {
+ let Some(cur) = geom.points().value(name, p) else { continue };
+ let out: Vec<f32> = attrib_components(cur).into_iter().map(&f).collect();
+ let _ = geom.points_mut().set_value(name, p, components_attrib(ty, &out));
+ }
+}
+
+/// Reduce a column of component rows to one row, componentwise.
+fn reduce_rows(rows: &[Vec<f32>], k: usize, method: &str) -> Vec<f32> {
+ (0..k)
+ .map(|c| {
+ let col = rows.iter().map(|r| r.get(c).copied().unwrap_or(0.0));
+ match method {
+ "sum" => col.sum(),
+ "minimum" => col.fold(f32::INFINITY, f32::min),
+ "maximum" => col.fold(f32::NEG_INFINITY, f32::max),
+ "first" => rows.first().and_then(|r| r.get(c)).copied().unwrap_or(0.0),
+ _ => {
+ let n = rows.len().max(1) as f32;
+ col.sum::<f32>() / n
+ }
+ }
+ })
+ .map(|v: f32| if v.is_finite() { v } else { 0.0 })
+ .collect()
}
/// An attribute value as loose components, for the arithmetic that does not
@@ -2745,6 +3078,8 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "collision"
|| nt == "relax"
|| nt == "neighbour"
+ || nt == "time"
+ || nt == "analysis"
|| nt == "attribute"
|| nt == "simnet"
}
@@ -2883,6 +3218,24 @@ pub fn network_sphere_vertices_with_errors(
out.merge(&geom);
}
}
+ } else if node.node_type.eq_ignore_ascii_case("time") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_time_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
+ } else if node.node_type.eq_ignore_ascii_case("analysis") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_analysis_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
} else if node.node_type.eq_ignore_ascii_case("collision") {
let _idx = *count;
*count += 1;
@@ -4220,6 +4573,286 @@ mod simnet_tests {
}
}
+ /// Apply one Attribute-node operation to geometry in hand.
+ fn run_attr(before: &Detail, params: &[(&str, &str)]) -> (Detail, Option<String>) {
+ let mut geom = before.clone();
+ let mut ps = vec![param("Input", "In"), param("Attribute Name", "mass")];
+ for (k, v) in params {
+ match ps.iter_mut().find(|p| p.name == *k) {
+ Some(p) => p.default = v.to_string(),
+ None => ps.push(param(k, v)),
+ }
+ }
+ let n = node("id-a", "A", "attribute", ps, vec![]);
+ let mut err = None;
+ apply_attribute(&mut geom, &n, &mut err);
+ (geom, err)
+ }
+
+ /// A sphere whose `mass` ramps 0, 1, 2 … across its points.
+ fn ramped_mass() -> Detail {
+ let mut d = sphere_detail(Vec3::ZERO, 0.5, 4, 6);
+ d.points_mut().create("mass", AttribValue::Float(0.0));
+ for p in 0..d.num_points() {
+ d.points_mut()
+ .set_value("mass", p, AttribValue::Float(p as f32))
+ .unwrap();
+ }
+ d
+ }
+
+ #[test]
+ fn test_attribute_remap_and_clip_share_their_range_parameters() {
+ let before = ramped_mass();
+ let n = before.num_points();
+ let last = (n - 1) as f32;
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+
+ let (remapped, err) = run_attr(
+ &before,
+ &[
+ ("Operation", "Remap"),
+ ("From Min", "0.00"),
+ ("From Max", &last.to_string()),
+ ("To Min", "0.00"),
+ ("To Max", "1.00"),
+ ],
+ );
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(mass(&remapped, 0), 0.0);
+ assert!((mass(&remapped, n - 1) - 1.0).abs() < 1e-5);
+
+ // Clip reads the same From Min / From Max, so the two chain without
+ // renaming anything between them.
+ let (clipped, err) = run_attr(
+ &before,
+ &[("Operation", "Clip"), ("From Min", "2.00"), ("From Max", "5.00")],
+ );
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(mass(&clipped, 0), 2.0);
+ assert_eq!(mass(&clipped, 3), 3.0);
+ assert_eq!(mass(&clipped, n - 1), 5.0);
+
+ // A degenerate source range is refused rather than dividing by zero.
+ let (_, err) = run_attr(
+ &before,
+ &[("Operation", "Remap"), ("From Min", "1.00"), ("From Max", "1.00")],
+ );
+ assert!(err.is_some(), "a zero-width source range must be reported");
+ }
+
+ #[test]
+ fn test_attribute_normalize_measures_before_it_scales() {
+ let before = ramped_mass();
+ let n = before.num_points();
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+ let sum = |d: &Detail| (0..n).map(|p| mass(d, p)).sum::<f32>();
+
+ // Unlike Remap, Normalize needs no knowledge of the values — which is
+ // what lets it sit in a solve whose range moves every frame.
+ let (by_max, err) = run_attr(&before, &[("Operation", "Normalize"), ("Target", "Maximum"), ("To Max", "1.00")]);
+ assert!(err.is_none(), "{err:?}");
+ assert!((mass(&by_max, n - 1) - 1.0).abs() < 1e-5);
+
+ let (by_sum, _) = run_attr(&before, &[("Operation", "Normalize"), ("Target", "Sum"), ("To Max", "1.00")]);
+ assert!((sum(&by_sum) - 1.0).abs() < 1e-4, "sum is {}", sum(&by_sum));
+
+ // An all-zero attribute has no scale to hit, and says so instead of
+ // filling the geometry with infinities.
+ let mut flat = before.clone();
+ flat.points_mut().create("mass", AttribValue::Float(0.0));
+ let (_, err) = run_attr(&flat, &[("Operation", "Normalize")]);
+ assert!(err.is_some(), "normalizing nothing must be reported");
+ }
+
+ #[test]
+ fn test_attribute_composite_folds_a_second_attribute_in() {
+ let mut before = ramped_mass();
+ before.points_mut().create("other", AttribValue::Float(2.0));
+
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+ for (op, want) in [("Multiply", 6.0), ("Add", 5.0), ("Subtract", 1.0), ("Maximum", 3.0)] {
+ let (g, err) = run_attr(
+ &before,
+ &[("Operation", "Composite"), ("Source B", "other"), ("Combine Op", op)],
+ );
+ assert!(err.is_none(), "{op}: {err:?}");
+ assert_eq!(mass(&g, 3), want, "{op}");
+ }
+
+ // Dividing by zero yields the numerator rather than an infinity that
+ // would poison every later frame of a solve.
+ let mut zeroed = before.clone();
+ zeroed.points_mut().create("other", AttribValue::Float(0.0));
+ let (g, _) = run_attr(
+ &zeroed,
+ &[("Operation", "Composite"), ("Source B", "other"), ("Combine Op", "Divide")],
+ );
+ assert_eq!(mass(&g, 3), 3.0);
+
+ let (_, err) = run_attr(
+ &before,
+ &[("Operation", "Composite"), ("Source B", "nope"), ("Combine Op", "Add")],
+ );
+ assert!(err.is_some(), "a missing Source B must be reported");
+ }
+
+ #[test]
+ fn test_analysis_writes_its_answers_to_detail_attributes() {
+ let before = ramped_mass();
+ let n = before.num_points();
+ let mut geom = before.clone();
+ let an = node(
+ "id-an",
+ "An",
+ "analysis",
+ vec![param("Input", "In"), param("Source", "Attribute"), param("Attribute", "mass")],
+ vec![],
+ );
+ let mut err = None;
+ apply_analysis(&mut geom, &an, &mut err);
+ assert!(err.is_none(), "{err:?}");
+
+ let d = |name: &str| geom.detail().value(name, 0).unwrap().as_f32();
+ // Five ordinary detail attributes where Houdini writes one dictionary.
+ // Nothing had to learn to index a dict, and the spreadsheet shows them
+ // as `d:` columns for free.
+ assert_eq!(d("mass_min"), 0.0);
+ assert_eq!(d("mass_max"), (n - 1) as f32);
+ assert_eq!(d("mass_count"), n as f32);
+ assert_eq!(d("mass_sum"), (0..n).map(|p| p as f32).sum::<f32>());
+ assert!((d("mass_average") - d("mass_sum") / n as f32).abs() < 1e-4);
+ assert_eq!(d("mass_spread"), d("mass_max") - d("mass_min"));
+
+ // Edge lengths are the same reduction over a different column — the
+ // measurement a remesher steers by.
+ let mut edges = before.clone();
+ let an = node(
+ "id-an",
+ "An",
+ "analysis",
+ vec![param("Input", "In"), param("Source", "Edge Lengths")],
+ vec![],
+ );
+ apply_analysis(&mut edges, &an, &mut None);
+ assert_eq!(
+ edges.detail().value("edges_count", 0).unwrap().as_f32(),
+ before.edges().len() as f32
+ );
+ assert!(edges.detail().value("edges_average", 0).unwrap().as_f32() > 0.0);
+
+ // A missing attribute is reported, and the geometry still passes.
+ let mut miss = before.clone();
+ let an = node(
+ "id-an",
+ "An",
+ "analysis",
+ vec![param("Input", "In"), param("Attribute", "nope")],
+ vec![],
+ );
+ let mut err = None;
+ apply_analysis(&mut miss, &an, &mut err);
+ assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
+ }
+
+ #[test]
+ fn test_measure_then_remap_is_the_chain_the_detail_class_exists_for() {
+ // Analysis measures, Promote lifts the answer back to points, and
+ // Composite divides by it — a normalization that survives a range
+ // moving every frame, assembled from the vocabulary rather than
+ // hard-coded into a node.
+ let mut geom = ramped_mass();
+ let n = geom.num_points();
+
+ apply_analysis(
+ &mut geom,
+ &node("a", "A", "analysis", vec![param("Attribute", "mass")], vec![]),
+ &mut None,
+ );
+ let measured_max = geom.detail().value("mass_max", 0).unwrap().as_f32();
+ assert_eq!(measured_max, (n - 1) as f32);
+
+ let (geom, err) = run_attr(
+ &geom,
+ &[("Operation", "Promote"), ("Attribute Name", "mass_max"), ("To Class", "Point")],
+ );
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(
+ geom.points().value("mass_max", 0),
+ Some(AttribValue::Float(measured_max)),
+ "the measurement is per-point now"
+ );
+
+ let (geom, err) = run_attr(
+ &geom,
+ &[("Operation", "Composite"), ("Source B", "mass_max"), ("Combine Op", "Divide")],
+ );
+ assert!(err.is_none(), "{err:?}");
+ let mass = |p: usize| geom.points().value("mass", p).unwrap().as_f32();
+ assert_eq!(mass(0), 0.0);
+ assert!((mass(n - 1) - 1.0).abs() < 1e-5, "{}", mass(n - 1));
+ }
+
+ #[test]
+ fn test_promote_reduces_points_to_one_detail_row() {
+ let before = ramped_mass();
+ let n = before.num_points();
+ for (method, want) in [
+ ("Average", (0..n).map(|p| p as f32).sum::<f32>() / n as f32),
+ ("Sum", (0..n).map(|p| p as f32).sum::<f32>()),
+ ("Minimum", 0.0),
+ ("Maximum", (n - 1) as f32),
+ ("First", 0.0),
+ ] {
+ let (g, err) = run_attr(
+ &before,
+ &[("Operation", "Promote"), ("To Class", "Detail"), ("Method", method)],
+ );
+ assert!(err.is_none(), "{method}: {err:?}");
+ let got = g.detail().value("mass", 0).unwrap().as_f32();
+ assert!((got - want).abs() < 1e-3, "{method}: {got} vs {want}");
+ assert_eq!(g.detail().len(), 1, "detail stays one row");
+ }
+ }
+
+ #[test]
+ fn test_time_reads_the_frame_off_the_evaluation() {
+ let before = ramped_mass();
+ let tn = node(
+ "id-t",
+ "T",
+ "time",
+ vec![param("Attribute", "t"), param("Start Frame", "1"), param("End Frame", "11")],
+ vec![],
+ );
+ let t_at = |frame: i32| {
+ let mut g = before.clone();
+ apply_time(&mut g, &tn, frame);
+ g.detail().value("t", 0).unwrap().as_f32()
+ };
+ assert_eq!(t_at(1), 0.0);
+ assert!((t_at(6) - 0.5).abs() < 1e-5);
+ assert_eq!(t_at(11), 1.0);
+ // Clamped by default, so a scrub past the end holds rather than
+ // running the chain off into values it was never shaped for.
+ assert_eq!(t_at(50), 1.0);
+ assert_eq!(t_at(-20), 0.0);
+
+ // A zero-length range reads as "elapsed", not as a division by zero.
+ let degenerate = node(
+ "id-t",
+ "T",
+ "time",
+ vec![param("Attribute", "t"), param("Start Frame", "5"), param("End Frame", "5")],
+ vec![],
+ );
+ let mut g = before.clone();
+ apply_time(&mut g, °enerate, 5);
+ assert_eq!(g.detail().value("t", 0).unwrap().as_f32(), 1.0);
+ apply_time(&mut g, °enerate, 4);
+ assert_eq!(g.detail().value("t", 0).unwrap().as_f32(), 0.0);
+ }
+
/// A sphere, one point given a spike of `mass`, then a Neighbour node.
/// Returns (before, after) so a test can compare the two directly.
fn neighbour_chain(extra: &[(&str, &str)]) -> (Detail, Detail) {
diff --git a/src/main.rs b/src/main.rs
index 64fee75..70a7e68 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2875,6 +2875,9 @@ mod tests {
geom.points_mut().set_value("ID", 0, AttribValue::Int(42)).unwrap();
geom.points_mut().create_group("pinned");
geom.points_mut().add_to_group("pinned", 1);
+ // A detail attribute — what Analysis writes — shows as a `d:` column,
+ // constant down the table, which is what a detail attribute is.
+ geom.detail_mut().create("mass_max", AttribValue::Float(9.5));
assert_eq!(geom.num_points(), 2);
let render_verts = crate::geometry::detail_vertices(&geom);
@@ -2885,7 +2888,7 @@ mod tests {
headers,
vec![
"Point", "Pos.x", "Pos.y", "Pos.z", "Col.r", "Col.g", "Col.b",
- "ID", "UV.x", "UV.y", "g:pinned",
+ "ID", "UV.x", "UV.y", "g:pinned", "d:mass_max",
]
);
@@ -2902,6 +2905,8 @@ mod tests {
assert_eq!(rows[1][7], "0", "unwritten is the type's zero, not a dash");
assert_eq!(rows[1][9], "0.4000"); // UV.y
assert_eq!(rows[1][10], "1", "point 1 is in the group");
+ assert_eq!(rows[0][11], "9.5000");
+ assert_eq!(rows[1][11], "9.5000", "a detail value repeats down the column");
}
#[test]