graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Attribute node's Value is a control as wide as its target, or another attribute
The params pane presents Value as a slider (one wide) or a float2/float3/
float4 group, over Create's Type or Modify's target; a single number is
spread over the components, and reading the spread back unchanged is not an
edit. A new Value From row (Constant / Attribute) on Create and Modify
replaces Value with From Attribute: each point's own value of it, fitted to
the target's width; Pos and Col name the position and colour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 26 ++++++++---
nodes/attribute.json | 16 ++++++-
src/app.rs | 80 +++++++++++++++++++++++++++++-----
src/geometry.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++----
src/main.rs | 64 ++++++++++++++++++++++-----
5 files changed, 269 insertions(+), 38 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c45321..45207f6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -718,11 +718,27 @@ old text Center a float3 from the load on. Tests build parameters with
typed into blind; a template declares it now and needs no entry anywhere.
What is still `text` is text for a reason: Attribute's `Value` is as wide
as its `Type` row says (one number or two, three or four) — though the
- pane PRESENTS it as a float3 row, over a deliberately wide
- `VALUE_ROW_RANGE`, when the target is three wide and the text holds three
- numbers (`add_pick_lists`, since 2026-09-28: Create with Type Float3, or
- Modify aimed at Pos, Col or an input Float3; the parameter, the file and
- MCP see text throughout) — Transfer's
+ pane PRESENTS it as a control as wide as its target, over a deliberately
+ wide `VALUE_ROW_RANGE` (`add_pick_lists` / `value_row_control`): a
+ slider for one, cce-ui's `float2` / `float3` / `float4` group for more
+ (the float3 since 2026-09-28, every width since 2026-10-01). The target
+ is Create's Type, or Modify's Pos, Col or input attribute. The text must
+ hold one number or as many as the target: ONE is shown spread over every
+ component, as the node spreads it (`fit` in `apply_attribute`), and the
+ pane's write-back reading that spread back unchanged is not an edit
+ (`same_value_row_text`), or every sync would rewrite `1.00` as
+ `1.00:1.00:1.00` and file an undo step. An expression, a text that fits
+ no width and an attribute the input lacks keep the text box; the
+ parameter, the file and MCP see text throughout. **Value From** (Constant
+ / Attribute, since the same day, Create and Modify) replaces the Value
+ row with **From Attribute**, an `attribute` row: each point's own value
+ of it is used in place of the constant — copied when as wide, a single
+ number spread, otherwise as many components as fit and the rest zero;
+ Pos and Col name the position and the colour; read before anything is
+ written, so a Modify can read the attribute it writes. A save from
+ before has Constant. `test_attribute_takes_its_value_from_another_attribute`
+ and `an_attribute_value_row_is_a_control_as_wide_as_its_target` are the
+ tests — Transfer's
`Attributes` is a comma LIST of names, Simnet's `Start Frame` is empty for
"the playbar's", Bounds' `Prefix` is a prefix, Curve's `Points` a list of
positions, Export's `File` a path. The same day Attribute's `From Min` /
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 9799f5a..281ad2a 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -29,11 +29,25 @@
"show_when": "Operation == Create",
"description": "How many components the new attribute has: one number, or a vector of two, three or four."
},
+ {
+ "name": "Value From",
+ "type": "choice:Constant,Attribute",
+ "default": "Constant",
+ "show_when": "Operation == Create|Modify",
+ "description": "Where the value comes from: Constant uses the Value row for every point; Attribute reads each point's own value of From Attribute."
+ },
+ {
+ "name": "From Attribute",
+ "type": "attribute",
+ "default": "",
+ "show_when": "Operation == Create|Modify && Value From == Attribute",
+ "description": "The point attribute each point's value is read from when Value From is Attribute: copied as it is when it is as wide as the result, a single number spread to every component, otherwise as many components as fit, the rest zero. Pos and Col name the position and the colour."
+ },
{
"name": "Value",
"type": "text",
"default": "1.00",
- "show_when": "Operation == Create|Modify",
+ "show_when": "Operation == Create|Modify && Value From == Constant",
"description": "The number or numbers written, separated by spaces, commas or colons. One number fills every component; otherwise the count must match the attribute's width."
},
{
diff --git a/src/app.rs b/src/app.rs
index 99e9cbb..31a98ef 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -641,7 +641,7 @@ pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static
let head = shown.split(':').next().unwrap_or("");
let control = match head {
"float3" if shown.split(':').nth(3) == Some("trackball") => "trackball and sliders",
- "slider" | "float3" => "slider",
+ "slider" | "float2" | "float3" | "float4" => "slider",
"spinbox" => "spinbox",
"choice" => "dropdown",
"toggle" | "checkbox" => "toggle",
@@ -652,10 +652,14 @@ pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static
"color" | "rgb" | "rgba" => "color picker",
_ => "text box",
};
- let ty = if head == "float3" {
- "float3"
- } else {
- match kind {
+ let ty = match head {
+ "float2" => "float2",
+ "float3" => "float3",
+ "float4" => "float4",
+ // A text parameter presented as a slider (the Attribute node's
+ // Value, one wide) sets a number.
+ "slider" => "float",
+ _ => match kind {
ParamKind::Slider | ParamKind::Float => "float",
ParamKind::Spin => "integer",
ParamKind::Float3 => "float3",
@@ -666,7 +670,7 @@ pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static
ParamKind::Attribute => "attribute",
ParamKind::Group => "group",
ParamKind::Button => "none",
- }
+ },
};
(control, ty)
}
@@ -739,6 +743,51 @@ pub fn float3_row(min: f32, max: f32, trackball: bool) -> String {
format!("float3:{}:{}{}", min, max, if trackball { ":trackball" } else { "" })
}
+/// The control the Attribute node's Value row is presented as, for a target
+/// `width` components wide, and the row's text as that control shows it: a
+/// slider for one, the float group with two, three or four rows for more
+/// (cce-ui's `float2` / `float3` / `float4`), over [`VALUE_ROW_RANGE`].
+/// The text must hold one number or `width` of them; one is SPREAD to every
+/// component, which is what a single number means to the node (`fit` in
+/// `apply_attribute`), so the controls show it as it acts. `None` — a text
+/// box, as before — for a width it does not fit.
+pub fn value_row_control(text: &str, width: usize, ball: bool) -> Option<(String, String)> {
+ let parts: Vec<&str> = text.split(|c| c == ':' || c == ',' || c == ' ').filter(|s| !s.is_empty()).collect();
+ if !(1..=4).contains(&width) || parts.iter().any(|p| p.parse::<f32>().is_err()) {
+ return None;
+ }
+ let shown = match parts.len() {
+ n if n == width => parts.join(":"),
+ 1 => vec![parts[0]; width].join(":"),
+ _ => return None,
+ };
+ let (lo, hi) = VALUE_ROW_RANGE;
+ let ty = match width {
+ 1 => format!("slider:{lo}:{hi}"),
+ 3 => float3_row(lo, hi, ball),
+ n => format!("float{n}:{lo}:{hi}"),
+ };
+ Some((ty, shown))
+}
+
+/// Whether `shown` is `kept` as a value row presents it — the same numbers,
+/// or one number of `kept` spread over every component of `shown` — so the
+/// write-back leaves a row the user did not touch alone rather than
+/// rewriting `1.00` as `1.00:1.00:1.00` the first time the pane syncs.
+pub fn same_value_row_text(kept: &str, shown: &str) -> bool {
+ let nums = |t: &str| -> Option<Vec<f32>> {
+ t.split(|c| c == ':' || c == ',' || c == ' ').filter(|s| !s.is_empty()).map(|s| s.parse::<f32>().ok()).collect()
+ };
+ let (Some(a), Some(b)) = (nums(kept), nums(shown)) else { return false };
+ if a.is_empty() || b.is_empty() {
+ return false;
+ }
+ if a.len() == b.len() {
+ return a == b;
+ }
+ a.len() == 1 && b.iter().all(|v| *v == a[0])
+}
+
/// A node's wires as the network draws them: every parameter of the `node`
/// kind, in order — the k-th is input port k — as (name, the node it names).
/// A wire whose row is hidden (`show_when`) names nothing here: it keeps its
@@ -4115,7 +4164,11 @@ impl State {
let key = if p.label.is_empty() { &p.name } else { &p.label };
key == u_name
}) {
- if p.text() != *u_val {
+ // A value row presents a single number spread
+ // over its components; read back unchanged, it
+ // is the text it was, not an edit.
+ let presented = p.name == "Value" && !p.is_expr() && same_value_row_text(p.text(), u_val);
+ if p.text() != *u_val && !presented {
// A reference typed into a plain row becomes
// an expression — the one way to make one
// without the row menu — and an expression is
@@ -4706,9 +4759,10 @@ impl State {
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map_or(0, |(_, w)| *w),
};
- if width == 3 {
- if let Some(row) = params.iter_mut().find(|r| r.0 == key && r.2 == "text") {
- row.2 = float3_row(VALUE_ROW_RANGE.0, VALUE_ROW_RANGE.1, ball);
+ if let Some(row) = params.iter_mut().find(|r| r.0 == key && r.2 == "text") {
+ if let Some((ty, text)) = value_row_control(&row.1, width, ball) {
+ row.2 = ty;
+ row.1 = text;
}
}
}
@@ -4733,6 +4787,10 @@ impl State {
if p.is_expr() {
return None;
}
+ // Read from an attribute, the row is not shown at all.
+ if node_param_str(node, "Value From", "Constant").eq_ignore_ascii_case("attribute") {
+ return None;
+ }
let comps = p
.text()
.split(|c| c == ':' || c == ',' || c == ' ')
@@ -4740,7 +4798,7 @@ impl State {
.filter_map(|s| s.parse::<f32>().ok())
.count();
let raw = p.text().split(|c| c == ':' || c == ',' || c == ' ').filter(|s| !s.is_empty()).count();
- if comps != 3 || raw != 3 {
+ if comps == 0 || comps > 4 || raw != comps {
return None;
}
let key = if p.label.is_empty() { p.name.clone() } else { p.label.clone() };
diff --git a/src/geometry.rs b/src/geometry.rs
index 9ed8dee..97289af 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -4898,6 +4898,49 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
None
}
};
+ // Value From Attribute: each affected point's own value of From
+ // Attribute, read BEFORE anything is written, since it may be the very
+ // attribute being written. Pos and Col (P and Cd) are the position and
+ // the colour. `None` for a constant Value.
+ let from_attr = matches!(op.as_str(), "create" | "modify")
+ && node_param_str(target, "Value From", "Constant").eq_ignore_ascii_case("attribute");
+ let src_name = node_param_str(target, "From Attribute", "").trim().to_string();
+ let src_rows: Option<Vec<Vec<f32>>> = if !from_attr {
+ None
+ } else if src_name.is_empty() {
+ fail = "From Attribute names no attribute".to_string();
+ None
+ } else if src_name.eq_ignore_ascii_case("Pos") || src_name == "P" {
+ Some(affected.iter().map(|&p| geom.pos(p).to_array().to_vec()).collect())
+ } else if src_name.eq_ignore_ascii_case("Col") || src_name == "Cd" {
+ Some(affected.iter().map(|&p| geom.color(p).to_vec()).collect())
+ } else if geom.points().has(&src_name) {
+ Some(affected.iter().map(|&p| geom.points().value(&src_name, p).map(attrib_components).unwrap_or_default()).collect())
+ } else {
+ fail = format!("From Attribute '{}' is not a point attribute", src_name);
+ None
+ };
+ // What the i-th affected point is given at width `n`: the constant
+ // resized as `fit` does, or its own source value — as it is when as
+ // wide, a single number spread to every component, otherwise as many
+ // components as fit and the rest zero.
+ let src_for = |i: usize, n: usize| -> Option<Vec<f32>> {
+ match &src_rows {
+ None => fit(n),
+ Some(rows) => {
+ let row = rows.get(i)?;
+ Some(if row.len() == n {
+ row.clone()
+ } else if row.len() == 1 {
+ vec![row[0]; n]
+ } else {
+ (0..n).map(|k| row.get(k).copied().unwrap_or(0.0)).collect()
+ })
+ }
+ }
+ };
+ // Constant values must parse; a value read from an attribute has.
+ let value_ok = value_ok || src_rows.is_some();
let combine = |dst: &mut [f32], src: &[f32]| {
for (d, s) in dst.iter_mut().zip(src) {
match combine_mode.as_str() {
@@ -4980,9 +5023,10 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
} else if !value_ok {
fail = format!("Value '{}' does not parse as numbers", value_str);
} else if builtin {
- match fit(3) {
- Some(src) => {
+ match fit(3).or_else(|| src_rows.as_ref().map(|_| Vec::new())) {
+ Some(_) => {
for (i, &p) in affected.iter().enumerate() {
+ let Some(src) = src_for(i, 3) else { continue };
let old = if is_col { geom.color(p) } else { geom.pos(p).to_array() };
let mut v = old;
combine(&mut v, &src);
@@ -4999,10 +5043,11 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
} else {
match geom.points().get(&name).map(|a| a.ty()) {
None => {}
- Some(ty) => match fit(ty.components()) {
+ Some(ty) => match fit(ty.components()).or_else(|| src_rows.as_ref().map(|_| Vec::new())) {
None => fail = format!("Value '{}' does not fit '{}'", value_str, name),
- Some(src) => {
+ Some(_) => {
for (i, &p) in affected.iter().enumerate() {
+ let Some(src) = src_for(i, ty.components()) else { continue };
let Some(cur) = geom.points().value(&name, p) else { continue };
let old = attrib_components(cur);
let mut buf = old.clone();
@@ -5180,6 +5225,8 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
_ => {
if builtin {
fail = format!("'{}' is built-in and cannot be created", name);
+ } else if !fail.is_empty() {
+ // From Attribute names nothing: the message is set.
} else if !value_ok {
fail = format!("Value '{}' does not parse as numbers", value_str);
} else {
@@ -5189,10 +5236,9 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
"float4" => crate::detail::AttribType::Float4,
_ => crate::detail::AttribType::Float,
};
- match fit(ty.components()) {
- Some(src) => {
+ match fit(ty.components()).or_else(|| src_rows.as_ref().map(|_| Vec::new())) {
+ Some(_) => {
let zero = components_attrib(ty, &vec![0.0; ty.components()]);
- let value = components_attrib(ty, &src);
// Declared where the author knows the answer: at the
// point of creation, not in a list somewhere else that
// has to be kept in step.
@@ -5204,8 +5250,10 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
crate::detail::AttribKind::Live
};
geom.points_mut().create_kind(&name, zero, kind);
- for &p in &affected {
- let _ = geom.points_mut().set_value(&name, p, value);
+ for (i, &p) in affected.iter().enumerate() {
+ if let Some(src) = src_for(i, ty.components()) {
+ let _ = geom.points_mut().set_value(&name, p, components_attrib(ty, &src));
+ }
}
}
None => {
@@ -7373,6 +7421,61 @@ mod simnet_tests {
assert!(err.is_some(), "a zero-width source range must be reported");
}
+ /// Value From Attribute: Create and Modify take each point's own value
+ /// of From Attribute in place of the constant — copied when as wide,
+ /// one number spread, otherwise as many components as fit and the rest
+ /// zero; Pos is the position. An attribute that is not there is an
+ /// error, and a Remap left with the switch set is not affected.
+ #[test]
+ fn test_attribute_takes_its_value_from_another_attribute() {
+ let before = ramped_mass();
+ let n = before.num_points();
+ let get = |d: &Detail, name: &str, p: usize| attrib_components(d.points().value(name, p).unwrap());
+ let from = |src: &str| [("Value From", "Attribute"), ("From Attribute", src)].map(|(k, v)| (k, v.to_string()));
+ let run = |extra: &[(&str, &str)], src: &str| {
+ let f = from(src);
+ let mut ps: Vec<(&str, &str)> = f.iter().map(|(k, v)| (*k, v.as_str())).collect();
+ ps.extend_from_slice(extra);
+ run_attr(&before, &ps)
+ };
+
+ // A Float copied: weight = mass, point by point.
+ let (d, err) = run(&[("Operation", "Create"), ("Attribute Name", "weight"), ("Type", "Float")], "mass");
+ assert!(err.is_none(), "{err:?}");
+ for p in 0..n {
+ assert_eq!(get(&d, "weight", p), vec![p as f32]);
+ }
+ // Spread over a Float3.
+ let (d, _) = run(&[("Operation", "Create"), ("Attribute Name", "v"), ("Type", "Float3")], "mass");
+ assert_eq!(get(&d, "v", 3), vec![3.0, 3.0, 3.0]);
+ // Pos into a Float2: as many components as fit.
+ let (d, _) = run(&[("Operation", "Create"), ("Attribute Name", "xy"), ("Type", "Float2")], "Pos");
+ let pos = before.pos(5);
+ assert_eq!(get(&d, "xy", 5), vec![pos.x, pos.y]);
+ // Pos into a Float4: the rest zero.
+ let (d, _) = run(&[("Operation", "Create"), ("Attribute Name", "xyzw"), ("Type", "Float4")], "Pos");
+ assert_eq!(get(&d, "xyzw", 5), vec![pos.x, pos.y, pos.z, 0.0]);
+
+ // Modify: Pos plus the point's own displacement, mass spread.
+ let (d, err) = run(&[("Operation", "Modify"), ("Attribute Name", "Pos"), ("Combine", "Add")], "mass");
+ assert!(err.is_none(), "{err:?}");
+ let moved = d.pos(4) - before.pos(4);
+ assert!((moved - Vec3::splat(4.0)).length() < 1e-5, "{moved:?}");
+ // From itself: read before it is written.
+ let (d, _) = run(&[("Operation", "Modify"), ("Attribute Name", "mass"), ("Combine", "Add")], "mass");
+ assert_eq!(get(&d, "mass", 6), vec![12.0]);
+
+ // Nothing named, or nothing there: an error, and nothing written.
+ let (d, err) = run(&[("Operation", "Create"), ("Attribute Name", "w2"), ("Type", "Float")], "nope");
+ assert!(err.is_some_and(|e| e.contains("nope")));
+ assert!(!d.points().has("w2"));
+ let (_, err) = run(&[("Operation", "Create"), ("Attribute Name", "w3")], "");
+ assert!(err.is_some());
+ // An operation that has no Value is untouched by the switch.
+ let (_, err) = run(&[("Operation", "Clip"), ("From Min", "0"), ("From Max", "1")], "nope");
+ assert!(err.is_none(), "{err:?}");
+ }
+
#[test]
fn test_attribute_normalize_measures_before_it_scales() {
let before = ramped_mass();
diff --git a/src/main.rs b/src/main.rs
index 6601e62..9d6f2c4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9661,7 +9661,11 @@ mod tests {
state.apply_action(McpAction::SetParam { slot: pull, name: "Value".into(), value: "0.06".into() }, &mut redraw).unwrap();
show(&mut state, pull);
let (rows, _) = fields(&state, pull, "Value");
- assert_eq!((&rows[1], &rows[2]), (&"Control: text box".to_string(), &"Type: string".to_string()), "a broadcast number stays a text box");
+ assert_eq!(
+ (&rows[1], &rows[2]),
+ (&"Control: trackball and sliders".to_string(), &"Type: float3".to_string()),
+ "a single number is spread over the same control"
+ );
// And a reference typed straight into a row (or scripted) becomes one.
state.apply_action(McpAction::SetParam { slot: ball, name: "Rows".into(), value: "chi(\"../sphere1/Rows\") * 2".into() }, &mut redraw).unwrap();
@@ -16611,13 +16615,16 @@ mod tests {
}
/// The Attribute node's Value stays a text parameter, but the pane
- /// presents it as a float3 row — over the wide `VALUE_ROW_RANGE` — when
- /// the target is three wide and the text holds three numbers: Modify on
- /// Pos (the pull node), on an input Float3 (Norm), or Create with Type
- /// Float3. Modify on a Float2 (UV), Create of a Float, a broadcast
- /// single number and an expression all keep the text box.
- #[test]
- fn an_attribute_value_row_is_a_float3_when_its_target_is_three_wide() {
+ /// presents it as a control as wide as its target — over the wide
+ /// `VALUE_ROW_RANGE` — 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 (Norm) 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;
+ /// read from an attribute, there is no Value row at all.
+ #[test]
+ fn an_attribute_value_row_is_a_control_as_wide_as_its_target() {
let templates_root = crate::app::load_fs_tree();
let find = |name: &str| templates_root.children.iter().find(|t| t.name == name).unwrap();
let instance = |template: &FsNode, id: &str, name: &str, params: &[(&str, &str)]| {
@@ -16657,7 +16664,10 @@ mod tests {
set(&mut state, "Attribute Name", "Norm");
assert_eq!(value_row(&mut state), wide, "Modify on an input Float3");
set(&mut state, "Attribute Name", "UV");
- assert_eq!(value_row(&mut state), "text", "Modify on an input Float2 stays text");
+ 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:-1000:1000", "Modify on an input Float2");
+ set(&mut state, "Value", "0.00:0.06:0.00");
set(&mut state, "Attribute Name", "nothing_here");
assert_eq!(value_row(&mut state), "text", "an attribute the input lacks has no width");
@@ -16666,14 +16676,44 @@ mod tests {
set(&mut state, "Type", "Float3");
assert_eq!(value_row(&mut state), wide, "Create of a Float3");
set(&mut state, "Type", "Float");
- assert_eq!(value_row(&mut state), "text", "Create of a Float");
- set(&mut state, "Type", "Float3");
+ assert_eq!(value_row(&mut state), "text", "three numbers do not fit a Float");
+ let value_text = |state: &mut State| {
+ state.sync_parameters_pane();
+ state.param_mut().node_params().iter().find(|r| r.0 == "Value").unwrap().1.clone()
+ };
set(&mut state, "Value", "1.00");
- assert_eq!(value_row(&mut state), "text", "a broadcast single number stays text");
+ assert_eq!(value_row(&mut state), "slider:-1000:1000", "Create of a Float");
+ for (ty, row, shown) in [
+ ("Float2", "float2:-1000:1000".to_string(), "1.00:1.00"),
+ ("Float3", wide.clone(), "1.00:1.00:1.00"),
+ ("Float4", "float4:-1000:1000".to_string(), "1.00:1.00:1.00:1.00"),
+ ] {
+ set(&mut state, "Type", ty);
+ assert_eq!(value_row(&mut state), row, "Create of a {ty}");
+ assert_eq!(value_text(&mut state), shown, "one number spread over a {ty}");
+ }
+ // Read back unchanged, the spread number is not an edit.
+ let steps = state.edit_history.undo_len();
+ state.sync_parameters_to_project();
+ assert_eq!(state.fs_root.children[1].params.iter().find(|p| p.name == "Value").unwrap().text(), "1.00");
+ assert_eq!(state.edit_history.undo_len(), steps);
+
+ set(&mut state, "Type", "Float3");
set(&mut state, "Value", "1.00:2.00:3.00");
assert_eq!(value_row(&mut state), wide);
state.fs_root.children[1].params.iter_mut().find(|p| p.name == "Value").unwrap().set_expr(true);
assert_eq!(value_row(&mut state), "text", "an expression is shown as its text");
+ state.fs_root.children[1].params.iter_mut().find(|p| p.name == "Value").unwrap().set_expr(false);
+
+ // Read from an attribute: no Value row, and the source is picked
+ // from the input's attributes.
+ set(&mut state, "Value From", "Attribute");
+ state.sync_parameters_pane();
+ let rows = state.param_mut().node_params();
+ assert!(rows.iter().all(|r| r.0 != "Value"), "no Value row");
+ let from = rows.iter().find(|r| r.0 == "From Attribute").expect("a From Attribute row");
+ assert!(from.2.starts_with("textpick:") && from.2.contains("Norm"), "{}", from.2);
+ set(&mut state, "Value From", "Constant");
// The parameter itself never changed kind: it is text in the node.
assert_eq!(state.fs_root.children[1].params.iter().find(|p| p.name == "Value").unwrap().kind(), crate::param::ParamKind::Text);