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

commit0e3f99f4e094730c2681b377787f738780e7acaf
parent21325bcb64
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 20:02
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(&param_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(),