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

commit16d82e881649a6823fd291bb506157ad9203ef91
parent1057a8c358
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 14:44
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);