graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Attribute node's Value shows as a float3 row when its target is three wide
The parameter stays text — its width is the target's (the Type row under
Create, the named attribute under Modify), which no fixed kind can say —
but the pane now presents it as a float3 row, the way it presents the
attribute and group rows as pickers: Create with Type Float3, or Modify
aimed at Pos, Col or an input attribute the evaluated input holds as a
Float3 (the pick lists carry each attribute's width now). The text must
already hold three numbers; a broadcast single number and an expression
keep the text box. The range is VALUE_ROW_RANGE, deliberately wide, since
a float3 row clamps to its range and Pos is set to whatever the scene
needs; the readouts take typed values.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 7 +++-
src/app.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
src/main.rs | 68 +++++++++++++++++++++++++++++++++
3 files changed, 189 insertions(+), 11 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index fdbb7cd..303c641 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -568,7 +568,12 @@ old text Center a float3 from the load on. Tests build parameters with
`Pin Group` — and the other thirty-odd rows that name one were text boxes
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), Transfer's
+ 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
`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/src/app.rs b/src/app.rs
index a457fd1..a25cf5e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -555,6 +555,30 @@ pub struct NodeTemplate {
pub node: FsNode,
}
+/// The Attribute node's Value row's range when it is presented as a float3
+/// (`add_pick_lists`): wide, because a float3 row clamps to its range and
+/// Pos is set to whatever the scene needs. A drag is coarse at this width;
+/// the row's readouts take typed values.
+pub const VALUE_ROW_RANGE: (f32, f32) = (-1000.0, 1000.0);
+
+/// What an Attribute node's `Value` is aimed at, as `add_pick_lists` needs
+/// it: a width the node itself decides, or the name of the input attribute
+/// whose width has to be read off the evaluated input.
+enum ValueTarget {
+ Width(usize),
+ Named(String),
+}
+
+/// What the params pane reads off a node's evaluated INPUT to build its
+/// controls: group names, point attribute names (plus the Pos/Col
+/// built-ins), and each attribute's width in components.
+#[derive(Clone, Default)]
+pub struct PickLists {
+ pub groups: Vec<String>,
+ pub attrs: Vec<String>,
+ pub widths: Vec<(String, usize)>,
+}
+
pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
// Rows whose condition does not hold are not shown. Write-back resolves a
// row by its display key rather than by position, so a hidden parameter
@@ -2307,7 +2331,7 @@ pub struct State {
/// version) → (group names, attribute names) read off that input's
/// evaluated geometry, feeding the textpick rows on group/attribute
/// params. One entry: the selected node's input.
- pub pick_cache: Option<((String, u64), (Vec<String>, Vec<String>))>,
+ pub pick_cache: Option<((String, u64), PickLists)>,
/// The raster scene's model-view-projection and the viewport pane rect in
/// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
pub last_scene_mvp: Option<Mat4>,
@@ -3938,6 +3962,21 @@ impl State {
/// text boxes you typed into blind. The template says what a row names
/// now (`ParamKind::Attribute` / `Group`), so a row gets the picker by
/// declaring it, and a new template needs no entry here.
+ ///
+ /// The same pass PRESENTS the Attribute node's `Value` as a float3 row
+ /// when its target is three wide (since 2026-09-28). The parameter
+ /// itself stays `text`, because its width is the target's — the Type
+ /// row under Create, the named attribute under Modify — which no fixed
+ /// kind can say (see "Parameter kinds" in CLAUDE.md); what changes is
+ /// the control, as the pickers change it. Three wide means Create with
+ /// Type Float3, or Modify aimed at Pos, Col or an input attribute the
+ /// evaluated input holds as a Float3. The text must already hold three
+ /// numbers: a single number BROADCASTS to every component, and a row
+ /// that showed it as `(n, 0, 0)` would write that triple back on the
+ /// first drag; an expression is shown as its text like any other. The
+ /// row's range is [`VALUE_ROW_RANGE`], deliberately wide — a float3 row
+ /// holds a fraction of its range and clamps to it, and Pos is set to
+ /// whatever the scene needs; the readouts are typed into for precision.
fn add_pick_lists(
&mut self,
mut params: Vec<(String, String, String)>,
@@ -3945,7 +3984,7 @@ impl State {
// Rows are keyed as `param_display` keys them — by label when
// there is one, by name otherwise — so the kind is looked up the
// same way the pane's write-back resolves a row.
- let (node_id, kinds) = {
+ let (node_id, kinds, value_row) = {
if self.is_detached_network {
return params;
}
@@ -3958,25 +3997,81 @@ impl State {
.filter(|p| matches!(p.kind(), ParamKind::Attribute | ParamKind::Group))
.map(|p| (if p.label.is_empty() { p.name.clone() } else { p.label.clone() }, p.kind()))
.collect();
- if kinds.is_empty() {
+ let value_row = Self::attribute_value_target(node);
+ if kinds.is_empty() && value_row.is_none() {
return params;
}
- (node.id.clone(), kinds)
+ (node.id.clone(), kinds, value_row)
};
- let (groups, attrs) = self.input_pick_lists(&node_id);
+ let lists = self.input_pick_lists(&node_id);
for row in params.iter_mut() {
let Some((_, kind)) = kinds.iter().find(|(key, _)| *key == row.0) else { continue };
let list = match kind {
- ParamKind::Attribute => &attrs,
- _ => &groups,
+ ParamKind::Attribute => &lists.attrs,
+ _ => &lists.groups,
};
if row.2 == "text" && !list.is_empty() {
row.2 = format!("textpick:{}", list.join(","));
}
}
+ if let Some((key, target)) = value_row {
+ let width = match target {
+ ValueTarget::Width(w) => w,
+ ValueTarget::Named(name) => lists
+ .widths
+ .iter()
+ .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 = format!("float3:{}:{}", VALUE_ROW_RANGE.0, VALUE_ROW_RANGE.1);
+ }
+ }
+ }
params
}
+ /// What an Attribute node's `Value` is aimed at — its display key and
+ /// the target's width, or the attribute name the width has to be read
+ /// off the input for — when the row is one the float3 presentation can
+ /// take: a plain (non-expression) text holding three numbers. `None`
+ /// for any other node, operation, or text.
+ fn attribute_value_target(node: &FsNode) -> Option<(String, ValueTarget)> {
+ if !node.node_type.eq_ignore_ascii_case("attribute") {
+ return None;
+ }
+ let p = node.params.iter().find(|p| p.name == "Value")?;
+ if p.is_expr() {
+ return None;
+ }
+ let comps = p
+ .text()
+ .split(|c| c == ':' || c == ',' || c == ' ')
+ .filter(|s| !s.is_empty())
+ .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 {
+ return None;
+ }
+ let key = if p.label.is_empty() { p.name.clone() } else { p.label.clone() };
+ let name = node_param_str(node, "Attribute Name", "");
+ let name = name.trim().to_string();
+ let target = match node_param_str(node, "Operation", "Create").to_lowercase().as_str() {
+ "create" => ValueTarget::Width(match node_param_str(node, "Type", "Float").to_lowercase().as_str() {
+ "float3" => 3,
+ "float2" => 2,
+ "float4" => 4,
+ _ => 1,
+ }),
+ "modify" if name.eq_ignore_ascii_case("Pos") || name.eq_ignore_ascii_case("Col") => ValueTarget::Width(3),
+ "modify" => ValueTarget::Named(name),
+ _ => return None,
+ };
+ Some((key, target))
+ }
+
/// The (groups, attributes) present on the evaluated geometry of node
/// `node_id`'s Input, cached on (input node id, geometry version) —
/// both empty when the Input is unconnected or names nothing. The input
@@ -3985,11 +4080,13 @@ impl State {
/// some other subnet. Attribute names get the Pos/Col built-ins appended
/// (the Attribute node can Modify them); names carrying a comma are
/// dropped — they cannot ride the type spec-string.
- fn input_pick_lists(&mut self, node_id: &str) -> (Vec<String>, Vec<String>) {
+ /// Beside the names, each point attribute's WIDTH in components, which
+ /// is what decides the control the Attribute node's Value row gets.
+ fn input_pick_lists(&mut self, node_id: &str) -> PickLists {
let input_id = crate::viewer_state::find_node_by_id(&self.fs_root, node_id)
.and_then(|n| crate::geometry::param_node(&self.fs_root, n, "Input"))
.map(|n| n.id.clone());
- let Some(input_id) = input_id else { return (Vec::new(), Vec::new()) };
+ let Some(input_id) = input_id else { return PickLists::default() };
let key = (input_id.clone(), self.rt_geometry_version);
if let Some((k, lists)) = &self.pick_cache {
if *k == key {
@@ -3999,6 +4096,7 @@ impl State {
let (frame, start) = (self.sim_frame(), self.sim_start_frame());
let mut groups = std::collections::BTreeSet::new();
let mut attrs = std::collections::BTreeSet::new();
+ let mut widths: Vec<(String, usize)> = Vec::new();
let mut sim_cache = std::mem::take(&mut self.sim_cache);
{
let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
@@ -4024,6 +4122,13 @@ impl State {
if !a.contains(',') {
attrs.insert(a.to_string());
}
+ let width = match geom.points().get(a) {
+ Some(crate::detail::AttribData::Float2(_)) => 2,
+ Some(crate::detail::AttribData::Float3(_)) => 3,
+ Some(crate::detail::AttribData::Float4(_)) => 4,
+ _ => 1,
+ };
+ widths.push((a.to_string(), width));
}
}
}
@@ -4032,7 +4137,7 @@ impl State {
let mut attrs: Vec<String> = attrs.into_iter().collect();
attrs.push("Pos".to_string());
attrs.push("Col".to_string());
- let lists = (groups.into_iter().collect(), attrs);
+ let lists = PickLists { groups: groups.into_iter().collect(), attrs, widths };
self.pick_cache = Some((key, lists.clone()));
lists
}
diff --git a/src/main.rs b/src/main.rs
index 9ed5780..331ea18 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12800,4 +12800,72 @@ mod tests {
assert!(state.run_command("toggle_playbar_repeat"));
assert!(DesignSettings::from_kdl_str(&fs::read_to_string(DesignSettings::file_path()).unwrap()).playbar_repeat);
}
+
+ /// 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() {
+ 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)]| {
+ let mut inst = template.clone();
+ inst.id = id.to_string();
+ inst.name = name.to_string();
+ for (pname, val) in params {
+ inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
+ }
+ inst
+ };
+ let mut state = State::new(false);
+ state.fs_root.children = vec![
+ instance(find("Sphere"), "s", "Sphere 1", &[]),
+ instance(find("Attribute"), "a", "pull1", &[
+ ("Input", "Sphere 1"),
+ ("Operation", "Modify"),
+ ("Attribute Name", "Pos"),
+ ("Value", "0.00:0.06:0.00"),
+ ("Combine", "Add"),
+ ]),
+ ];
+ state.sync_nodes();
+ state.graph_mut().set_selected_node(Some(1));
+ let value_row = |state: &mut State| {
+ state.sync_parameters_pane();
+ state.param_mut().node_params().iter().find(|r| r.0 == "Value").expect("a Value row").2.clone()
+ };
+ let wide = format!("float3:{}:{}", crate::app::VALUE_ROW_RANGE.0, crate::app::VALUE_ROW_RANGE.1);
+ assert_eq!(value_row(&mut state), wide, "Modify on Pos");
+ assert!(crate::app::VALUE_ROW_RANGE.0 <= -100.0 && crate::app::VALUE_ROW_RANGE.1 >= 100.0, "a wide range");
+
+ let set = |state: &mut State, name: &str, val: &str| {
+ state.fs_root.children[1].params.iter_mut().find(|p| p.name == name).unwrap().set_text(val.to_string());
+ };
+ 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");
+ set(&mut state, "Attribute Name", "nothing_here");
+ assert_eq!(value_row(&mut state), "text", "an attribute the input lacks has no width");
+
+ set(&mut state, "Operation", "Create");
+ set(&mut state, "Attribute Name", "vel");
+ 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");
+ set(&mut state, "Value", "1.00");
+ assert_eq!(value_row(&mut state), "text", "a broadcast single number stays text");
+ 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");
+
+ // 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);
+ }
}