graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: parameter kinds and typed readers (typed values, phases 0-2)
Phase 0: a param's type string now names a ParamKind, parsed by its head
in one place (ParamDef::kind). Every type-string comparison dispatches on
it. A type naming no kind is refused: the template loader drops the
template with a message, and MCP add_param returns an error listing the
kinds (its description had suggested "int", which was never one).
Phase 1: templates carry the kind they hold. Every wire (Input, Switch's
Input 2-4, With, Collider, Rest, Against, Copy's and Distance's To,
Transfer's From) is `node`. Grid's, Polygon's and Soft Transform's Center
and Soft Transform's Translation are float3. Thresholds, Copy's Scale and
Visualize's From/To are the new `float`, a number with no range, shown as
a text row because the pane's sliders clamp to their range. Old saves
pick the kinds up through the template merge.
Phase 2: node_param_bool replaces 24 hand-rolled toggle reads that mixed
== "true" and != "false"; node_param_node / param_node replace the
empty-check-then-lookup blocks at every wire. Collider, Rest, the page
chain's Input and the params pane's group/attribute pickers had been
whole-tree searches by name and are now sibling-first, so a second copy
of a subnet no longer resolves them to the first copy's nodes.
The designer_default_file project exports identically before and after
at frames 1 and 10.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 50 ++++++-
nodes/analysis.json | 2 +-
nodes/attribute.json | 2 +-
nodes/boolean.json | 4 +-
nodes/bounds.json | 2 +-
nodes/collision.json | 4 +-
nodes/connectivity.json | 2 +-
nodes/copy.json | 6 +-
nodes/cull.json | 4 +-
nodes/deform.json | 2 +-
nodes/detangle.json | 2 +-
nodes/develop.json | 2 +-
nodes/distance.json | 4 +-
nodes/embryo.json | 2 +-
nodes/export.json | 2 +-
nodes/extrude.json | 2 +-
nodes/grid.json | 2 +-
nodes/group.json | 4 +-
nodes/hull.json | 2 +-
nodes/mold_shell.json | 2 +-
nodes/neighbour.json | 2 +-
nodes/normal.json | 2 +-
nodes/output.json | 2 +-
nodes/page_border.json | 2 +-
nodes/page_grid.json | 2 +-
nodes/page_text.json | 2 +-
nodes/polygon.json | 2 +-
nodes/relax.json | 4 +-
nodes/remesh.json | 2 +-
nodes/scatter.json | 2 +-
nodes/simnet.json | 2 +-
nodes/soft_transform.json | 6 +-
nodes/subdivide.json | 2 +-
nodes/subnet.json | 2 +-
nodes/suture.json | 4 +-
nodes/switch.json | 8 +-
nodes/time.json | 2 +-
nodes/transfer.json | 4 +-
nodes/transform.json | 2 +-
nodes/valence.json | 2 +-
nodes/visualize.json | 6 +-
nodes/volume.json | 2 +-
nodes/wrangle.json | 2 +-
src/api.rs | 2 +-
src/app.rs | 155 ++++++++++++++++++---
src/geometry.rs | 344 ++++++++++++++++++++--------------------------
src/main.rs | 170 +++++++++++++++++++++++
src/page.rs | 8 +-
src/project.rs | 2 +-
src/shapes.rs | 8 +-
src/window.rs | 7 +-
51 files changed, 581 insertions(+), 283 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 951c186..339a388 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -475,6 +475,47 @@ diagnosis is in the git history of this section (commit `8fd0c29`) if the
pattern ever recurs with another driver: a `read` returning EBADF on a file
nothing is wrong with, in a process that has loaded a vendor ICD.
+### Parameter kinds
+
+A `ParamDef`'s `type` string names a `ParamKind` (`app.rs`) —
+`ParamKind::parse` reads the head before the first `:` (`slider:-2:2` is a
+Slider, `choice:A,B` a Choice; `string`, what an absent type deserializes to,
+is Text). `ParamDef::kind()` is the one place the string is interpreted;
+`param_display`, `format_for_param`, `param_number`, the float3 expression
+split, Paste Reference's `ch`/`chs` choice and the pane write-back all
+dispatch on it. The VALUE is still the `default` string — kinds are phases
+0–2 of the typed-value migration, and storage (phase 3) has not moved.
+
+- **A type naming no kind is refused**, not read as text: `load_fs_tree`
+ drops the template with a message (as it does an unparseable one), MCP's
+ `add_param` returns an error listing `ParamKind::NAMES`, and
+ `every_shipped_template_param_has_a_known_kind` walks the raw files.
+ `kind()` itself still falls back to Text so a hand-edited save stays
+ editable.
+- **`node` is a wire**: every `Input`, Switch's `Input 2`–`4`, Boolean's
+ `With`, Collision's `Collider`, Relax's `Rest`, Suture's `Against`, Copy's
+ and Distance's `To`, Transfer's `From`. Read them with `node_param_node`
+ (trimmed, `None` when unconnected) or resolve them with `param_node`,
+ never by hand. By template, not by name: Visualize's `From`/`To` are
+ numbers.
+- **`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
+ outside it. `param_display` shows `float` and `node` as text rows — cce-ui
+ is shared and has neither, and the conversion stays at this app's edge.
+- **Toggles read through `node_param_bool`**: `true`/`1`/`on` and
+ `false`/`0`/`off` in any case, else the fallback. The sites it replaced
+ mixed `== "true"` and `!= "false"`, which disagreed about garbage.
+- **Choices are still read as option TEXT** (`eq_ignore_ascii_case`), not
+ by index. Matching text survives a template reordering its options; an
+ index would silently change meaning. Expressions, which need a number,
+ already get the index through `param_number`.
+
+Saved projects need no migration for any of this: `merge_template_defs`
+hands every instance its template's type along with the rest of the UI
+metadata, so an old save's `"type": "text"` wire loads as `node`
+(`a_saved_text_wire_loads_as_a_node_wire`).
+
### Conditional parameter rows
A `ParamDef` may carry `show_when`, a condition over its SIBLINGS' current
@@ -636,8 +677,13 @@ way — both in `src/geometry.rs`:
anywhere.** Every resolver used to search the whole tree from the top, so
inside the second instance of a subnet a child wired to "input1" found the
first instance's; the opencl and output resolvers had each grown a
- sibling-first lookup of their own to dodge exactly that. All 39 lookups go
- through it now.
+ sibling-first lookup of their own to dodge exactly that. Every wire goes
+ through it now, most as `param_node(root, target, "Input")` (see
+ "Parameter kinds"). That was claimed on 2026-09-21 and was not quite
+ true until 2026-09-26: Collision's `Collider`, Relax's `Rest`, the page
+ 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`).
- **`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 b268481..b4d2117 100644
--- a/nodes/analysis.json
+++ b/nodes/analysis.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
diff --git a/nodes/attribute.json b/nodes/attribute.json
index ab52b08..2cc185d 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
diff --git a/nodes/boolean.json b/nodes/boolean.json
index f194a54..2200af9 100644
--- a/nodes/boolean.json
+++ b/nodes/boolean.json
@@ -4,8 +4,8 @@
"inputs": 2,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "With", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
+ { "name": "With", "type": "node", "default": "" },
{ "name": "Operation", "type": "choice:Union,Intersect,Subtract", "default": "Union" },
{ "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005 }
]
diff --git a/nodes/bounds.json b/nodes/bounds.json
index 7fe40bc..5990402 100644
--- a/nodes/bounds.json
+++ b/nodes/bounds.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Prefix", "type": "text", "default": "bounds" },
{ "name": "Group", "type": "text", "default": "" }
]
diff --git a/nodes/collision.json b/nodes/collision.json
index 1009f8b..239a81d 100644
--- a/nodes/collision.json
+++ b/nodes/collision.json
@@ -4,8 +4,8 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" },
- { "name": "Collider", "default": "", "type": "text" },
+ { "name": "Input", "default": "", "type": "node" },
+ { "name": "Collider", "default": "", "type": "node" },
{
"name": "Method",
"type": "choice:Inside,Proximity",
diff --git a/nodes/connectivity.json b/nodes/connectivity.json
index 1b3715c..47eaa6f 100644
--- a/nodes/connectivity.json
+++ b/nodes/connectivity.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Attribute", "type": "text", "default": "piece" }
]
}
diff --git a/nodes/copy.json b/nodes/copy.json
index 6bdba6a..9820af9 100644
--- a/nodes/copy.json
+++ b/nodes/copy.json
@@ -4,10 +4,10 @@
"inputs": 2,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "To", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
+ { "name": "To", "type": "node", "default": "" },
{ "name": "Orient", "type": "choice:None,Normal", "default": "None" },
- { "name": "Scale", "type": "text", "default": "1.00" },
+ { "name": "Scale", "type": "float", "default": "1.00" },
{ "name": "Scale Attribute", "type": "text", "default": "" },
{ "name": "Group", "type": "text", "default": "" }
]
diff --git a/nodes/cull.json b/nodes/cull.json
index 431b038..28db4c3 100644
--- a/nodes/cull.json
+++ b/nodes/cull.json
@@ -4,11 +4,11 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Group", "type": "text", "default": "" },
{ "name": "Attribute", "type": "text", "default": "" },
{ "name": "Comparison", "type": "choice:Below,Above", "default": "Below" },
- { "name": "Threshold", "type": "text", "default": "0.50" },
+ { "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 879e814..9c0fd98 100644
--- a/nodes/deform.json
+++ b/nodes/deform.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "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 },
diff --git a/nodes/detangle.json b/nodes/detangle.json
index 8562b59..a0fc3f8 100644
--- a/nodes/detangle.json
+++ b/nodes/detangle.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "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 },
diff --git a/nodes/develop.json b/nodes/develop.json
index f5354d7..95e7ffe 100644
--- a/nodes/develop.json
+++ b/nodes/develop.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Attribute", "type": "text", "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" },
diff --git a/nodes/distance.json b/nodes/distance.json
index 8ed5f41..1399458 100644
--- a/nodes/distance.json
+++ b/nodes/distance.json
@@ -4,8 +4,8 @@
"inputs": 2,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "To", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
+ { "name": "To", "type": "node", "default": "" },
{ "name": "Attribute", "type": "text", "default": "dist" },
{ "name": "Direction", "type": "text", "default": "" },
{ "name": "Signed", "type": "choice:false,true", "default": "false" },
diff --git a/nodes/embryo.json b/nodes/embryo.json
index c2fed30..047985c 100644
--- a/nodes/embryo.json
+++ b/nodes/embryo.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
diff --git a/nodes/export.json b/nodes/export.json
index 8555fa9..89040d4 100644
--- a/nodes/export.json
+++ b/nodes/export.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "File", "type": "text", "default": "" },
{ "name": "Format", "type": "choice:STL,STL (ASCII),OBJ", "default": "STL" },
{ "name": "Scale", "type": "slider", "default": "1.00", "min": 0.001, "max": 100.0, "step": 0.01 },
diff --git a/nodes/extrude.json b/nodes/extrude.json
index 8f69fc0..9bf43b3 100644
--- a/nodes/extrude.json
+++ b/nodes/extrude.json
@@ -7,7 +7,7 @@
{
"name": "Input",
"default": "",
- "type": "text"
+ "type": "node"
},
{
"name": "Distance",
diff --git a/nodes/grid.json b/nodes/grid.json
index 144fbf1..d646aa4 100644
--- a/nodes/grid.json
+++ b/nodes/grid.json
@@ -8,6 +8,6 @@
{ "name": "Columns", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0 },
{ "name": "Width", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05 },
{ "name": "Length", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05 },
- { "name": "Center", "type": "text", "default": "0.00:0.00:0.00" }
+ { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00" }
]
}
diff --git a/nodes/group.json b/nodes/group.json
index b2928b8..a3b7c11 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
@@ -83,7 +83,7 @@
},
{
"name": "Threshold",
- "type": "text",
+ "type": "float",
"default": "0.50",
"show_when": "Mode == Attribute"
},
diff --git a/nodes/hull.json b/nodes/hull.json
index 59f7743..ed817f6 100644
--- a/nodes/hull.json
+++ b/nodes/hull.json
@@ -4,6 +4,6 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" }
+ { "name": "Input", "type": "node", "default": "" }
]
}
diff --git a/nodes/mold_shell.json b/nodes/mold_shell.json
index b0e549d..d7fa0a1 100644
--- a/nodes/mold_shell.json
+++ b/nodes/mold_shell.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Maximum Thickness", "type": "slider", "default": "0.75", "min": 0.001, "max": 5.0, "step": 0.01 },
{ "name": "Minimum Thickness", "type": "slider", "default": "0.60", "min": 0.001, "max": 5.0, "step": 0.01 },
{ "name": "Remesh Division Size", "type": "slider", "default": "0.90", "min": 0.01, "max": 5.0, "step": 0.01 },
diff --git a/nodes/neighbour.json b/nodes/neighbour.json
index b6b4b7c..6df8b21 100644
--- a/nodes/neighbour.json
+++ b/nodes/neighbour.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
diff --git a/nodes/normal.json b/nodes/normal.json
index f430c51..ed3fa2a 100644
--- a/nodes/normal.json
+++ b/nodes/normal.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Attribute", "type": "text", "default": "N" },
{ "name": "Flip", "type": "choice:false,true", "default": "false" }
]
diff --git a/nodes/output.json b/nodes/output.json
index d0a33e7..762d95f 100644
--- a/nodes/output.json
+++ b/nodes/output.json
@@ -4,6 +4,6 @@
"inputs": 1,
"outputs": 0,
"params": [
- { "name": "Input", "default": "", "type": "text" }
+ { "name": "Input", "default": "", "type": "node" }
]
}
diff --git a/nodes/page_border.json b/nodes/page_border.json
index f26c96e..91aa49a 100644
--- a/nodes/page_border.json
+++ b/nodes/page_border.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Width", "type": "slider", "default": "0.06", "min": 0.001, "max": 2.0, "step": 0.005 },
{ "name": "Inset", "type": "slider", "default": "0.40", "min": 0.0, "max": 4.0, "step": 0.05 },
{ "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 }
diff --git a/nodes/page_grid.json b/nodes/page_grid.json
index b1544a5..c59e65f 100644
--- a/nodes/page_grid.json
+++ b/nodes/page_grid.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Cell Size", "type": "slider", "default": "0.25", "min": 0.01, "max": 4.0, "step": 0.01 },
{ "name": "Line Width", "type": "slider", "default": "0.01", "min": 0.001, "max": 0.25, "step": 0.001 },
{ "name": "Line Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
diff --git a/nodes/page_text.json b/nodes/page_text.json
index 8be0daa..22a722c 100644
--- a/nodes/page_text.json
+++ b/nodes/page_text.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Text", "type": "text", "default": "Title" },
{ "name": "Font", "type": "text", "default": "" },
{ "name": "Size", "type": "slider", "default": "0.25", "min": 0.02, "max": 4.0, "step": 0.01 },
diff --git a/nodes/polygon.json b/nodes/polygon.json
index 61294d3..4236a7e 100644
--- a/nodes/polygon.json
+++ b/nodes/polygon.json
@@ -8,6 +8,6 @@
{ "name": "Radius", "type": "slider", "default": "0.50", "min": 0.01, "max": 10.0, "step": 0.01 },
{ "name": "Inner Radius", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01 },
{ "name": "Fill", "type": "choice:true,false", "default": "true" },
- { "name": "Center", "type": "text", "default": "0.00:0.00:0.00" }
+ { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00" }
]
}
diff --git a/nodes/relax.json b/nodes/relax.json
index 1c55f01..7304504 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -4,9 +4,9 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" },
+ { "name": "Input", "default": "", "type": "node" },
{ "name": "Mode", "type": "choice:Springs,Repel", "default": "Springs" },
- { "name": "Rest", "default": "", "type": "text", "show_when": "Mode == Springs" },
+ { "name": "Rest", "default": "", "type": "node", "show_when": "Mode == Springs" },
{ "name": "Pin Group", "default": "", "type": "text", "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 },
diff --git a/nodes/remesh.json b/nodes/remesh.json
index d454ccd..5982a93 100644
--- a/nodes/remesh.json
+++ b/nodes/remesh.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
diff --git a/nodes/scatter.json b/nodes/scatter.json
index 8861042..4c0909d 100644
--- a/nodes/scatter.json
+++ b/nodes/scatter.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" },
+ { "name": "Input", "default": "", "type": "node" },
{ "name": "Mode", "type": "choice:Volume,Surface", "default": "Volume" },
{ "name": "Points", "type": "spinbox", "default": "100", "min": 10.0, "max": 10000.0, "step": 10.0 },
{ "name": "Seed", "type": "slider", "default": "1.1", "min": 0.0, "max": 10.0, "step": 0.1, "show_when": "Mode == Surface" },
diff --git a/nodes/simnet.json b/nodes/simnet.json
index f0b82e2..733b9eb 100644
--- a/nodes/simnet.json
+++ b/nodes/simnet.json
@@ -7,7 +7,7 @@
{
"name": "Input",
"default": "",
- "type": "text"
+ "type": "node"
},
{
"name": "Substeps",
diff --git a/nodes/soft_transform.json b/nodes/soft_transform.json
index 3bd1712..ab03f78 100644
--- a/nodes/soft_transform.json
+++ b/nodes/soft_transform.json
@@ -4,9 +4,9 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "Translation", "type": "text", "default": "0.00:0.20:0.00" },
- { "name": "Center", "type": "text", "default": "0.00:0.00:0.00" },
+ { "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": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01 },
{ "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth" },
diff --git a/nodes/subdivide.json b/nodes/subdivide.json
index dddcb10..f1f0dcd 100644
--- a/nodes/subdivide.json
+++ b/nodes/subdivide.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Depth", "type": "spinbox", "default": "1", "min": 0.0, "max": 6.0, "step": 1.0 }
]
}
diff --git a/nodes/subnet.json b/nodes/subnet.json
index df329ae..f370d32 100644
--- a/nodes/subnet.json
+++ b/nodes/subnet.json
@@ -4,6 +4,6 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" }
+ { "name": "Input", "default": "", "type": "node" }
]
}
diff --git a/nodes/suture.json b/nodes/suture.json
index 394ae60..4cce828 100644
--- a/nodes/suture.json
+++ b/nodes/suture.json
@@ -4,8 +4,8 @@
"inputs": 2,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "Against", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
+ { "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" }
diff --git a/nodes/switch.json b/nodes/switch.json
index 8a5a0f1..82d94ac 100644
--- a/nodes/switch.json
+++ b/nodes/switch.json
@@ -4,10 +4,10 @@
"inputs": 4,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "Input 2", "type": "text", "default": "" },
- { "name": "Input 3", "type": "text", "default": "" },
- { "name": "Input 4", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
+ { "name": "Input 2", "type": "node", "default": "" },
+ { "name": "Input 3", "type": "node", "default": "" },
+ { "name": "Input 4", "type": "node", "default": "" },
{ "name": "Index", "type": "spinbox", "default": "0", "min": 0, "max": 3, "step": 1 }
]
}
diff --git a/nodes/time.json b/nodes/time.json
index dcd518f..bc7daea 100644
--- a/nodes/time.json
+++ b/nodes/time.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Attribute", "type": "text", "default": "t" },
{ "name": "Start Frame", "type": "spinbox", "default": "1", "min": -9999.0, "max": 9999.0, "step": 1.0 },
{ "name": "End Frame", "type": "spinbox", "default": "100", "min": -9999.0, "max": 9999.0, "step": 1.0 },
diff --git a/nodes/transfer.json b/nodes/transfer.json
index 58b0c06..e26fb92 100644
--- a/nodes/transfer.json
+++ b/nodes/transfer.json
@@ -4,8 +4,8 @@
"inputs": 2,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "From", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
+ { "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": "" }
diff --git a/nodes/transform.json b/nodes/transform.json
index fde13df..41d6799 100644
--- a/nodes/transform.json
+++ b/nodes/transform.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" },
+ { "name": "Input", "default": "", "type": "node" },
{ "name": "Translation", "default": "0.00:0.00:0.00", "type": "float3", "min": -10.0, "max": 10.0 }
]
}
diff --git a/nodes/valence.json b/nodes/valence.json
index ed1aa66..09b03a0 100644
--- a/nodes/valence.json
+++ b/nodes/valence.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Attribute", "type": "text", "default": "valence" },
{ "name": "Measure", "type": "choice:Neighbours,Primitives", "default": "Neighbours" }
]
diff --git a/nodes/visualize.json b/nodes/visualize.json
index 1ecbe89..be5adab 100644
--- a/nodes/visualize.json
+++ b/nodes/visualize.json
@@ -6,7 +6,7 @@
"params": [
{
"name": "Input",
- "type": "text",
+ "type": "node",
"default": ""
},
{
@@ -33,13 +33,13 @@
},
{
"name": "From",
- "type": "text",
+ "type": "float",
"default": "0.00",
"show_when": "Mode == Ramp && Range == Manual"
},
{
"name": "To",
- "type": "text",
+ "type": "float",
"default": "1.00",
"show_when": "Mode == Ramp && Range == Manual"
},
diff --git a/nodes/volume.json b/nodes/volume.json
index 16e737c..252c92f 100644
--- a/nodes/volume.json
+++ b/nodes/volume.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
+ { "name": "Input", "type": "node", "default": "" },
{ "name": "Mode", "type": "choice:Offset,Shell", "default": "Offset" },
{ "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005 },
{ "name": "Offset", "type": "slider", "default": "0.00", "min": -1.0, "max": 1.0, "step": 0.005 },
diff --git a/nodes/wrangle.json b/nodes/wrangle.json
index ad32935..b2743b7 100644
--- a/nodes/wrangle.json
+++ b/nodes/wrangle.json
@@ -4,7 +4,7 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" },
+ { "name": "Input", "default": "", "type": "node" },
{ "name": "Class", "type": "choice:Points,Primitives,Detail", "default": "Points" },
{ "name": "Group", "default": "", "type": "text" },
{
diff --git a/src/api.rs b/src/api.rs
index 6e329f2..e696af9 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": "e.g. float, int, slider, float3, spinbox, choice" },
+ "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)" },
"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 9b41ffe..d7dce1e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -117,15 +117,104 @@ pub struct ParamDef {
pub expr: bool,
}
+/// What a parameter HOLDS, parsed from its `type` string — the one place
+/// that string is interpreted. The value is still the `default` string;
+/// the kind says how to read it and which control the params pane draws.
+///
+/// The head before the first `:` names the kind; what follows is the
+/// kind's own detail (`slider:-2:2` a range, `choice:UV,Icosphere,Cube`
+/// the options), read by the pane and by `choice_options`. A type naming
+/// no kind is a template bug: `load_fs_tree` drops the template and says
+/// so, and `every_shipped_template_param_has_a_known_kind` walks the
+/// shipped ones.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ParamKind {
+ /// Free text: a name, a path, a group or attribute name. `string` is
+ /// the same kind — it is what an ABSENT type deserializes to.
+ Text,
+ /// A number with no range — a threshold, a scale factor, a manual
+ /// ramp end — shown as a text row, because the pane's slider holds a
+ /// fraction of its range and would clamp anything outside it.
+ Float,
+ /// A number over a range (`min`/`max`, or inline `slider:lo:hi`).
+ Slider,
+ /// An integer, stepped.
+ Spin,
+ /// Three numbers, `x:y:z`.
+ Float3,
+ /// One of a fixed set of options, stored as the option's text.
+ Choice,
+ /// `true` / `false`.
+ Toggle,
+ /// A press, not a value: the pane writes `clicked` and the app clears it.
+ Button,
+ /// A program (a wrangle's script). Never an expression.
+ Code,
+ /// The NAME of another node, resolved sibling-first by
+ /// `geometry::find_input_node` — an `Input` wire, a Boolean's `With`,
+ /// a Relax's `Rest`. Empty means unconnected.
+ Node,
+}
+
+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"];
+
+ /// The kind a `type` string names, or `None` when it names none.
+ pub fn parse(ty: &str) -> Option<Self> {
+ let head = ty.split(':').next().unwrap_or("").trim();
+ Some(match head {
+ "text" | "string" => Self::Text,
+ "float" => Self::Float,
+ "slider" => Self::Slider,
+ "spinbox" => Self::Spin,
+ "float3" => Self::Float3,
+ "choice" => Self::Choice,
+ "toggle" => Self::Toggle,
+ "button" => Self::Button,
+ "code" => Self::Code,
+ "node" => Self::Node,
+ _ => return None,
+ })
+ }
+}
+
impl ParamDef {
+ /// This parameter's kind. A type that names none reads as text — the
+ /// row stays editable and its value survives — but a shipped template
+ /// cannot carry one (see [`ParamKind`]).
+ pub fn kind(&self) -> ParamKind {
+ ParamKind::parse(&self.param_type).unwrap_or(ParamKind::Text)
+ }
+
/// Whether a value that READS as a reference should become an
/// expression here. Not for a code parameter: a kernel or a wrangle
/// script is a program, and one whose whole text happens to be
/// `ch("../a/Radius")` is a one-line program, not a channel — flagging
/// it would evaluate the script to a number before it ever ran.
pub fn takes_expressions(&self) -> bool {
- !(self.param_type == "code" || self.name == "Code")
+ !(self.kind() == ParamKind::Code || self.name == "Code")
+ }
+}
+
+/// Every parameter in `node`'s tree whose type names no [`ParamKind`], as
+/// `(path, parameter, type)`. Empty for a well-formed template.
+pub fn unknown_param_kinds(node: &FsNode) -> Vec<(String, String, String)> {
+ fn walk(node: &FsNode, path: &str, out: &mut Vec<(String, String, String)>) {
+ for p in &node.params {
+ if ParamKind::parse(&p.param_type).is_none() {
+ out.push((path.to_string(), p.name.clone(), p.param_type.clone()));
+ }
+ }
+ for c in &node.children {
+ walk(c, &format!("{path}/{}", c.name), out);
+ }
}
+ let mut out = Vec::new();
+ walk(node, &node.name, &mut out);
+ out
}
fn default_param_type() -> String { "string".to_string() }
@@ -612,6 +701,7 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
// simply is not reported and keeps whatever value it had.
params.iter().filter(|p| param_visible(params, &p.show_when)).map(|p| {
let key = if p.label.is_empty() { &p.name } else { &p.label };
+ let kind = p.kind();
let value = if p.param_type == "choice" && !p.options.is_empty() && p.default.is_empty() {
p.options[0].clone()
} else {
@@ -622,6 +712,11 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
// then write zero back over it.
let ptype = if p.expr {
"text".to_string()
+ } else if matches!(kind, ParamKind::Text | ParamKind::Float | ParamKind::Node) {
+ // 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.
+ "text".to_string()
} else if p.param_type == "slider" {
let min = p.min.unwrap_or(0.0);
let max = p.max.unwrap_or(2.0);
@@ -1053,6 +1148,18 @@ pub fn load_fs_tree() -> FsNode {
for path in paths {
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<FsNode>(&content) {
+ // A type that names no kind is dropped like a parse
+ // error, and for the same reason: loaded, the row would
+ // read as text and look like it worked.
+ Ok(node) if !unknown_param_kinds(&node).is_empty() => eprintln!(
+ "cce-designer: dropping node template {} — unknown parameter type: {}",
+ path.display(),
+ unknown_param_kinds(&node)
+ .iter()
+ .map(|(at, name, ty)| format!("{at} '{name}' is \"{ty}\""))
+ .collect::<Vec<_>>()
+ .join(", ")
+ ),
Ok(mut node) => {
infer_template_exprs(&mut node);
raw_nodes.push(node);
@@ -3400,11 +3507,11 @@ impl State {
if !p.expr && p.takes_expressions() && crate::expr::looks_like_expression(&p.default) {
p.expr = true;
}
- if p.param_type == "button" && p.default == "clicked" {
+ if p.kind() == ParamKind::Button && p.default == "clicked" {
triggered_buttons.push(p.name.clone());
p.default = "".to_string();
}
- if p.param_type == "toggle" {
+ if p.kind() == ParamKind::Toggle {
let desired = p.default == "true";
let cur = match p.name.as_str() {
"Show Network Pane" => Some(cur_show.0),
@@ -3869,7 +3976,7 @@ impl State {
&mut self,
mut params: Vec<(String, String, String)>,
) -> Vec<(String, String, String)> {
- let (node_type, input_name) = {
+ let (node_type, node_id) = {
if self.is_detached_network {
return params;
}
@@ -3880,12 +3987,9 @@ impl State {
if nt != "attribute" && nt != "group" && nt != "relax" {
return params;
}
- (nt, node_param_str(node, "Input", ""))
+ (nt, node.id.clone())
};
- if input_name.is_empty() {
- return params;
- }
- let (groups, attrs) = self.input_pick_lists(&input_name);
+ 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,
@@ -3901,12 +4005,20 @@ impl State {
params
}
- /// The (groups, attributes) present on `input_name`'s evaluated geometry,
- /// cached on (name, geometry version). 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, input_name: &str) -> (Vec<String>, Vec<String>) {
- let key = (input_name.to_string(), self.rt_geometry_version);
+ /// 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
+ /// resolves sibling-first like the wire itself (`geometry::param_node`);
+ /// a whole-tree search by name offered the groups of a same-named node in
+ /// 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>) {
+ 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 key = (input_id.clone(), self.rt_geometry_version);
if let Some((k, lists)) = &self.pick_cache {
if *k == key {
return lists.clone();
@@ -3918,7 +4030,7 @@ impl State {
let mut sim_cache = std::mem::take(&mut self.sim_cache);
{
let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
- if let Some(input_node) = find_node_by_name(&self.fs_root, input_name) {
+ if let Some(input_node) = crate::viewer_state::find_node_by_id(&self.fs_root, &input_id) {
let mut visited = Vec::new();
let mut err = None;
if let Some(geom) = generate_single_node_geometry_with_errors(
@@ -4514,11 +4626,14 @@ impl State {
// number — by the TARGET, since that is what the value has
// to fit: a choice pasted onto a switch's Index wants the
// option's index, pasted onto a text row its name.
- let target_type = crate::viewer_state::find_node_by_id(&self.fs_root, node_id)
+ let target_kind = crate::viewer_state::find_node_by_id(&self.fs_root, node_id)
.and_then(|n| n.params.iter().find(|p| p.name == pname))
- .map(|p| p.param_type.clone())
- .unwrap_or_default();
- let func = if target_type == "text" || target_type == "string" || target_type.starts_with("choice") { "chs" } else { "ch" };
+ .map(|p| p.kind())
+ .unwrap_or(ParamKind::Text);
+ let func = match target_kind {
+ ParamKind::Text | ParamKind::Node | ParamKind::Choice | ParamKind::Code => "chs",
+ _ => "ch",
+ };
let full = if path.is_empty() { src_p.clone() } else { format!("{path}/{src_p}") };
let value = format!("{func}(\"{full}\")");
if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
diff --git a/src/geometry.rs b/src/geometry.rs
index 20476c7..2533630 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -539,6 +539,38 @@ pub fn node_param_vec3(node: &FsNode, name: &str, fallback: Vec3) -> Vec3 {
.unwrap_or(fallback)
}
+/// A toggle's value. `true` / `false` in any case — and `1` / `0`, `on` /
+/// `off`, which a hand-edited file or an expression may leave — else
+/// `fallback`: absent, empty and unparseable all mean "the default", where
+/// the hand-rolled `== "true"` and `!= "false"` this replaced disagreed about
+/// garbage depending on which way round each site had been written.
+pub fn node_param_bool(node: &FsNode, name: &str, fallback: bool) -> bool {
+ let Some(p) = node.params.iter().find(|p| p.name.eq_ignore_ascii_case(name)) else {
+ return fallback;
+ };
+ match p.default.trim().to_ascii_lowercase().as_str() {
+ "true" | "1" | "on" => true,
+ "false" | "0" | "off" => false,
+ _ => fallback,
+ }
+}
+
+/// The node NAME a reference parameter holds (an `Input` wire, a Boolean's
+/// `With`, a Relax's `Rest`), trimmed — `None` when it is unconnected,
+/// absent or empty alike.
+pub fn node_param_node(node: &FsNode, name: &str) -> Option<String> {
+ let v = node_param_str(node, name, "");
+ let v = v.trim();
+ (!v.is_empty()).then(|| v.to_string())
+}
+
+/// The node `target`'s reference parameter `name` points at, resolved the
+/// way every wire is ([`find_input_node`]: a sibling first, then anywhere).
+/// `None` when the parameter is unconnected or names nothing.
+pub fn param_node<'a>(root: &'a FsNode, target: &FsNode, name: &str) -> Option<&'a FsNode> {
+ find_input_node(root, target, &node_param_node(target, name)?)
+}
+
pub fn find_node_by_name<'a>(root: &'a FsNode, name: &str) -> Option<&'a FsNode> {
fn visit<'a>(node: &'a FsNode, name: &str) -> Option<&'a FsNode> {
if node.name == name {
@@ -623,8 +655,7 @@ pub fn choice_options(p: &ParamDef) -> Vec<String> {
/// anyway, so every choice read as 0 from inside a kernel.
pub fn param_number(p: &ParamDef) -> f32 {
let raw = p.default.trim();
- let is_choice = p.param_type == "choice" || p.param_type.starts_with("choice:");
- if is_choice {
+ if p.kind() == crate::app::ParamKind::Choice {
choice_options(p).iter().position(|o| o.eq_ignore_ascii_case(raw)).map_or(0.0, |i| i as f32)
} else {
number_of_str(raw)
@@ -802,11 +833,12 @@ impl<'a> crate::expr::Scope for TreeScope<'a> {
/// toggle's `true` / `false`, a choice's option NAME (an index picks one), a
/// spinbox's integer, anything else the value's text.
pub fn format_for_param(v: &Value, p: &ParamDef) -> String {
- let ty = p.param_type.as_str();
- if ty == "toggle" {
+ use crate::app::ParamKind;
+ let kind = p.kind();
+ if kind == ParamKind::Toggle {
return v.truthy().to_string();
}
- if ty == "choice" || ty.starts_with("choice:") {
+ if kind == ParamKind::Choice {
return match v {
Value::Num(n) => {
let options = choice_options(p);
@@ -820,7 +852,7 @@ pub fn format_for_param(v: &Value, p: &ParamDef) -> String {
Value::Str(s) => s.clone(),
};
}
- if ty == "spinbox" || ty.starts_with("spinbox:") {
+ if kind == ParamKind::Spin {
if let Value::Num(n) = v {
return crate::expr::fmt_num(n.trunc());
}
@@ -832,8 +864,7 @@ pub fn format_for_param(v: &Value, p: &ParamDef) -> String {
/// three expressions separated by `:` — each component its own, as
/// Houdini's channels are — so `chf("../a/Size.x"):0:0` reads naturally.
fn eval_param_value(scope: &mut TreeScope, p: &ParamDef) -> Result<String, String> {
- let is_float3 = p.param_type == "float3" || p.param_type.starts_with("float3:");
- if is_float3 {
+ if p.kind() == crate::app::ParamKind::Float3 {
let parts: Vec<&str> = p.default.split(':').collect();
if parts.len() == 3 {
let mut out = Vec::with_capacity(3);
@@ -1210,23 +1241,10 @@ pub fn generate_single_node_geometry_with_errors(
None
}
} else if target.node_type.eq_ignore_ascii_case("output") {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- None
- } else {
- let parent_node = find_parent_node(root, &target.id);
- let input_node = if let Some(parent) = parent_node {
- parent.children.iter().find(|c| c.name == input_name || c.id == input_name)
- } else {
- None
- };
- let input_node = input_node.or_else(|| find_input_node(root, target, &input_name));
- if let Some(node) = input_node {
- generate_single_node_geometry_with_errors(root, node, visited, ocl_error, sim)
- } else {
- None
- }
- }
+ // Sibling-first, then anywhere — `find_input_node`'s own rule,
+ // which this arm spelled out by hand before that function existed.
+ param_node(root, target, "Input")
+ .and_then(|node| generate_single_node_geometry_with_errors(root, node, visited, ocl_error, sim))
} else if target.node_type.eq_ignore_ascii_case("input") {
if let Some(parent) = find_parent_node(root, &target.id) {
// Inside a simnet that is mid-solve, the input IS the previous
@@ -1241,16 +1259,9 @@ pub fn generate_single_node_geometry_with_errors(
// Input may itself be a reference.
let resolved_parent = resolve_param_refs(root, parent, sim.frame, ocl_error);
let parent = resolved_parent.as_ref().unwrap_or(parent);
- let input_name = node_param_str(parent, "Input", "");
- if !input_name.is_empty() {
- if let Some(input_node) = find_input_node(root, target, &input_name) {
- generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
- } else {
- None
- }
- } else {
- None
- }
+ node_param_node(parent, "Input")
+ .and_then(|name| find_input_node(root, target, &name))
+ .and_then(|input_node| generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim))
} else {
None
}
@@ -1276,11 +1287,7 @@ pub fn resolve_transform_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let translation = node_param_vec3(target, "Translation", Vec3::ZERO).to_array();
// Moving points changes no topology, so the cache rides along.
@@ -1339,19 +1346,15 @@ pub fn resolve_group_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let group_name = node_param_str(target, "Group Name", "group1").trim().to_string();
let etype = node_param_str(target, "Element Type", "Points").to_lowercase();
let center = node_param_vec3(target, "Center", Vec3::ZERO);
let half = node_param_vec3(target, "Size", Vec3::ONE) * 0.5;
- let invert = node_param_str(target, "Invert", "false") == "true";
- let highlight = node_param_str(target, "Highlight", "true") == "true";
+ let invert = node_param_bool(target, "Invert", false);
+ let highlight = node_param_bool(target, "Highlight", true);
let inside = |p: Vec3| -> bool {
(p.x - center.x).abs() <= half.x
@@ -1628,19 +1631,13 @@ pub fn resolve_collision_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
- let collider_name = node_param_str(target, "Collider", "");
- let collider_name = collider_name.trim();
- if collider_name.is_empty() {
- return Some(geom);
- }
- let Some(collider_node) = find_node_by_name(root, collider_name) else { return Some(geom) };
+ // Sibling-first like every other wire: this was a whole-tree
+ // `find_node_by_name`, so inside a second copy of a subnet it found the
+ // first copy's collider.
+ let Some(collider_node) = param_node(root, target, "Collider") else { return Some(geom) };
let Some(collider) = generate_single_node_geometry_with_errors(root, collider_node, visited, ocl_error, sim) else {
return Some(geom);
};
@@ -1667,7 +1664,7 @@ pub fn resolve_collision_geometry_with_errors(
// per-element closure `select_elements` takes would have asked one
// point at a time. Edges test both endpoints, as before.
let etype = node_param_str(target, "Element Type", "Points").to_lowercase();
- let invert = node_param_str(target, "Invert", "false") == "true";
+ let invert = node_param_bool(target, "Invert", false);
let queries: Vec<Vec3> = if etype == "primitives" {
(0..geom.num_prims())
.map(|prim| {
@@ -1729,7 +1726,7 @@ pub fn resolve_collision_geometry_with_errors(
}
let group_name = node_param_str(target, "Group Name", "collisions").trim().to_string();
- let highlight = node_param_str(target, "Highlight", "true") == "true";
+ let highlight = node_param_bool(target, "Highlight", true);
apply_group(
&mut geom,
&group_name,
@@ -1767,11 +1764,7 @@ pub fn resolve_relax_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
// Repel mode: the Relax SOP — spheres of Radius pushed apart until they
@@ -1783,7 +1776,7 @@ pub fn resolve_relax_geometry_with_errors(
if iterations == 0 || radius <= 0.0 || geom.num_points() < 2 {
return Some(geom);
}
- let in_3d = node_param_str(target, "In 3D Space", "false") == "true";
+ let in_3d = node_param_bool(target, "In 3D Space", false);
let normals = if in_3d { None } else { Some(point_normals(&geom)) };
let mut pts: Vec<Vec3> = (0..geom.num_points()).map(|p| geom.pos(p)).collect();
crate::scatter::relax_points(&mut pts, normals.as_deref(), radius, iterations);
@@ -1793,12 +1786,9 @@ pub fn resolve_relax_geometry_with_errors(
return Some(geom);
}
- let rest_name = node_param_str(target, "Rest", "");
- let rest_name = rest_name.trim();
- if rest_name.is_empty() {
- return Some(geom);
- }
- let Some(rest_node) = find_node_by_name(root, rest_name) else { return Some(geom) };
+ // Sibling-first, as the collider is: a simnet's `Rest: input1` has to
+ // be ITS input1, not the first one in the tree.
+ let Some(rest_node) = param_node(root, target, "Rest") else { return Some(geom) };
let Some(rest) = generate_single_node_geometry_with_errors(root, rest_node, visited, ocl_error, sim) else {
return Some(geom);
};
@@ -1868,13 +1858,13 @@ pub fn resolve_normal_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let name = node_param_str(target, "Attribute", "N").trim().to_string();
if name.is_empty() {
return Some(geom);
}
- let flip = node_param_str(target, "Flip", "false") == "true";
+ let flip = node_param_bool(target, "Flip", false);
let sign = if flip { -1.0 } else { 1.0 };
let normals: Vec<[f32; 3]> = point_normals(&geom).iter().map(|n| (*n * sign).to_array()).collect();
geom.points_mut().create(&name, AttribValue::Float3([0.0; 3]));
@@ -1895,7 +1885,7 @@ pub fn resolve_bounds_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let prefix = node_param_str(target, "Prefix", "bounds").trim().to_string();
@@ -1947,11 +1937,10 @@ pub fn resolve_distance_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
- let to_name = node_param_str(target, "To", "");
- let to_name = to_name.trim().to_string();
+ let to_name = node_param_node(target, "To").unwrap_or_default();
let Some(other) = find_input_node(root, target, &to_name)
.and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
else {
@@ -1964,7 +1953,7 @@ pub fn resolve_distance_geometry_with_errors(
let name = node_param_str(target, "Attribute", "dist").trim().to_string();
let dir_name = node_param_str(target, "Direction", "");
let dir_name = dir_name.trim().to_string();
- let signed = node_param_str(target, "Signed", "false") == "true";
+ let signed = node_param_bool(target, "Signed", false);
// Zero means no clamp: a maximum is for keeping a falloff bounded, and a
// node whose default quietly flattened every measurement to zero would be
// a trap.
@@ -2028,7 +2017,7 @@ pub fn resolve_connectivity_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_connectivity(&mut geom, target);
Some(geom)
@@ -2097,7 +2086,7 @@ pub fn resolve_cull_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let group = node_param_str(target, "Group", "");
@@ -2119,7 +2108,7 @@ pub fn resolve_cull_geometry_with_errors(
let below = node_param_str(target, "Comparison", "Below").eq_ignore_ascii_case("below");
let threshold = node_param_f32(target, "Threshold", 0.5);
- let invert = node_param_str(target, "Invert", "false") == "true";
+ let invert = node_param_bool(target, "Invert", false);
let selected: Vec<bool> = (0..geom.num_points())
.map(|p| {
@@ -2157,7 +2146,7 @@ pub fn resolve_volume_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
if geom.num_prims() == 0 {
return Some(geom);
@@ -2214,11 +2203,10 @@ pub fn resolve_boolean_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let a = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
- let with_name = node_param_str(target, "With", "");
- let with_name = with_name.trim().to_string();
+ let with_name = node_param_node(target, "With").unwrap_or_default();
let Some(b) = find_input_node(root, target, &with_name)
.and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
else {
@@ -2257,7 +2245,7 @@ pub fn resolve_mold_shell_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let input = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let shell = crate::mold::mold_shell(
&input,
@@ -2283,7 +2271,7 @@ pub fn resolve_hull_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let input = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let pts: Vec<Vec3> = (0..input.num_points()).map(|i| input.pos(i)).collect();
Some(crate::hull::convex_hull(&pts).unwrap_or(input))
@@ -2305,8 +2293,7 @@ pub fn resolve_wrangle_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- let input = match find_input_node(root, target, &input_name) {
+ let input = match param_node(root, target, "Input") {
Some(n) => generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim).unwrap_or_default(),
None => Detail::new(),
};
@@ -2346,10 +2333,10 @@ pub fn resolve_extrude_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let input = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let distance = node_param_f32(target, "Distance", 0.2);
- let keep_base = node_param_str(target, "Keep Base", "true") != "false";
+ let keep_base = node_param_bool(target, "Keep Base", true);
Some(crate::shapes::extrude_detail(&input, distance, keep_base))
}
@@ -2368,7 +2355,7 @@ pub fn retired_opencl_node(
if ocl_error.is_none() {
*ocl_error = Some(format!("{}: OpenCL nodes are retired; rewrite the kernel as a wrangle", target.name));
}
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
}
@@ -2396,8 +2383,7 @@ pub fn resolve_switch_geometry_with_errors(
sim: &mut EvalSim,
) -> Option<Detail> {
let index = (node_param_f32(target, "Index", 0.0).round().max(0.0) as usize).min(SWITCH_INPUTS - 1);
- let name = node_param_str(target, &switch_input_param(index), "");
- let input_node = find_input_node(root, target, &name)?;
+ let input_node = param_node(root, target, &switch_input_param(index))?;
generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
}
@@ -2415,7 +2401,7 @@ pub fn resolve_export_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
}
@@ -2482,7 +2468,7 @@ pub fn polygon_detail(target: &FsNode) -> Detail {
let sides = node_param_f32(target, "Sides", 4.0).clamp(3.0, 256.0) as usize;
let radius = node_param_f32(target, "Radius", 0.5).max(1e-4);
let inner = node_param_f32(target, "Inner Radius", 0.0).max(0.0);
- let fill = node_param_str(target, "Fill", "true") != "false";
+ let fill = node_param_bool(target, "Fill", true);
let centre = node_param_vec3(target, "Center", Vec3::ZERO);
let mut d = Detail::new();
@@ -2535,11 +2521,10 @@ pub fn resolve_transfer_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
- let from_name = node_param_str(target, "From", "");
- let from_name = from_name.trim().to_string();
+ let from_name = node_param_node(target, "From").unwrap_or_default();
let Some(source) = find_input_node(root, target, &from_name)
.and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
else {
@@ -2615,7 +2600,7 @@ pub fn resolve_valence_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let name = node_param_str(target, "Attribute", "valence").trim().to_string();
if name.is_empty() {
@@ -2652,7 +2637,7 @@ pub fn resolve_deform_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_deform(&mut geom, target);
Some(geom)
@@ -2737,11 +2722,10 @@ pub fn resolve_copy_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let source_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let source_node = param_node(root, target, "Input")?;
let source = generate_single_node_geometry_with_errors(root, source_node, visited, ocl_error, sim)?;
- let to_name = node_param_str(target, "To", "");
- let to_name = to_name.trim().to_string();
+ let to_name = node_param_node(target, "To").unwrap_or_default();
let Some(onto) = find_input_node(root, target, &to_name)
.and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
else {
@@ -2825,7 +2809,7 @@ pub fn resolve_soft_transform_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_soft_transform(&mut geom, target);
Some(geom)
@@ -2901,11 +2885,7 @@ pub fn resolve_subdivide_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let depth = node_param_f32(target, "Depth", 1.0).clamp(0.0, 6.0) as usize;
Some(crate::remesh::subdivide(&geom, depth))
@@ -2932,11 +2912,7 @@ pub fn resolve_detangle_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_detangle(&mut geom, target);
Some(geom)
@@ -3061,21 +3037,11 @@ pub fn resolve_suture_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
- let against_name = node_param_str(target, "Against", "");
- let against_name = against_name.trim().to_string();
- let against = if against_name.is_empty() {
- None
- } else {
- find_input_node(root, target, &against_name)
- .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
- };
+ let against = param_node(root, target, "Against")
+ .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim));
apply_suture(&mut geom, against.as_ref(), target);
Some(geom)
}
@@ -3178,11 +3144,7 @@ pub fn resolve_remesh_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
Some(crate::remesh::remesh(&geom, remesh_settings(target)))
}
@@ -3192,10 +3154,10 @@ pub(crate) fn remesh_settings(target: &FsNode) -> crate::remesh::Settings {
target: node_param_f32(target, "Target Length", 0.1).max(1e-4),
iterations: node_param_f32(target, "Iterations", 3.0).clamp(1.0, 20.0) as usize,
relax: node_param_f32(target, "Relax", 0.5),
- split: node_param_str(target, "Split", "true") == "true",
- collapse: node_param_str(target, "Collapse", "true") == "true",
- flip: node_param_str(target, "Flip", "true") == "true",
- project: node_param_str(target, "Project", "true") == "true",
+ split: node_param_bool(target, "Split", true),
+ collapse: node_param_bool(target, "Collapse", true),
+ flip: node_param_bool(target, "Flip", true),
+ project: node_param_bool(target, "Project", true),
}
}
@@ -3222,11 +3184,7 @@ pub fn resolve_develop_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_develop(&mut geom, target, ocl_error);
Some(geom)
@@ -3378,11 +3336,7 @@ pub fn moves_points(node: &FsNode) -> bool {
/// node was rewired onto something that adds or removes points) give nothing,
/// since the indices no longer pair up.
pub fn point_displacements(root: &FsNode, target: &FsNode, sim: &mut EvalSim) -> Vec<(Vec3, Vec3)> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.trim().is_empty() {
- return Vec::new();
- }
- let Some(input_node) = find_input_node(root, target, &input_name) else {
+ let Some(input_node) = param_node(root, target, "Input") else {
return Vec::new();
};
let mut ocl_error = None;
@@ -3499,11 +3453,7 @@ pub fn resolve_visualize_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_visualize(&mut geom, target, ocl_error);
Some(geom)
@@ -3623,11 +3573,7 @@ pub fn resolve_analysis_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_analysis(&mut geom, target, ocl_error);
Some(geom)
@@ -3715,11 +3661,7 @@ pub fn resolve_time_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let frame = sim.frame;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_time(&mut geom, target, frame);
@@ -3741,7 +3683,7 @@ pub(crate) fn apply_time(geom: &mut Detail, target: &FsNode, frame: i32) {
} else {
(frame as f32 - start) / span
};
- let t = if node_param_str(target, "Clamp", "true") == "true" {
+ let t = if node_param_bool(target, "Clamp", true) {
t.clamp(0.0, 1.0)
} else {
t
@@ -3828,11 +3770,7 @@ pub fn resolve_neighbour_geometry_with_errors(
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_neighbour(&mut geom, target, ocl_error);
Some(geom)
@@ -4298,11 +4236,7 @@ pub fn resolve_attribute_geometry_with_errors(
// pushes the target's id before dispatching to this resolver, so a local
// `visited.contains` check would see it and refuse every call (the trap
// that broke this node's first draft).
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
apply_attribute(&mut geom, target, ocl_error);
Some(geom)
@@ -4689,18 +4623,14 @@ pub fn resolve_scatter_geometry_with_errors(
// geometry evaluated to None for the spreadsheet and for any downstream
// consumer, while the scene walk's direct call (fresh `visited`) kept the
// node LOOKING healthy. Cycles stay guarded by the dispatch itself.
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- return None;
- }
- let input_node = find_input_node(root, target, &input_name)?;
+ let input_node = param_node(root, target, "Input")?;
let geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
let num_points = node_param_f32(target, "Points", 100.0) as usize;
let radius = node_param_f32(target, "Radius", 0.02);
// See points_detail: markers are for looking at, bare points are for
// working with.
- let markers = node_param_str(target, "Markers", "true") != "false";
+ let markers = node_param_bool(target, "Markers", true);
// Surface mode: points ON the surface, by area, optionally pushed apart
// across it — the Scatter SOP with Relax Points, which is what a seed
@@ -4709,11 +4639,11 @@ pub fn resolve_scatter_geometry_with_errors(
if node_param_str(target, "Mode", "Volume").eq_ignore_ascii_case("surface") {
let seed = node_param_f32(target, "Seed", 1.1);
let mut pts = crate::scatter::scatter_on_surface(&geom, num_points, seed);
- let relax = node_param_str(target, "Relax Points", "false") == "true";
+ let relax = node_param_bool(target, "Relax Points", false);
let iterations = node_param_f32(target, "Relax Iterations", 50.0).max(0.0) as usize;
if relax && iterations > 0 && !pts.is_empty() {
let scale = node_param_f32(target, "Scale Radii By", 1.248);
- let max = (node_param_str(target, "Use Max Relax Radius", "true") == "true")
+ let max = (node_param_bool(target, "Use Max Relax Radius", true))
.then(|| node_param_f32(target, "Max Relax Radius", 10.0));
let r = crate::scatter::relax_radius(&geom, pts.len(), scale, max);
let grid = crate::spatial::TriGrid::build(&geom);
@@ -5377,7 +5307,7 @@ pub fn points_detail(node: &FsNode, center: Vec3) -> Detail {
// looking at and wrong for working with: Copy placed one instance per
// marker vertex rather than one per location, because the markers were the
// only points there were.
- let markers = node_param_str(node, "Markers", "true") != "false";
+ let markers = node_param_bool(node, "Markers", true);
let mut d = Detail::new();
for i in 0..num_points {
let t = i as f32 / num_points.max(1) as f32;
@@ -6478,14 +6408,9 @@ pub fn resolve_simnet_geometry_with_errors(
.clone();
let seed = {
- let input_name = node_param_str(target, "Input", "");
- if input_name.is_empty() {
- Detail::new()
- } else {
- find_input_node(root, target, &input_name)
- .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
- .unwrap_or_default()
- }
+ param_node(root, target, "Input")
+ .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+ .unwrap_or_default()
};
let key = sim_solve_key(target, &seed);
@@ -6502,7 +6427,7 @@ pub fn resolve_simnet_geometry_with_errors(
let cached = sim.cache.entries.get(&target.id).and_then(|prev| {
(prev.key == key && prev.frame <= due).then(|| (prev.state.clone(), prev.frame))
});
- let caching = node_param_str(target, "Cache", "false") == "true";
+ let caching = node_param_bool(target, "Cache", false);
// What the last step consumed, carried with the solve so the interior
// view can be drawn without re-solving: resumed from the cache when the
// cache is what we resume from, the seed otherwise.
@@ -8720,4 +8645,39 @@ mod simnet_tests {
assert!((*b - *a - Vec3::X).length() < 1e-5);
}
}
+
+ /// A Relax's `Rest` wire resolves to its OWN sibling. It was a
+ /// whole-tree search by name, so in the second of two subnets that each
+ /// hold a `shape`, Rest found the first one's — here a sphere of half
+ /// the radius, whose shorter edges the relax then pulled the second
+ /// sphere toward. Rest equal to the input is a relax with nothing to do.
+ #[test]
+ fn a_rest_wire_resolves_to_its_own_sibling() {
+ let small = node("id-a-shape", "shape", "sphere", vec![param("Radius", "0.5")], vec![]);
+ let a = node("id-a", "a", "node", vec![], vec![small]);
+ let big = node("id-b-shape", "shape", "sphere", vec![param("Radius", "1.0")], vec![]);
+ let relax = node(
+ "id-b-relax",
+ "relax1",
+ "relax",
+ vec![
+ param("Input", "shape"),
+ param("Rest", "shape"),
+ param("Stiffness", "1.00"),
+ param("Iterations", "8"),
+ ],
+ vec![],
+ );
+ let b = node("id-b", "b", "node", vec![], vec![big, relax]);
+ let root = node("id-root", "root", "node", vec![], vec![a, b]);
+ let relax = &root.children[1].children[1];
+
+ assert_eq!(param_node(&root, relax, "Rest").map(|n| n.id.as_str()), Some("id-b-shape"));
+ let mut cache = SimCache::default();
+ let mut sim = EvalSim::new(1, 1, &mut cache);
+ let input = generate_single_node_geometry_with_errors(&root, &root.children[1].children[0], &mut Vec::new(), &mut None, &mut sim).unwrap();
+ let out = generate_single_node_geometry_with_errors(&root, relax, &mut Vec::new(), &mut None, &mut sim).unwrap();
+ let moved = (0..out.num_points()).map(|p| out.pos(p).distance(input.pos(p))).fold(0.0f32, f32::max);
+ assert!(moved < 1e-5, "relax against its own input moved a point {moved}");
+ }
}
diff --git a/src/main.rs b/src/main.rs
index fe87a1c..c9ab1e3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3942,6 +3942,176 @@ mod tests {
/// KERNEL SUBNET saved while Sphere was one becomes the native node with
/// its values intact and its children gone, and non-template lookalikes
/// are left alone.
+ /// Phase 0 of typed parameters: a `type` string names a [`ParamKind`],
+ /// read by its head, and anything else is refused rather than read as
+ /// text and left to look like it worked.
+ #[test]
+ fn param_kinds_parse_by_their_head() {
+ use crate::app::ParamKind as K;
+ for (ty, kind) in [
+ ("text", K::Text), ("string", K::Text), ("float", K::Float),
+ ("slider", K::Slider), ("slider:-2:2", K::Slider), ("spinbox", K::Spin),
+ ("float3", K::Float3), ("choice:UV,Icosphere,Cube", K::Choice),
+ ("toggle", K::Toggle), ("button", K::Button), ("code", K::Code), ("node", K::Node),
+ ] {
+ assert_eq!(K::parse(ty), Some(kind), "{ty}");
+ }
+ for bad in ["int", "slidr", "textpick:a,b", "", "Text"] {
+ assert_eq!(K::parse(bad), None, "{bad:?} names no kind");
+ }
+ for name in K::NAMES {
+ assert!(K::parse(name).is_some(), "NAMES lists {name}, which does not parse");
+ }
+ // Found anywhere in a template's tree, children included.
+ let mut t = crate::app::load_fs_tree().children.into_iter().find(|t| t.name == "Embryo").unwrap();
+ assert!(crate::app::unknown_param_kinds(&t).is_empty());
+ t.children[0].params.push(crate::app::ParamDef {
+ name: "Count".into(), label: String::new(), param_type: "int".into(), default: "1".into(),
+ options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
+ });
+ let bad = crate::app::unknown_param_kinds(&t);
+ assert_eq!(bad.len(), 1);
+ assert_eq!((bad[0].1.as_str(), bad[0].2.as_str()), ("Count", "int"));
+ }
+
+ /// Every shipped template's every parameter names a kind — walked from
+ /// the RAW files, because the loader drops a template that does not, and
+ /// a dropped template shows up only as a missing palette entry.
+ #[test]
+ fn every_shipped_template_param_has_a_known_kind() {
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("nodes");
+ let mut files = 0;
+ for entry in fs::read_dir(&dir).unwrap().flatten() {
+ let path = entry.path();
+ if path.extension().and_then(|e| e.to_str()) != Some("json") {
+ continue;
+ }
+ files += 1;
+ let node: FsNode = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
+ let bad = crate::app::unknown_param_kinds(&node);
+ assert!(bad.is_empty(), "{}: {bad:?}", path.display());
+ }
+ assert_eq!(crate::app::load_fs_tree().children.len(), files, "the loader dropped a template");
+ }
+
+ /// 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.
+ #[test]
+ fn template_params_carry_the_kind_they_hold() {
+ use crate::app::ParamKind as K;
+ let root = crate::app::load_fs_tree();
+ let kind = |ty: &str, name: &str| {
+ root.children.iter().find(|t| t.node_type == ty)
+ .and_then(|t| t.params.iter().find(|p| p.name == name))
+ .unwrap_or_else(|| panic!("{ty} has no {name}"))
+ .kind()
+ };
+ for (ty, name) in [
+ ("switch", "Input 2"), ("switch", "Input 3"), ("switch", "Input 4"),
+ ("boolean", "With"), ("collision", "Collider"), ("relax", "Rest"),
+ ("suture", "Against"), ("copy", "To"), ("distance", "To"), ("transfer", "From"),
+ ] {
+ assert_eq!(kind(ty, name), K::Node, "{ty}'s {name}");
+ }
+ 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")] {
+ assert_eq!(kind(ty, name), K::Float, "{ty}'s {name}");
+ }
+ // 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") {
+ out.push((n.name.clone(), p.kind()));
+ }
+ n.children.iter().for_each(|c| inputs(c, out));
+ }
+ let mut all = Vec::new();
+ root.children.iter().for_each(|t| inputs(t, &mut all));
+ assert!(all.len() > 40);
+ let not_node: Vec<_> = all.iter().filter(|(_, k)| *k != K::Node).collect();
+ assert!(not_node.is_empty(), "{not_node:?}");
+ }
+
+ /// A save made before phase 1 carries `"type": "text"` on its wires; the
+ /// template merge hands it the template's kind, as it does all UI
+ /// metadata, and leaves the value alone.
+ #[test]
+ fn a_saved_text_wire_loads_as_a_node_wire() {
+ let templates_root = crate::app::load_fs_tree();
+ let templates = crate::app::flatten_node_templates(&templates_root);
+ let mut relax = templates_root.children.iter().find(|t| t.node_type == "relax").unwrap().clone();
+ for p in relax.params.iter_mut().filter(|p| p.name == "Input" || p.name == "Rest") {
+ p.param_type = "text".into();
+ p.default = "sphere1".into();
+ }
+ let mut root = FsNode { children: vec![relax], ..templates_root.clone() };
+ crate::app::merge_template_defs(&mut root, &templates);
+ for name in ["Input", "Rest"] {
+ let p = root.children[0].params.iter().find(|p| p.name == name).unwrap();
+ assert_eq!(p.kind(), crate::app::ParamKind::Node, "{name}");
+ assert_eq!(p.default, "sphere1", "the value is the instance's");
+ }
+ }
+
+ /// 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.
+ #[test]
+ fn node_and_float_rows_show_as_text() {
+ use crate::app::ParamDef;
+ let row = |ty: &str| ParamDef {
+ name: "X".into(), label: String::new(), param_type: ty.into(), default: "1".into(),
+ options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
+ };
+ let shown = crate::app::param_display(&[row("node"), row("float"), row("string"), row("toggle")]);
+ let types: Vec<&str> = shown.iter().map(|r| r.2.as_str()).collect();
+ assert_eq!(types, vec!["text", "text", "text", "toggle"]);
+ }
+
+ /// Phase 2's toggle reader: the words a toggle can hold, in any case,
+ /// and the FALLBACK for anything else — where `== "true"` and
+ /// `!= "false"` used to disagree about garbage.
+ #[test]
+ fn node_param_bool_reads_a_toggle_and_falls_back_on_anything_else() {
+ use crate::geometry::node_param_bool;
+ let node = |v: &str| FsNode {
+ params: vec![crate::app::ParamDef {
+ name: "On".into(), label: String::new(), param_type: "toggle".into(), default: v.into(),
+ options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
+ }],
+ ..crate::app::load_fs_tree()
+ };
+ for v in ["true", "TRUE", " True ", "1", "on"] {
+ assert!(node_param_bool(&node(v), "On", false), "{v:?}");
+ }
+ for v in ["false", "False", "0", "off"] {
+ assert!(!node_param_bool(&node(v), "On", true), "{v:?}");
+ }
+ for v in ["", "yes please", "2"] {
+ assert!(node_param_bool(&node(v), "On", true) && !node_param_bool(&node(v), "On", false), "{v:?}");
+ }
+ assert!(node_param_bool(&node("true"), "Missing", true) && !node_param_bool(&node("true"), "Missing", false));
+ }
+
+ /// MCP's add_param refuses a type that names no kind, and says which do.
+ #[test]
+ fn add_param_refuses_an_unknown_type() {
+ let mut state = State::new(false);
+ let mut redraw = false;
+ let before = state.current_dir().children[0].params.len();
+ let err = state
+ .apply_action(crate::app::McpAction::AddParam { slot: 0, name: "N".into(), param_type: "int".into(), default: "1".into() }, &mut redraw)
+ .unwrap_err();
+ assert!(err.contains("int") && err.contains("spinbox"), "{err}");
+ assert_eq!(state.current_dir().children[0].params.len(), before);
+ state
+ .apply_action(crate::app::McpAction::AddParam { slot: 0, name: "N".into(), param_type: "spinbox".into(), default: "1".into() }, &mut redraw)
+ .expect("a known kind is added");
+ }
+
#[test]
fn test_loader_merges_new_template_params() {
let templates_root = crate::app::load_fs_tree();
diff --git a/src/page.rs b/src/page.rs
index b853558..17f1737 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -380,7 +380,7 @@ impl Page {
// ---------------------------------------------------------------------------
use crate::app::FsNode;
-use crate::geometry::{find_node_by_name, node_param_f32, node_param_str, node_param_vec3};
+use crate::geometry::{node_param_f32, node_param_str, node_param_vec3};
use glam::Vec3;
/// Whether a node belongs to the page context rather than the geometry graph.
@@ -418,7 +418,7 @@ fn color_of(node: &FsNode, name: &str, fallback: Vec3) -> [f32; 4] {
}
fn toggle_of(node: &FsNode, name: &str) -> bool {
- matches!(node_param_str(node, name, "false").trim().to_ascii_lowercase().as_str(), "true" | "1" | "on")
+ crate::geometry::node_param_bool(node, name, false)
}
/// Compose the page `target` describes, resolving its input chain.
@@ -455,7 +455,9 @@ pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -
// the honest answer: a border with no page is not a page with a border —
// and it is also how an Export node in a GEOMETRY chain falls through to
// the geometry resolvers rather than being claimed by this one.
- let input = find_node_by_name(root, node_param_str(target, "Input", "").trim())?;
+ // Sibling-first like every geometry wire; a whole-tree search by name
+ // found the first same-named page node anywhere.
+ let input = crate::geometry::param_node(root, target, "Input")?;
let mut page = resolve_page(root, input, visited)?;
match kind.as_str() {
diff --git a/src/project.rs b/src/project.rs
index 0bd58d1..4c0cce6 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -767,7 +767,7 @@ impl State {
for (name, get, action) in Self::PANE_FLAGS {
let Some(p) = view
.iter()
- .find(|p| p.param_type == "toggle" && p.name == format!("Show {} Pane", capitalize(name)))
+ .find(|p| p.kind() == crate::app::ParamKind::Toggle && p.name == format!("Show {} Pane", capitalize(name)))
else {
continue;
};
diff --git a/src/shapes.rs b/src/shapes.rs
index e5968db..2fe52c2 100644
--- a/src/shapes.rs
+++ b/src/shapes.rs
@@ -17,7 +17,7 @@
use crate::app::FsNode;
use crate::detail::{AttribData, Detail, CD, DEFAULT_COLOR};
-use crate::geometry::{node_param_f32, node_param_str, node_param_vec3, point_normals, sphere_detail};
+use crate::geometry::{node_param_bool, node_param_f32, node_param_str, node_param_vec3, point_normals, sphere_detail};
use glam::Vec3;
use std::collections::HashMap;
@@ -92,7 +92,7 @@ pub fn sphere_node_detail(target: &FsNode, legacy_center: Option<Vec3>) -> Detai
node_param_f32(target, "Center Z", 0.0),
)
});
- let colored = node_param_str(target, "Color", "true") != "false";
+ let colored = node_param_bool(target, "Color", true);
let unit = match method.as_str() {
"icosphere" => icosphere_unit(node_param_f32(target, "Frequency", 4.0).round().clamp(1.0, 16.0) as usize),
"cube" => cube_sphere_unit(node_param_f32(target, "Resolution", 8.0).round().clamp(1.0, 64.0) as usize),
@@ -262,7 +262,7 @@ pub fn cuboid_detail(center: Vec3, half: Vec3, color: [f32; 3]) -> Detail {
/// what the kernel drew and what a reference frame wants.
pub fn box_node_detail(target: &FsNode) -> Detail {
let scale = node_param_f32(target, "Scale", 1.0).max(1e-4);
- let wireframe = node_param_str(target, "Wireframe", "false") != "false";
+ let wireframe = node_param_bool(target, "Wireframe", false);
let center = node_param_vec3(target, "Center", Vec3::new(0.0, 0.55, 0.0));
let color = [0.8, 0.2, 0.2];
let h = 0.5 * scale;
@@ -304,7 +304,7 @@ pub fn plane_node_detail(target: &FsNode) -> Detail {
node_param_f32(target, "Center Y", 0.0),
node_param_f32(target, "Center Z", 0.0),
);
- let colored = node_param_str(target, "Color", "true") != "false";
+ let colored = node_param_bool(target, "Color", true);
let mut d = Detail::new();
let mut cds = Vec::with_capacity((rows + 1) * (cols + 1));
diff --git a/src/window.rs b/src/window.rs
index 84a957f..4911d4c 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -879,7 +879,12 @@ impl State {
}
McpAction::AddParam { slot, name, param_type, default } => {
let len = state.current_dir().children.len();
- if slot < len {
+ if crate::app::ParamKind::parse(¶m_type).is_none() {
+ Err(format!(
+ "Unknown param_type '{param_type}'; expected one of: {}",
+ crate::app::ParamKind::NAMES.join(", ")
+ ))
+ } else if slot < len {
let param = ParamDef {
name,
label: String::new(),