graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: attribute and group parameter kinds — the pane's pickers follow the kind
Two new ParamKinds, `attribute` and `group`: a row that names a point
attribute or a point group on the node's input. Any text is a valid
value; what the kind changes is the params pane. add_pick_lists used to
know four rows on three node types by name (Attribute's Attribute Name
and Group, Group's Group Name, Relax's Pin Group) and upgrade only those
to textpick rows; the other thirty-odd rows that name an attribute or a
group were text boxes typed into blind. It now upgrades every row of
either kind, resolved by display key as the pane's write-back does, so a
template declares what a row names and needs no entry anywhere.
42 template rows retyped across 22 templates: every Group, Group Name,
Pin Group and Source Group is `group`; every Attribute, Attribute Name,
Source, Source B, Direction, Scale Attribute and Suture's Counter is
`attribute`. Attribute's From Min / From Max / To Min / To Max become
`float` and Neighbour's Constant a `float3` — numbers phase 1 missed.
Deliberately still text, with the reason in CLAUDE.md: Attribute's Value
(as wide as its Type row says), Transfer's Attributes (a comma list),
Simnet's Start Frame (empty means the playbar's), Bounds' Prefix, Curve's
Points, Export's File.
Old saves pick the kinds up through the template merge, as in phase 1.
The designer_default_file project (128 params) has no value that fails
the new kinds and exports identical geometry at frames 1 and 10.
template_params_carry_the_kind_they_hold pins the kinds, including the
rows that stay text; test_param_pane_pick_lists checks a Visualize node,
one the old table never listed, gets the pickers and keeps its float rows
as text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 22 +++++++++++++++
nodes/analysis.json | 4 +--
nodes/attribute.json | 14 +++++-----
nodes/bounds.json | 2 +-
nodes/collision.json | 2 +-
nodes/connectivity.json | 2 +-
nodes/copy.json | 4 +--
nodes/cull.json | 4 +--
nodes/deform.json | 2 +-
nodes/detangle.json | 2 +-
nodes/develop.json | 6 ++---
nodes/distance.json | 4 +--
nodes/group.json | 6 ++---
nodes/neighbour.json | 10 +++----
nodes/normal.json | 2 +-
nodes/relax.json | 2 +-
nodes/soft_transform.json | 4 +--
nodes/suture.json | 2 +-
nodes/time.json | 2 +-
nodes/transfer.json | 2 +-
nodes/valence.json | 2 +-
nodes/visualize.json | 4 +--
nodes/wrangle.json | 2 +-
src/api.rs | 2 +-
src/app.rs | 54 ++++++++++++++++++++++++-------------
src/main.rs | 69 ++++++++++++++++++++++++++++++++++++++++-------
src/param.rs | 21 ++++++++++++---
27 files changed, 178 insertions(+), 74 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 5502539..ce5649f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -539,6 +539,26 @@ old text Center a float3 from the load on. Tests build parameters with
(trimmed, `None` when unconnected) or resolve them with `param_node`,
never by hand. By template, not by name: Visualize's `From`/`To` are
numbers.
+- **`attribute` and `group` name a point attribute or a point group on the
+ node's input** (since 2026-09-28) — read or written, it is the same kind:
+ Visualize's `Attribute` and Normal's are both `attribute`, Relax's `Pin
+ Group` and the Group node's `Group Name` both `group`. Any text is a valid
+ value; what the kind changes is the params pane, where `add_pick_lists`
+ upgrades every row of either kind to a `textpick` (the Houdini chooser,
+ a text box with a picker of the input's names) when the input has any to
+ offer. Until then the pane knew FOUR rows by (node type, row name) —
+ Attribute's `Attribute Name` and `Group`, Group's `Group Name`, Relax's
+ `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
+ `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` /
+ `From Max` / `To Min` / `To Max` became `float` and Neighbour's `Constant`
+ a `float3`, the numbers phase 1 missed.
+ `template_params_carry_the_kind_they_hold` pins all of it, including the
+ rows that stay text.
- **`float` is a number with no range.** The pane's slider and float3 rows
hold a FRACTION of their range and clamp to it, so a threshold, a scale
factor or a manual ramp end cannot be a slider without losing values
@@ -725,6 +745,8 @@ way — both in `src/geometry.rs`:
chain's `Input` and the params pane's group/attribute pickers still
searched the whole tree by name, so in a second copy of a subnet they
found the first copy's node (`a_rest_wire_resolves_to_its_own_sibling`).
+ Which rows GET a picker is the parameter's kind — `attribute` / `group`,
+ see "Parameter kinds" — not a table of row names.
- **`switch`** passes one of `Input`, `Input 2` … `Input 4` by `Index`,
clamped; an empty slot passes nothing. Only `Input` draws a wire, the
limit every second operand has (Boolean's With, Copy's target).
diff --git a/nodes/analysis.json b/nodes/analysis.json
index b4d2117..7c03c50 100644
--- a/nodes/analysis.json
+++ b/nodes/analysis.json
@@ -16,13 +16,13 @@
},
{
"name": "Attribute",
- "type": "text",
+ "type": "attribute",
"default": "mass",
"show_when": "Source == Attribute"
},
{
"name": "Group",
- "type": "text",
+ "type": "group",
"default": "",
"show_when": "Source == Attribute"
}
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 2cc185d..e84258d 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -16,7 +16,7 @@
},
{
"name": "Attribute Name",
- "type": "text",
+ "type": "attribute",
"default": "attr1"
},
{
@@ -39,37 +39,37 @@
},
{
"name": "Group",
- "type": "text",
+ "type": "group",
"default": "",
"show_when": "Operation != Promote"
},
{
"name": "From Min",
- "type": "text",
+ "type": "float",
"default": "0.00",
"show_when": "Operation == Remap|Clip"
},
{
"name": "From Max",
- "type": "text",
+ "type": "float",
"default": "1.00",
"show_when": "Operation == Remap|Clip"
},
{
"name": "To Min",
- "type": "text",
+ "type": "float",
"default": "0.00",
"show_when": "Operation == Remap"
},
{
"name": "To Max",
- "type": "text",
+ "type": "float",
"default": "1.00",
"show_when": "Operation == Remap|Normalize"
},
{
"name": "Source B",
- "type": "text",
+ "type": "attribute",
"default": "",
"show_when": "Operation == Composite"
},
diff --git a/nodes/bounds.json b/nodes/bounds.json
index 5990402..fc4b632 100644
--- a/nodes/bounds.json
+++ b/nodes/bounds.json
@@ -6,6 +6,6 @@
"params": [
{ "name": "Input", "type": "node", "default": "" },
{ "name": "Prefix", "type": "text", "default": "bounds" },
- { "name": "Group", "type": "text", "default": "" }
+ { "name": "Group", "type": "group", "default": "" }
]
}
diff --git a/nodes/collision.json b/nodes/collision.json
index 239a81d..f8246eb 100644
--- a/nodes/collision.json
+++ b/nodes/collision.json
@@ -24,7 +24,7 @@
"type": "choice:Points,Primitives",
"default": "Points"
},
- { "name": "Group Name", "default": "collisions", "type": "text" },
+ { "name": "Group Name", "default": "collisions", "type": "group" },
{
"name": "Invert",
"type": "toggle",
diff --git a/nodes/connectivity.json b/nodes/connectivity.json
index 47eaa6f..327d9a9 100644
--- a/nodes/connectivity.json
+++ b/nodes/connectivity.json
@@ -5,6 +5,6 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Attribute", "type": "text", "default": "piece" }
+ { "name": "Attribute", "type": "attribute", "default": "piece" }
]
}
diff --git a/nodes/copy.json b/nodes/copy.json
index 9820af9..2a31a8f 100644
--- a/nodes/copy.json
+++ b/nodes/copy.json
@@ -8,7 +8,7 @@
{ "name": "To", "type": "node", "default": "" },
{ "name": "Orient", "type": "choice:None,Normal", "default": "None" },
{ "name": "Scale", "type": "float", "default": "1.00" },
- { "name": "Scale Attribute", "type": "text", "default": "" },
- { "name": "Group", "type": "text", "default": "" }
+ { "name": "Scale Attribute", "type": "attribute", "default": "" },
+ { "name": "Group", "type": "group", "default": "" }
]
}
diff --git a/nodes/cull.json b/nodes/cull.json
index 28db4c3..06244c0 100644
--- a/nodes/cull.json
+++ b/nodes/cull.json
@@ -5,8 +5,8 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Group", "type": "text", "default": "" },
- { "name": "Attribute", "type": "text", "default": "" },
+ { "name": "Group", "type": "group", "default": "" },
+ { "name": "Attribute", "type": "attribute", "default": "" },
{ "name": "Comparison", "type": "choice:Below,Above", "default": "Below" },
{ "name": "Threshold", "type": "float", "default": "0.50" },
{ "name": "Invert", "type": "choice:false,true", "default": "false" }
diff --git a/nodes/deform.json b/nodes/deform.json
index 9c0fd98..dc639dd 100644
--- a/nodes/deform.json
+++ b/nodes/deform.json
@@ -8,6 +8,6 @@
{ "name": "Mode", "type": "choice:Twist,Bend,Taper", "default": "Twist" },
{ "name": "Axis", "type": "choice:X,Y,Z", "default": "Y" },
{ "name": "Amount", "type": "slider", "default": "1.00", "min": -4.0, "max": 4.0, "step": 0.05 },
- { "name": "Group", "type": "text", "default": "" }
+ { "name": "Group", "type": "group", "default": "" }
]
}
diff --git a/nodes/detangle.json b/nodes/detangle.json
index a0fc3f8..af323fe 100644
--- a/nodes/detangle.json
+++ b/nodes/detangle.json
@@ -8,6 +8,6 @@
{ "name": "Thickness", "type": "slider", "default": "1.00", "min": 0.0, "max": 8.0, "step": 0.05 },
{ "name": "Rings", "type": "spinbox", "default": "2", "min": 0.0, "max": 6.0, "step": 1.0 },
{ "name": "Iterations", "type": "spinbox", "default": "4", "min": 1.0, "max": 32.0, "step": 1.0 },
- { "name": "Group", "type": "text", "default": "" }
+ { "name": "Group", "type": "group", "default": "" }
]
}
diff --git a/nodes/develop.json b/nodes/develop.json
index 95e7ffe..f3588f7 100644
--- a/nodes/develop.json
+++ b/nodes/develop.json
@@ -5,10 +5,10 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Attribute", "type": "text", "default": "growth" },
+ { "name": "Attribute", "type": "attribute", "default": "growth" },
{ "name": "Scale", "type": "slider", "default": "0.10", "min": -2.0, "max": 2.0, "step": 0.01 },
{ "name": "Direction", "type": "choice:Normal,Attribute", "default": "Normal" },
- { "name": "Source", "type": "text", "default": "" },
- { "name": "Group", "type": "text", "default": "" }
+ { "name": "Source", "type": "attribute", "default": "" },
+ { "name": "Group", "type": "group", "default": "" }
]
}
diff --git a/nodes/distance.json b/nodes/distance.json
index 1399458..42d4b39 100644
--- a/nodes/distance.json
+++ b/nodes/distance.json
@@ -6,8 +6,8 @@
"params": [
{ "name": "Input", "type": "node", "default": "" },
{ "name": "To", "type": "node", "default": "" },
- { "name": "Attribute", "type": "text", "default": "dist" },
- { "name": "Direction", "type": "text", "default": "" },
+ { "name": "Attribute", "type": "attribute", "default": "dist" },
+ { "name": "Direction", "type": "attribute", "default": "" },
{ "name": "Signed", "type": "choice:false,true", "default": "false" },
{ "name": "Maximum", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.05 }
]
diff --git a/nodes/group.json b/nodes/group.json
index a3b7c11..5dae6e1 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -11,7 +11,7 @@
},
{
"name": "Group Name",
- "type": "text",
+ "type": "group",
"default": "group1"
},
{
@@ -71,7 +71,7 @@
},
{
"name": "Attribute",
- "type": "text",
+ "type": "attribute",
"default": "",
"show_when": "Mode == Attribute"
},
@@ -89,7 +89,7 @@
},
{
"name": "Source Group",
- "type": "text",
+ "type": "group",
"default": "",
"show_when": "Mode == Expand"
},
diff --git a/nodes/neighbour.json b/nodes/neighbour.json
index 6df8b21..3bed3dd 100644
--- a/nodes/neighbour.json
+++ b/nodes/neighbour.json
@@ -11,7 +11,7 @@
},
{
"name": "Attribute",
- "type": "text",
+ "type": "attribute",
"default": "mass"
},
{
@@ -53,13 +53,13 @@
},
{
"name": "Direction",
- "type": "text",
+ "type": "attribute",
"default": "",
"show_when": "Mode == Migrate"
},
{
"name": "Group",
- "type": "text",
+ "type": "group",
"default": ""
},
{
@@ -70,13 +70,13 @@
},
{
"name": "Constant",
- "type": "text",
+ "type": "float3",
"default": "0.00:1.00:0.00",
"show_when": "Mode == Align && Target == Constant"
},
{
"name": "Source",
- "type": "text",
+ "type": "attribute",
"default": "",
"show_when": "Mode == Align|Lead"
},
diff --git a/nodes/normal.json b/nodes/normal.json
index ed3fa2a..b380826 100644
--- a/nodes/normal.json
+++ b/nodes/normal.json
@@ -5,7 +5,7 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Attribute", "type": "text", "default": "N" },
+ { "name": "Attribute", "type": "attribute", "default": "N" },
{ "name": "Flip", "type": "choice:false,true", "default": "false" }
]
}
diff --git a/nodes/relax.json b/nodes/relax.json
index 7304504..d7db17a 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -7,7 +7,7 @@
{ "name": "Input", "default": "", "type": "node" },
{ "name": "Mode", "type": "choice:Springs,Repel", "default": "Springs" },
{ "name": "Rest", "default": "", "type": "node", "show_when": "Mode == Springs" },
- { "name": "Pin Group", "default": "", "type": "text", "show_when": "Mode == Springs" },
+ { "name": "Pin Group", "default": "", "type": "group", "show_when": "Mode == Springs" },
{ "name": "Stiffness", "type": "slider", "default": "0.50", "min": 0.0, "max": 1.0, "step": 0.05, "show_when": "Mode == Springs" },
{ "name": "Iterations", "type": "spinbox", "default": "8", "min": 0.0, "max": 50.0, "step": 1.0 },
{ "name": "Radius", "type": "slider", "default": "0.05", "min": 0.001, "max": 1.0, "step": 0.001, "show_when": "Mode == Repel" },
diff --git a/nodes/soft_transform.json b/nodes/soft_transform.json
index ab03f78..ea2db18 100644
--- a/nodes/soft_transform.json
+++ b/nodes/soft_transform.json
@@ -7,9 +7,9 @@
{ "name": "Input", "type": "node", "default": "" },
{ "name": "Translation", "type": "float3", "default": "0.00:0.20:0.00" },
{ "name": "Center", "type": "float3", "default": "0.00:0.00:0.00" },
- { "name": "Group", "type": "text", "default": "" },
+ { "name": "Group", "type": "group", "default": "" },
{ "name": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01 },
{ "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth" },
- { "name": "Attribute", "type": "text", "default": "" }
+ { "name": "Attribute", "type": "attribute", "default": "" }
]
}
diff --git a/nodes/suture.json b/nodes/suture.json
index 4cce828..7b04db9 100644
--- a/nodes/suture.json
+++ b/nodes/suture.json
@@ -8,6 +8,6 @@
{ "name": "Against", "type": "node", "default": "" },
{ "name": "Distance Threshold", "type": "slider", "default": "0.05", "min": 0.0, "max": 1.0, "step": 0.005 },
{ "name": "Fusion Threshold", "type": "spinbox", "default": "3", "min": 1.0, "max": 100.0, "step": 1.0 },
- { "name": "Counter", "type": "text", "default": "contact" }
+ { "name": "Counter", "type": "attribute", "default": "contact" }
]
}
diff --git a/nodes/time.json b/nodes/time.json
index bc7daea..e03fbb1 100644
--- a/nodes/time.json
+++ b/nodes/time.json
@@ -5,7 +5,7 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Attribute", "type": "text", "default": "t" },
+ { "name": "Attribute", "type": "attribute", "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/nodes/transfer.json b/nodes/transfer.json
index e26fb92..5ca01b4 100644
--- a/nodes/transfer.json
+++ b/nodes/transfer.json
@@ -8,6 +8,6 @@
{ "name": "From", "type": "node", "default": "" },
{ "name": "Attributes", "type": "text", "default": "" },
{ "name": "Maximum Distance", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01 },
- { "name": "Group", "type": "text", "default": "" }
+ { "name": "Group", "type": "group", "default": "" }
]
}
diff --git a/nodes/valence.json b/nodes/valence.json
index 09b03a0..f6f6e8d 100644
--- a/nodes/valence.json
+++ b/nodes/valence.json
@@ -5,7 +5,7 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Attribute", "type": "text", "default": "valence" },
+ { "name": "Attribute", "type": "attribute", "default": "valence" },
{ "name": "Measure", "type": "choice:Neighbours,Primitives", "default": "Neighbours" }
]
}
diff --git a/nodes/visualize.json b/nodes/visualize.json
index be5adab..2090268 100644
--- a/nodes/visualize.json
+++ b/nodes/visualize.json
@@ -11,7 +11,7 @@
},
{
"name": "Attribute",
- "type": "text",
+ "type": "attribute",
"default": "mass"
},
{
@@ -69,7 +69,7 @@
},
{
"name": "Group",
- "type": "text",
+ "type": "group",
"default": ""
}
]
diff --git a/nodes/wrangle.json b/nodes/wrangle.json
index b2743b7..f9d3712 100644
--- a/nodes/wrangle.json
+++ b/nodes/wrangle.json
@@ -6,7 +6,7 @@
"params": [
{ "name": "Input", "default": "", "type": "node" },
{ "name": "Class", "type": "choice:Points,Primitives,Detail", "default": "Points" },
- { "name": "Group", "default": "", "type": "text" },
+ { "name": "Group", "default": "", "type": "group" },
{
"name": "Code",
"type": "code",
diff --git a/src/api.rs b/src/api.rs
index 3633b14..646fc57 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -155,7 +155,7 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
"properties": {
"slot": slot("Child index in the current network level"),
"name": { "type": "string" },
- "param_type": { "type": "string", "description": "One of text, float, slider, spinbox, float3, choice, toggle, button, code, node — optionally with detail after a colon (slider:-2:2, choice:A,B,C)" },
+ "param_type": { "type": "string", "description": "One of text, float, slider, spinbox, float3, choice, toggle, button, code, node, attribute, group — optionally with detail after a colon (slider:-2:2, choice:A,B,C)" },
"default": { "type": "string", "description": "Default value, as a string" },
},
"required": ["slot", "name", "param_type", "default"],
diff --git a/src/app.rs b/src/app.rs
index ee427c7..26e0616 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -569,10 +569,13 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
// then write zero back over it.
let ptype = if p.is_expr() {
"text".to_string()
- } else if matches!(kind, ParamKind::Text | ParamKind::Float | ParamKind::Node) {
+ } else if matches!(kind, ParamKind::Text | ParamKind::Float | ParamKind::Node | ParamKind::Attribute | ParamKind::Group) {
// The pane has no numeric-text or node-picker row; both are a
// text box there. `string` (an absent type) is one too — the
- // pane does not know that word and would draw nothing.
+ // pane does not know that word and would draw nothing. An
+ // attribute or group name is a text box as well, upgraded to a
+ // `textpick` row by `add_pick_lists` when the input has names
+ // to offer.
"text".to_string()
} else if p.ty() == "slider" {
let min = p.min.unwrap_or(0.0);
@@ -3843,36 +3846,51 @@ impl State {
self.param_pane_source = if self.is_detached_network { None } else { self.param_pane_target() };
}
- /// Upgrade a selected group/attribute node's group- and attribute-name
- /// text rows to `textpick` rows carrying the candidates read off the
- /// node's INPUT geometry (the Houdini attribute/group chooser). Rows stay
- /// plain text when there is no input, evaluation fails, or the list is
- /// empty — the picker degrades to nothing rather than an empty menu.
+ /// Upgrade the selected node's `attribute`- and `group`-kind text rows
+ /// to `textpick` rows carrying the candidates read off the node's INPUT
+ /// geometry (the Houdini attribute/group chooser). Rows stay plain text
+ /// when there is no input, evaluation fails, or the list is empty — the
+ /// picker degrades to nothing rather than an empty menu.
+ ///
+ /// By KIND, not by name: until 2026-09-28 this knew four rows on three
+ /// node types by their names (Attribute's Attribute Name and Group, the
+ /// Group node's Group Name, Relax's Pin Group), and the other thirty-odd
+ /// rows that name an attribute or a group — every Visualize, Cull,
+ /// Neighbour and Wrangle Group, every operator's output Attribute — were
+ /// 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.
fn add_pick_lists(
&mut self,
mut params: Vec<(String, String, String)>,
) -> Vec<(String, String, String)> {
- let (node_type, node_id) = {
+ // 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) = {
if self.is_detached_network {
return params;
}
let Some(slot) = self.param_editor_selected() else { return params };
let dir = self.param_editor_dir();
let Some(node) = dir.children.get(slot) else { return params };
- let nt = node.node_type.to_lowercase();
- if nt != "attribute" && nt != "group" && nt != "relax" {
+ let kinds: Vec<(String, ParamKind)> = node
+ .params
+ .iter()
+ .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() {
return params;
}
- (nt, node.id.clone())
+ (node.id.clone(), kinds)
};
let (groups, attrs) = self.input_pick_lists(&node_id);
for row in params.iter_mut() {
- let list = match (node_type.as_str(), row.0.as_str()) {
- ("attribute", "Attribute Name") => &attrs,
- ("attribute", "Group") => &groups,
- ("group", "Group Name") => &groups,
- ("relax", "Pin Group") => &groups,
- _ => continue,
+ let Some((_, kind)) = kinds.iter().find(|(key, _)| *key == row.0) else { continue };
+ let list = match kind {
+ ParamKind::Attribute => &attrs,
+ _ => &groups,
};
if row.2 == "text" && !list.is_empty() {
row.2 = format!("textpick:{}", list.join(","));
@@ -4507,7 +4525,7 @@ impl State {
.map(|p| p.kind())
.unwrap_or(ParamKind::Text);
let func = match target_kind {
- ParamKind::Text | ParamKind::Node | ParamKind::Choice | ParamKind::Code => "chs",
+ ParamKind::Text | ParamKind::Node | ParamKind::Attribute | ParamKind::Group | ParamKind::Choice | ParamKind::Code => "chs",
_ => "ch",
};
let full = if path.is_empty() { src_p.clone() } else { format!("{path}/{src_p}") };
diff --git a/src/main.rs b/src/main.rs
index 35d9b35..ceff1e8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3631,11 +3631,13 @@ mod tests {
assert!(!geom.points().has("mass"));
}
- /// The param pane's attribute/group pickers: selecting an Attribute node
- /// upgrades its name/group text rows to textpick rows whose candidates
- /// are read off the INPUT geometry (groups from group: tags, attributes
- /// plus the Pos/Col built-ins); a Group node with a group-less input
- /// keeps a plain text row (no empty menu).
+ /// The param pane's attribute/group pickers: selecting a node upgrades
+ /// its `attribute`- and `group`-kind rows to textpick rows whose
+ /// candidates are read off the INPUT geometry (attributes plus the
+ /// Pos/Col built-ins); a Group node with a group-less input keeps a
+ /// plain text row (no empty menu). By KIND: a Visualize node — one the
+ /// old by-name table never listed — gets the same pickers, and its
+ /// `float` rows stay text.
#[test]
fn test_param_pane_pick_lists() {
let templates_root = crate::app::load_fs_tree();
@@ -3664,6 +3666,7 @@ mod tests {
("Size", "2.00:0.50:2.00"),
]),
instance(find("Attribute"), "a", "Attr 1", &[("Input", "Group 1")]),
+ instance(find("Visualize"), "v", "Vis 1", &[("Input", "Group 1"), ("Range", "Manual")]),
];
state.sync_nodes();
@@ -3691,6 +3694,20 @@ mod tests {
let rows = state.param_mut().node_params();
let gn = rows.iter().find(|r| r.0 == "Group Name").unwrap();
assert_eq!(gn.2, "text");
+
+ // Visualize: its Attribute and Group rows are pickers because the
+ // template says what they name, not because this node is listed
+ // anywhere; From and To are numbers and stay text boxes.
+ state.graph_mut().set_selected_node(Some(3));
+ state.sync_parameters_pane();
+ let rows = state.param_mut().node_params();
+ let row = |name: &str| {
+ rows.iter().find(|r| r.0 == name).unwrap_or_else(|| panic!("row {name}")).2.clone()
+ };
+ assert!(row("Attribute").starts_with("textpick:") && row("Attribute").contains("Pos"), "got {}", row("Attribute"));
+ assert_eq!(row("Group"), "textpick:group1");
+ assert_eq!(row("From"), "text");
+ assert_eq!(row("Input"), "text");
}
/// The point overlays are a VIEW setting, not a node property: the
@@ -4195,7 +4212,12 @@ mod tests {
/// Phase 1: every parameter that names another node is a `node`, and
/// the numbers and vectors that shipped as `text` are what they hold.
/// By TEMPLATE, not by name: Visualize's From and To are numbers where
- /// Transfer's From and Copy's To are wires.
+ /// Transfer's From and Copy's To are wires. Since 2026-09-28 a row that
+ /// names a point attribute is an `attribute` and one that names a group
+ /// a `group` — read or written, the picker is the same — and what is
+ /// still `text` is text for a reason: Attribute's Value is as wide as
+ /// its Type row says, Transfer's Attributes is a comma list, Simnet's
+ /// Start Frame is empty for "the playbar's".
#[test]
fn template_params_carry_the_kind_they_hold() {
use crate::app::ParamKind as K;
@@ -4216,9 +4238,35 @@ mod tests {
for (ty, name) in [("grid", "Center"), ("polygon", "Center"), ("soft_transform", "Center"), ("soft_transform", "Translation")] {
assert_eq!(kind(ty, name), K::Float3, "{ty}'s {name}");
}
- for (ty, name) in [("cull", "Threshold"), ("group", "Threshold"), ("copy", "Scale"), ("visualize", "From"), ("visualize", "To")] {
+ for (ty, name) in [
+ ("cull", "Threshold"), ("group", "Threshold"), ("copy", "Scale"), ("visualize", "From"), ("visualize", "To"),
+ ("attribute", "From Min"), ("attribute", "From Max"), ("attribute", "To Min"), ("attribute", "To Max"),
+ ] {
assert_eq!(kind(ty, name), K::Float, "{ty}'s {name}");
}
+ assert_eq!(kind("neighbour", "Constant"), K::Float3);
+ for (ty, name) in [
+ ("attribute", "Attribute Name"), ("attribute", "Source B"), ("visualize", "Attribute"), ("neighbour", "Attribute"),
+ ("neighbour", "Direction"), ("neighbour", "Source"), ("distance", "Direction"), ("develop", "Source"),
+ ("copy", "Scale Attribute"), ("normal", "Attribute"), ("suture", "Counter"), ("time", "Attribute"),
+ ] {
+ assert_eq!(kind(ty, name), K::Attribute, "{ty}'s {name}");
+ }
+ for (ty, name) in [
+ ("attribute", "Group"), ("group", "Group Name"), ("group", "Source Group"), ("relax", "Pin Group"),
+ ("collision", "Group Name"), ("wrangle", "Group"), ("visualize", "Group"), ("cull", "Group"),
+ ] {
+ assert_eq!(kind(ty, name), K::Group, "{ty}'s {name}");
+ }
+ // Every row called Group, on every template, names a group.
+ for t in &root.children {
+ for p in t.params.iter().filter(|p| p.name == "Group") {
+ assert_eq!(p.kind(), K::Group, "{}'s Group", t.name);
+ }
+ }
+ for (ty, name) in [("attribute", "Value"), ("transfer", "Attributes"), ("simnet", "Start Frame"), ("bounds", "Prefix")] {
+ assert_eq!(kind(ty, name), K::Text, "{ty}'s {name} stays text on purpose");
+ }
// Every template's Input is a wire, top level and composed children alike.
fn inputs(n: &FsNode, out: &mut Vec<(String, crate::app::ParamKind)>) {
for p in n.params.iter().filter(|p| p.name == "Input") {
@@ -4256,13 +4304,14 @@ mod tests {
/// The pane has no node or numeric-text row: both show as text, and so
/// does `string` (an absent type), which the pane would otherwise not
- /// recognise at all.
+ /// recognise at all — and so do an attribute and a group name, until
+ /// `add_pick_lists` has candidates to offer.
#[test]
fn node_and_float_rows_show_as_text() {
let row = |ty: &str| crate::app::ParamDef::new("X", ty, "1");
- let shown = crate::app::param_display(&[row("node"), row("float"), row("string"), row("toggle")]);
+ let shown = crate::app::param_display(&[row("node"), row("float"), row("string"), row("attribute"), row("group"), row("toggle")]);
let types: Vec<&str> = shown.iter().map(|r| r.2.as_str()).collect();
- assert_eq!(types, vec!["text", "text", "text", "toggle"]);
+ assert_eq!(types, vec!["text", "text", "text", "text", "text", "toggle"]);
}
/// Phase 2's toggle reader: the words a toggle can hold, in any case,
diff --git a/src/param.rs b/src/param.rs
index 3c1ca91..89385dd 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -59,13 +59,24 @@ pub enum ParamKind {
/// `geometry::find_input_node` — an `Input` wire, a Boolean's `With`,
/// a Relax's `Rest`. Empty means unconnected.
Node,
+ /// The NAME of a point attribute on the node's input — one it reads
+ /// (Visualize's Attribute, Neighbour's Direction) or one it writes
+ /// (Normal's Attribute, Suture's Counter). Any text is a valid value;
+ /// what the kind changes is the pane, which offers the input's
+ /// attributes as a picker on every row of this kind
+ /// (`State::add_pick_lists`), where it used to know four rows by name.
+ Attribute,
+ /// The NAME of a point group on the node's input, read or written; the
+ /// pane offers the input's groups. Empty means every point, which is
+ /// what every Group row's default is.
+ Group,
}
impl ParamKind {
/// The type-string heads [`ParamKind::parse`] accepts, for messages.
/// `string` is left out: it is an alias, not something to ask for.
pub const NAMES: &'static [&'static str] =
- &["text", "float", "slider", "spinbox", "float3", "choice", "toggle", "button", "code", "node"];
+ &["text", "float", "slider", "spinbox", "float3", "choice", "toggle", "button", "code", "node", "attribute", "group"];
/// The kind a `type` string names, or `None` when it names none.
pub fn parse(ty: &str) -> Option<Self> {
@@ -81,6 +92,8 @@ impl ParamKind {
"button" => Self::Button,
"code" => Self::Code,
"node" => Self::Node,
+ "attribute" => Self::Attribute,
+ "group" => Self::Group,
_ => return None,
})
}
@@ -98,7 +111,7 @@ pub enum ParamValue {
/// The option, spelled as the options list spells it (the text may
/// differ in case, and an empty text means the first option).
Choice(String),
- /// Text, Node, Code and Button: the text is the value.
+ /// Text, Node, Attribute, Group, Code and Button: the text is the value.
Text(String),
}
@@ -238,7 +251,9 @@ pub fn parse_value(kind: ParamKind, text: &str, options: &[String]) -> Result<Pa
let t = text.trim();
let number = |t: &str| t.parse::<f32>().ok().filter(|n| n.is_finite());
match kind {
- ParamKind::Text | ParamKind::Node | ParamKind::Code | ParamKind::Button => Ok(ParamValue::Text(text.to_string())),
+ ParamKind::Text | ParamKind::Node | ParamKind::Attribute | ParamKind::Group | ParamKind::Code | ParamKind::Button => {
+ Ok(ParamValue::Text(text.to_string()))
+ }
ParamKind::Float | ParamKind::Slider => {
number(t).map(ParamValue::Number).ok_or_else(|| format!("'{text}' is not a number"))
}