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

commita063edbd8c25f19f9e6c5b9ef127d6d9dfb63d9d
parent0b59e18f48
authorLucas Galante <[email protected]>
date2026-09-18 21:01
feat(params): rows that appear only when they apply

The bill for the 50 → 10 collapse, come due. Trading node count for parameter
count was the right trade — one Neighbour node beats seven operators — but it
left `attribute` with seventeen parameters of which seven apply at once, and a
pane showing ten irrelevant rows is worse than the ten nodes it replaced.

A ParamDef may now carry `show_when`, a condition over its SIBLINGS' current
values: `Mode == Twist`, `Mode == Twist|Bend` for any-of, `Mode != Bleed` for
unless, ` && ` between clauses, compared without case. Empty means always,
which is what most parameters have.

Phrased the positive way round, unlike Houdini's hideWhen, because a template
author is describing when a control APPLIES and stating that directly is easier
to get right than stating its negation.

Two decisions:

A condition that does not parse, or names a parameter the node does not have,
HIDES its row. The alternative — showing it unconditionally — makes a template
bug invisible, and a row that has gone missing is a complaint you can act on.
test_the_shipped_templates_only_name_parameters_they_have walks every template
and every clause, because the failure this guards against is exactly the kind
nobody notices until they go looking for a control a month later.

Hiding a row never touches its value. Write-back already resolved rows by
display key rather than position, so a hidden parameter is simply not reported;
set a Remap range, switch to Clip, switch back, and the numbers are still
there.

At their defaults: attribute 17 rows to 7, group 15 to 8, neighbour 13 to 7,
visualize 11 to 8. merge_template_defs carries the condition like the rest of
the UI metadata, so a saved project picks up a node's new conditions rather
than keeping the pane it had the day it was made.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md            |  25 +++++++
 nodes/analysis.json  |  38 +++++++---
 nodes/attribute.json | 196 +++++++++++++++++++++++++++------------------------
 nodes/group.json     |  30 +++++---
 nodes/neighbour.json | 174 +++++++++++++++++++++++----------------------
 nodes/visualize.json |  91 +++++++++++++++++++-----
 shapeshifter.md      |   8 +++
 src/app.rs           |  60 +++++++++++++++-
 src/geometry.rs      |  19 +++++
 src/main.rs          | 146 ++++++++++++++++++++++++++++++++++++++
 src/project.rs       |   4 ++
 src/window.rs        |   1 +
 12 files changed, 580 insertions(+), 212 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0b766ec..a8a344d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -254,6 +254,31 @@ files migrate on load. Scroll behavior (`scroll_speed`, `inertial_scroll`,
 `scroll_friction`) is intentionally absent: it is config-owned
 (`input.inertial` in config.kdl) and must not be shadowed by app state.
 
+### Conditional parameter rows
+
+A `ParamDef` may carry `show_when`, a condition over its SIBLINGS' current
+values deciding whether the params pane shows it: `Mode == Twist`,
+`Mode == Twist|Bend` for any-of, `Mode != Bleed` for unless, ` && ` between
+clauses, compared case-insensitively. Empty means always, which is what most
+parameters have. `param_visible` evaluates it and `param_display` filters on
+it.
+
+It exists because collapsing the Houdini operator set into fewer nodes traded
+node count for parameter count — `attribute` reached seventeen parameters, of
+which seven apply at once. Phrased the positive way round (unlike Houdini's
+`hideWhen`) because a template author is describing when a control APPLIES.
+
+Two rules worth knowing. A condition that does not parse, or names a parameter
+the node does not have, HIDES its row: a template bug should be visible, not
+silent — and `test_the_shipped_templates_only_name_parameters_they_have` walks
+every shipped template to catch exactly that. And hiding a row never touches
+its value: write-back resolves rows by display key rather than position, so a
+hidden parameter is simply not reported and comes back as it was.
+
+`merge_template_defs` carries `show_when` from the template like the rest of
+the UI metadata — the template owns when a control applies, the instance owns
+its value.
+
 ### Runtime paths point into the source tree
 
 Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/nodes/analysis.json b/nodes/analysis.json
index 0331a01..b268481 100644
--- a/nodes/analysis.json
+++ b/nodes/analysis.json
@@ -1,12 +1,30 @@
 {
- "name": "Analysis",
- "type": "analysis",
- "inputs": 1,
- "outputs": 1,
- "params": [
-  { "name": "Input", "type": "text", "default": "" },
-  { "name": "Source", "type": "choice:Attribute,Edge Lengths", "default": "Attribute" },
-  { "name": "Attribute", "type": "text", "default": "mass" },
-  { "name": "Group", "type": "text", "default": "" }
- ]
+  "name": "Analysis",
+  "type": "analysis",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Input",
+      "type": "text",
+      "default": ""
+    },
+    {
+      "name": "Source",
+      "type": "choice:Attribute,Edge Lengths",
+      "default": "Attribute"
+    },
+    {
+      "name": "Attribute",
+      "type": "text",
+      "default": "mass",
+      "show_when": "Source == Attribute"
+    },
+    {
+      "name": "Group",
+      "type": "text",
+      "default": "",
+      "show_when": "Source == Attribute"
+    }
+  ]
 }
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 6799c66..ab52b08 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -1,93 +1,107 @@
 {
- "name": "Attribute",
- "type": "attribute",
- "inputs": 1,
- "outputs": 1,
- "params": [
-  {
-   "name": "Input",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "Operation",
-   "type": "choice:Create,Modify,Delete,Remap,Clip,Normalize,Composite,Promote",
-   "default": "Create"
-  },
-  {
-   "name": "Attribute Name",
-   "type": "text",
-   "default": "attr1"
-  },
-  {
-   "name": "Type",
-   "type": "choice:Float,Float2,Float3,Float4",
-   "default": "Float"
-  },
-  {
-   "name": "Value",
-   "type": "text",
-   "default": "1.00"
-  },
-  {
-   "name": "Combine",
-   "type": "choice:Set,Add,Multiply",
-   "default": "Set"
-  },
-  {
-   "name": "Group",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "From Min",
-   "type": "text",
-   "default": "0.00"
-  },
-  {
-   "name": "From Max",
-   "type": "text",
-   "default": "1.00"
-  },
-  {
-   "name": "To Min",
-   "type": "text",
-   "default": "0.00"
-  },
-  {
-   "name": "To Max",
-   "type": "text",
-   "default": "1.00"
-  },
-  {
-   "name": "Source B",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "Combine Op",
-   "type": "choice:Add,Subtract,Multiply,Divide,Minimum,Maximum,Average,Difference,Dot,Distance,Length",
-   "default": "Add"
-  },
-  {
-   "name": "To Class",
-   "type": "choice:Detail,Point",
-   "default": "Detail"
-  },
-  {
-   "name": "Method",
-   "type": "choice:Average,Sum,Minimum,Maximum,First",
-   "default": "Average"
-  },
-  {
-   "name": "Target",
-   "type": "choice:Sum,Maximum,Range",
-   "default": "Maximum"
-  },
-  {
-   "name": "Kind",
-   "type": "choice:Live,Derivative",
-   "default": "Live"
-  }
- ]
+  "name": "Attribute",
+  "type": "attribute",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Input",
+      "type": "text",
+      "default": ""
+    },
+    {
+      "name": "Operation",
+      "type": "choice:Create,Modify,Delete,Remap,Clip,Normalize,Composite,Promote",
+      "default": "Create"
+    },
+    {
+      "name": "Attribute Name",
+      "type": "text",
+      "default": "attr1"
+    },
+    {
+      "name": "Type",
+      "type": "choice:Float,Float2,Float3,Float4",
+      "default": "Float",
+      "show_when": "Operation == Create"
+    },
+    {
+      "name": "Value",
+      "type": "text",
+      "default": "1.00",
+      "show_when": "Operation == Create|Modify"
+    },
+    {
+      "name": "Combine",
+      "type": "choice:Set,Add,Multiply",
+      "default": "Set",
+      "show_when": "Operation == Modify"
+    },
+    {
+      "name": "Group",
+      "type": "text",
+      "default": "",
+      "show_when": "Operation != Promote"
+    },
+    {
+      "name": "From Min",
+      "type": "text",
+      "default": "0.00",
+      "show_when": "Operation == Remap|Clip"
+    },
+    {
+      "name": "From Max",
+      "type": "text",
+      "default": "1.00",
+      "show_when": "Operation == Remap|Clip"
+    },
+    {
+      "name": "To Min",
+      "type": "text",
+      "default": "0.00",
+      "show_when": "Operation == Remap"
+    },
+    {
+      "name": "To Max",
+      "type": "text",
+      "default": "1.00",
+      "show_when": "Operation == Remap|Normalize"
+    },
+    {
+      "name": "Source B",
+      "type": "text",
+      "default": "",
+      "show_when": "Operation == Composite"
+    },
+    {
+      "name": "Combine Op",
+      "type": "choice:Add,Subtract,Multiply,Divide,Minimum,Maximum,Average,Difference,Dot,Distance,Length",
+      "default": "Add",
+      "show_when": "Operation == Composite"
+    },
+    {
+      "name": "To Class",
+      "type": "choice:Detail,Point",
+      "default": "Detail",
+      "show_when": "Operation == Promote"
+    },
+    {
+      "name": "Method",
+      "type": "choice:Average,Sum,Minimum,Maximum,First",
+      "default": "Average",
+      "show_when": "Operation == Promote"
+    },
+    {
+      "name": "Target",
+      "type": "choice:Sum,Maximum,Range",
+      "default": "Maximum",
+      "show_when": "Operation == Normalize"
+    },
+    {
+      "name": "Kind",
+      "type": "choice:Live,Derivative",
+      "default": "Live",
+      "show_when": "Operation == Create"
+    }
+  ]
 }
diff --git a/nodes/group.json b/nodes/group.json
index 99ae535..b2928b8 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -17,7 +17,8 @@
     {
       "name": "Element Type",
       "type": "choice:Points,Primitives,Edges",
-      "default": "Points"
+      "default": "Points",
+      "show_when": "Mode != Expand|Attribute"
     },
     {
       "name": "Mode",
@@ -30,7 +31,8 @@
       "default": "1",
       "min": 1.0,
       "max": 500.0,
-      "step": 1.0
+      "step": 1.0,
+      "show_when": "Mode == Random"
     },
     {
       "name": "Seed",
@@ -38,21 +40,24 @@
       "default": "0",
       "min": 0.0,
       "max": 999.0,
-      "step": 1.0
+      "step": 1.0,
+      "show_when": "Mode == Random"
     },
     {
       "name": "Center",
       "type": "float3",
       "default": "0.00:0.00:0.00",
       "min": -10.0,
-      "max": 10.0
+      "max": 10.0,
+      "show_when": "Mode == Box"
     },
     {
       "name": "Size",
       "type": "float3",
       "default": "1.00:1.00:1.00",
       "min": 0.0,
-      "max": 20.0
+      "max": 20.0,
+      "show_when": "Mode == Box"
     },
     {
       "name": "Invert",
@@ -67,22 +72,26 @@
     {
       "name": "Attribute",
       "type": "text",
-      "default": ""
+      "default": "",
+      "show_when": "Mode == Attribute"
     },
     {
       "name": "Comparison",
       "type": "choice:Below,Above",
-      "default": "Above"
+      "default": "Above",
+      "show_when": "Mode == Attribute"
     },
     {
       "name": "Threshold",
       "type": "text",
-      "default": "0.50"
+      "default": "0.50",
+      "show_when": "Mode == Attribute"
     },
     {
       "name": "Source Group",
       "type": "text",
-      "default": ""
+      "default": "",
+      "show_when": "Mode == Expand"
     },
     {
       "name": "Rings",
@@ -90,7 +99,8 @@
       "default": "1",
       "min": -8.0,
       "max": 8.0,
-      "step": 1.0
+      "step": 1.0,
+      "show_when": "Mode == Expand"
     }
   ]
 }
diff --git a/nodes/neighbour.json b/nodes/neighbour.json
index ba7707e..b6b4b7c 100644
--- a/nodes/neighbour.json
+++ b/nodes/neighbour.json
@@ -1,85 +1,93 @@
 {
- "name": "Neighbour",
- "type": "neighbour",
- "inputs": 1,
- "outputs": 1,
- "params": [
-  {
-   "name": "Input",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "Attribute",
-   "type": "text",
-   "default": "mass"
-  },
-  {
-   "name": "Mode",
-   "type": "choice:Diffuse,Concentrate,Migrate,Bleed,Align,Lead,Charge",
-   "default": "Diffuse"
-  },
-  {
-   "name": "Neighbourhood",
-   "type": "choice:Connectivity,Radius,Global",
-   "default": "Connectivity"
-  },
-  {
-   "name": "Rings",
-   "type": "spinbox",
-   "default": "1",
-   "min": 1.0,
-   "max": 8.0,
-   "step": 1.0
-  },
-  {
-   "name": "Radius",
-   "type": "slider",
-   "default": "0.20",
-   "min": 0.0,
-   "max": 2.0,
-   "step": 0.01
-  },
-  {
-   "name": "Amount",
-   "type": "slider",
-   "default": "0.50",
-   "min": 0.0,
-   "max": 1.0,
-   "step": 0.01
-  },
-  {
-   "name": "Direction",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "Group",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "Target",
-   "type": "choice:Local Average,Global Average,Constant,Attribute,Surface Tangent",
-   "default": "Local Average"
-  },
-  {
-   "name": "Constant",
-   "type": "text",
-   "default": "0.00:1.00:0.00"
-  },
-  {
-   "name": "Source",
-   "type": "text",
-   "default": ""
-  },
-  {
-   "name": "Release",
-   "type": "slider",
-   "default": "1.00",
-   "min": 0.0,
-   "max": 10.0,
-   "step": 0.05
-  }
- ]
+  "name": "Neighbour",
+  "type": "neighbour",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Input",
+      "type": "text",
+      "default": ""
+    },
+    {
+      "name": "Attribute",
+      "type": "text",
+      "default": "mass"
+    },
+    {
+      "name": "Mode",
+      "type": "choice:Diffuse,Concentrate,Migrate,Bleed,Align,Lead,Charge",
+      "default": "Diffuse"
+    },
+    {
+      "name": "Neighbourhood",
+      "type": "choice:Connectivity,Radius,Global",
+      "default": "Connectivity",
+      "show_when": "Mode != Bleed"
+    },
+    {
+      "name": "Rings",
+      "type": "spinbox",
+      "default": "1",
+      "min": 1.0,
+      "max": 8.0,
+      "step": 1.0,
+      "show_when": "Neighbourhood == Connectivity && Mode != Bleed"
+    },
+    {
+      "name": "Radius",
+      "type": "slider",
+      "default": "0.20",
+      "min": 0.0,
+      "max": 2.0,
+      "step": 0.01,
+      "show_when": "Neighbourhood == Radius && Mode != Bleed"
+    },
+    {
+      "name": "Amount",
+      "type": "slider",
+      "default": "0.50",
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01
+    },
+    {
+      "name": "Direction",
+      "type": "text",
+      "default": "",
+      "show_when": "Mode == Migrate"
+    },
+    {
+      "name": "Group",
+      "type": "text",
+      "default": ""
+    },
+    {
+      "name": "Target",
+      "type": "choice:Local Average,Global Average,Constant,Attribute,Surface Tangent",
+      "default": "Local Average",
+      "show_when": "Mode == Align"
+    },
+    {
+      "name": "Constant",
+      "type": "text",
+      "default": "0.00:1.00:0.00",
+      "show_when": "Mode == Align && Target == Constant"
+    },
+    {
+      "name": "Source",
+      "type": "text",
+      "default": "",
+      "show_when": "Mode == Align|Lead"
+    },
+    {
+      "name": "Release",
+      "type": "slider",
+      "default": "1.00",
+      "min": 0.0,
+      "max": 10.0,
+      "step": 0.05,
+      "show_when": "Mode == Charge"
+    }
+  ]
 }
diff --git a/nodes/visualize.json b/nodes/visualize.json
index fd8d069..1ecbe89 100644
--- a/nodes/visualize.json
+++ b/nodes/visualize.json
@@ -1,19 +1,76 @@
 {
- "name": "Visualize",
- "type": "visualize",
- "inputs": 1,
- "outputs": 1,
- "params": [
-  { "name": "Input", "type": "text", "default": "" },
-  { "name": "Attribute", "type": "text", "default": "mass" },
-  { "name": "Mode", "type": "choice:Ramp,Vector", "default": "Ramp" },
-  { "name": "Ramp", "type": "choice:Grayscale,Heat,Spectrum,Viridis", "default": "Viridis" },
-  { "name": "Range", "type": "choice:Auto,Manual", "default": "Auto" },
-  { "name": "From", "type": "text", "default": "0.00" },
-  { "name": "To", "type": "text", "default": "1.00" },
-  { "name": "Blend", "type": "choice:Set,Mix,Multiply,Add", "default": "Set" },
-  { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01 },
-  { "name": "Scale", "type": "slider", "default": "0.20", "min": 0.0, "max": 2.0, "step": 0.01 },
-  { "name": "Group", "type": "text", "default": "" }
- ]
+  "name": "Visualize",
+  "type": "visualize",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Input",
+      "type": "text",
+      "default": ""
+    },
+    {
+      "name": "Attribute",
+      "type": "text",
+      "default": "mass"
+    },
+    {
+      "name": "Mode",
+      "type": "choice:Ramp,Vector",
+      "default": "Ramp"
+    },
+    {
+      "name": "Ramp",
+      "type": "choice:Grayscale,Heat,Spectrum,Viridis",
+      "default": "Viridis",
+      "show_when": "Mode == Ramp"
+    },
+    {
+      "name": "Range",
+      "type": "choice:Auto,Manual",
+      "default": "Auto",
+      "show_when": "Mode == Ramp"
+    },
+    {
+      "name": "From",
+      "type": "text",
+      "default": "0.00",
+      "show_when": "Mode == Ramp && Range == Manual"
+    },
+    {
+      "name": "To",
+      "type": "text",
+      "default": "1.00",
+      "show_when": "Mode == Ramp && Range == Manual"
+    },
+    {
+      "name": "Blend",
+      "type": "choice:Set,Mix,Multiply,Add",
+      "default": "Set",
+      "show_when": "Mode == Ramp"
+    },
+    {
+      "name": "Opacity",
+      "type": "slider",
+      "default": "1.00",
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01,
+      "show_when": "Mode == Ramp"
+    },
+    {
+      "name": "Scale",
+      "type": "slider",
+      "default": "0.20",
+      "min": 0.0,
+      "max": 2.0,
+      "step": 0.01,
+      "show_when": "Mode == Vector"
+    },
+    {
+      "name": "Group",
+      "type": "text",
+      "default": ""
+    }
+  ]
 }
diff --git a/shapeshifter.md b/shapeshifter.md
index 7658b4f..327d9dd 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -317,6 +317,14 @@ Touches: `geometry.rs`, `nodes/*.json`.
 
 *Medium. Needs nothing — start any time.*
 
+> **Started.** Parameters can declare `show_when`, a condition over their
+> siblings' values, and the pane shows only the rows that apply: `attribute`
+> drops from seventeen rows to seven, `group` from fifteen to eight,
+> `neighbour` from thirteen to seven. This was the cost of the 50 → 10
+> collapse coming due — a pane of twelve irrelevant rows is worse than the
+> twelve nodes it replaced — and it is the first Phase 5 item because it was
+> the binding constraint on using what Phases 1 to 4 built.
+
 Independent of all the geometry work, and the place where the app gets to be
 better rather than equal. A **command palette** on the HC Panel's model — fuzzy
 search over every action, contextual to the focused pane — which in Houdini
diff --git a/src/app.rs b/src/app.rs
index 379be00..6c301da 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -92,10 +92,58 @@ pub struct ParamDef {
     pub max: Option<f32>,
     #[serde(default)]
     pub step: Option<f32>,
+    /// When this parameter should be SHOWN, as a condition over its siblings'
+    /// current values. Empty means always.
+    ///
+    /// Grammar, deliberately tiny: `Mode == Twist`, `Mode == Twist|Bend` for
+    /// any-of, `Mode != Bleed` for unless, and ` && ` between clauses. It
+    /// exists because collapsing fifty operators into ten traded node count
+    /// for parameter count — Attribute reached sixteen parameters, of which
+    /// four matter at any moment — and a pane showing twelve irrelevant rows
+    /// is worse than the twelve nodes it replaced.
+    ///
+    /// Houdini calls this `hideWhen`. Phrased the positive way round here
+    /// because a template author is describing when a control APPLIES, and
+    /// stating that directly is easier to get right than stating its negation.
+    #[serde(default)]
+    pub show_when: String,
 }
 
 fn default_param_type() -> String { "string".to_string() }
 
+/// Whether a parameter's `show_when` condition holds, given its siblings.
+///
+/// Values are compared case-insensitively against the sibling's CURRENT value
+/// (`default` is where this app keeps live values). A condition naming a
+/// parameter that does not exist is treated as unmet: a template that
+/// misspells a name hides the row rather than showing it unconditionally, so
+/// the mistake is visible instead of silent.
+pub fn param_visible(params: &[ParamDef], cond: &str) -> bool {
+    let cond = cond.trim();
+    if cond.is_empty() {
+        return true;
+    }
+    cond.split("&&").all(|clause| {
+        let clause = clause.trim();
+        let (name, wanted, negated) = match clause.split_once("!=") {
+            Some((n, v)) => (n.trim(), v.trim(), true),
+            None => match clause.split_once("==") {
+                Some((n, v)) => (n.trim(), v.trim(), false),
+                // Not a comparison at all: an unparseable condition is a
+                // template bug, and hiding the row makes it noticeable.
+                None => return false,
+            },
+        };
+        let Some(sibling) = params.iter().find(|p| p.name.eq_ignore_ascii_case(name)) else {
+            return false;
+        };
+        let matches = wanted
+            .split('|')
+            .any(|w| w.trim().eq_ignore_ascii_case(sibling.default.trim()));
+        matches != negated
+    })
+}
+
 static NODE_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
 
 pub fn generate_node_id() -> String {
@@ -350,7 +398,10 @@ pub struct NodeTemplate {
 }
 
 pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
-    params.iter().map(|p| {
+    // Rows whose condition does not hold are not shown. Write-back resolves a
+    // row by its display key rather than by position, so a hidden parameter
+    // 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 value = if p.param_type == "choice" && !p.options.is_empty() && p.default.is_empty() {
             p.options[0].clone()
@@ -435,6 +486,7 @@ pub fn ensure_meta_on(node: &mut FsNode) {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 });
             }
         }
@@ -524,6 +576,12 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
                 ip.min = tp.min;
                 ip.max = tp.max;
                 ip.step = tp.step;
+                // The condition is UI metadata like the rest: the template
+                // owns when a control applies, the instance owns its value.
+                // Without this a saved project keeps the pane it had on the
+                // day it was made, and a node that later learned to hide its
+                // irrelevant rows would not hide them there.
+                ip.show_when = tp.show_when.clone();
             } else {
                 node.params.push(tp.clone());
             }
diff --git a/src/geometry.rs b/src/geometry.rs
index e793314..20abdad 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -4159,6 +4159,7 @@ pub fn parse_dynamic_params(code: &str) -> Vec<ParamDef> {
                                     min,
                                     max,
                                     step,
+                                    show_when: String::new(),
                                 });
                             }
                         }
@@ -5738,6 +5739,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 })
                 .collect(),
             geometry_visible: true,
@@ -5868,6 +5870,7 @@ mod tests {
                     min: Some(1.0),
                     max: Some(10.0),
                     step: Some(1.0),
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Shape".to_string(),
@@ -5878,6 +5881,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
             ],
             geometry_visible: true,
@@ -5941,6 +5945,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -5963,6 +5968,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Translation".to_string(),
@@ -5973,6 +5979,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6019,6 +6026,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Translation".to_string(),
@@ -6029,6 +6037,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6072,6 +6081,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Translation".to_string(),
@@ -6082,6 +6092,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6127,6 +6138,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6150,6 +6162,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Code".to_string(),
@@ -6167,6 +6180,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6215,6 +6229,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6238,6 +6253,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Points".to_string(),
@@ -6248,6 +6264,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 },
                 ParamDef {
                     name: "Radius".to_string(),
@@ -6258,6 +6275,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 }
             ],
             geometry_visible: true,
@@ -6508,6 +6526,7 @@ mod simnet_tests {
             min: None,
             max: None,
             step: None,
+            show_when: String::new(),
         }
     }
 
diff --git a/src/main.rs b/src/main.rs
index 59105d3..cadf9b7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -571,6 +571,7 @@ mod tests {
                 min: None,
                 max: None,
                 step: None,
+                show_when: String::new(),
             });
         }
 
@@ -742,6 +743,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 });
             }
             state.fs_root.children.push(child);
@@ -3362,6 +3364,147 @@ mod tests {
         }
     }
 
+    // ---- The parameter pane's conditional rows ----
+
+    fn pd(name: &str, value: &str, show_when: &str) -> crate::app::ParamDef {
+        crate::app::ParamDef {
+            name: name.into(),
+            label: String::new(),
+            param_type: "text".into(),
+            default: value.into(),
+            options: vec![],
+            min: None,
+            max: None,
+            step: None,
+            show_when: show_when.into(),
+        }
+    }
+
+    #[test]
+    fn test_a_row_shows_only_when_its_condition_holds() {
+        use crate::app::{param_display, param_visible};
+        let params = vec![
+            pd("Mode", "Twist", ""),
+            pd("Angle", "1.0", "Mode == Twist"),
+            pd("Bend Axis", "Y", "Mode == Bend"),
+            pd("Shared", "x", "Mode == Twist|Bend"),
+            pd("Not Bleed", "x", "Mode != Bleed"),
+        ];
+        let shown: Vec<String> = param_display(&params).into_iter().map(|r| r.0).collect();
+        assert_eq!(shown, vec!["Mode", "Angle", "Shared", "Not Bleed"]);
+
+        // Flip the driving parameter and a different set applies. This is the
+        // whole point: collapsing fifty operators into ten traded node count
+        // for parameter count, and a pane showing twelve irrelevant rows is
+        // worse than the twelve nodes it replaced.
+        let mut bent = params.clone();
+        bent[0].default = "Bend".into();
+        let shown: Vec<String> = param_display(&bent).into_iter().map(|r| r.0).collect();
+        assert_eq!(shown, vec!["Mode", "Bend Axis", "Shared", "Not Bleed"]);
+
+        // Bleed matches none of the conditions, so only the driving row is
+        // left — which is a node with one relevant control showing one.
+        let mut bleeding = params.clone();
+        bleeding[0].default = "Bleed".into();
+        let shown: Vec<String> = param_display(&bleeding).into_iter().map(|r| r.0).collect();
+        assert_eq!(shown, vec!["Mode"]);
+
+        // The condition is evaluated against siblings' CURRENT values, which
+        // is where this app keeps them.
+        assert!(param_visible(&params, "Mode == Twist"));
+        assert!(!param_visible(&bleeding, "Mode == Twist"));
+    }
+
+    #[test]
+    fn test_conditions_and_together_and_compare_without_case() {
+        use crate::app::param_visible;
+        let params = vec![
+            pd("Mode", "Align", ""),
+            pd("Target", "Constant", ""),
+        ];
+        assert!(param_visible(&params, "Mode == Align && Target == Constant"));
+        assert!(!param_visible(&params, "Mode == Align && Target == Attribute"));
+        // Case does not matter: a template author writing `twist` and a choice
+        // reading `Twist` is not a bug worth having.
+        assert!(param_visible(&params, "mode == ALIGN"));
+        // An empty condition always holds — that is what most parameters have.
+        assert!(param_visible(&params, ""));
+        assert!(param_visible(&params, "   "));
+    }
+
+    #[test]
+    fn test_a_broken_condition_hides_its_row_rather_than_hiding_the_mistake() {
+        use crate::app::param_visible;
+        let params = vec![pd("Mode", "Twist", "")];
+        // A misspelled sibling, and a clause that is not a comparison at all.
+        // Both are template bugs; showing the row unconditionally would let
+        // them pass unnoticed, and the row going missing is a complaint you
+        // can act on.
+        assert!(!param_visible(&params, "Moed == Twist"));
+        assert!(!param_visible(&params, "Mode"));
+        assert!(!param_visible(&params, "Mode ~ Twist"));
+    }
+
+    #[test]
+    fn test_the_shipped_templates_only_name_parameters_they_have() {
+        // Every condition in every template has to resolve, or the row it
+        // guards silently never appears. Checking the shipped set here is
+        // cheaper than finding one missing in the pane a month from now.
+        let templates = crate::app::load_fs_tree();
+        let mut checked = 0;
+        fn walk(node: &FsNode, checked: &mut usize) {
+            let names: Vec<&str> = node.params.iter().map(|p| p.name.as_str()).collect();
+            for p in &node.params {
+                for clause in p.show_when.split("&&") {
+                    let clause = clause.trim();
+                    if clause.is_empty() {
+                        continue;
+                    }
+                    let sep = if clause.contains("!=") { "!=" } else { "==" };
+                    let (lhs, rhs) = clause.split_once(sep).unwrap_or_else(|| {
+                        panic!("{}: '{}' is not a comparison", node.name, clause)
+                    });
+                    let lhs = lhs.trim();
+                    assert!(
+                        names.iter().any(|n| n.eq_ignore_ascii_case(lhs)),
+                        "{} guards '{}' on '{}', which it does not have",
+                        node.name,
+                        p.name,
+                        lhs
+                    );
+                    assert!(!rhs.trim().is_empty(), "{}: '{}' compares to nothing", node.name, clause);
+                    *checked += 1;
+                }
+            }
+            for c in &node.children {
+                walk(c, checked);
+            }
+        }
+        for t in &templates.children {
+            walk(t, &mut checked);
+        }
+        assert!(checked > 20, "only {checked} conditions checked — did the templates lose them?");
+    }
+
+    #[test]
+    fn test_hiding_a_row_does_not_lose_its_value() {
+        use crate::app::param_display;
+        // Write-back resolves a row by its display key, not by position, so a
+        // hidden parameter is simply not reported and keeps what it had. A
+        // user who sets a Remap range, switches to Clip and switches back must
+        // find their numbers still there.
+        let mut params = vec![
+            pd("Operation", "Remap", ""),
+            pd("To Max", "7.5", "Operation == Remap"),
+        ];
+        assert_eq!(param_display(&params).len(), 2);
+        params[0].default = "Clip".into();
+        assert_eq!(param_display(&params).len(), 1, "the row hid");
+        assert_eq!(params[1].default, "7.5", "but the value is untouched");
+        params[0].default = "Remap".into();
+        assert_eq!(param_display(&params)[1].1, "7.5", "and comes back as it was");
+    }
+
     // ---- Phase 4: the modelling set ----
 
     /// Two spheres far apart: two connected pieces, the first much larger.
@@ -3488,6 +3631,7 @@ mod tests {
                 min: None,
                 max: None,
                 step: None,
+                show_when: String::new(),
             });
 
         let (g, err) = eval_node(&root, "distance 1");
@@ -4321,6 +4465,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 })
                 .collect(),
             geometry_visible: true,
@@ -4364,6 +4509,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
+                    show_when: String::new(),
                 })
                 .collect(),
             geometry_visible: true,
diff --git a/src/project.rs b/src/project.rs
index ba1f81c..5fed3f5 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -592,6 +592,7 @@ impl State {
                     min,
                     max,
                     step,
+                    show_when: String::new(),
                 });
             }
         }
@@ -990,6 +991,7 @@ impl State {
                         min: None,
                         max: None,
                         step: None,
+                        show_when: String::new(),
                     });
                 }
                 if !node.params.iter().any(|p| p.name == "Show Camera Pivot") {
@@ -1002,6 +1004,7 @@ impl State {
                         min: None,
                         max: None,
                         step: None,
+                        show_when: String::new(),
                     });
                 }
                 if !node.params.iter().any(|p| p.name == "Camera Pivot Size") {
@@ -1014,6 +1017,7 @@ impl State {
                         min: Some(1.0),
                         max: Some(50.0),
                         step: Some(1.0),
+                        show_when: String::new(),
                     });
                 }
             }
diff --git a/src/window.rs b/src/window.rs
index 2fa02a2..3cd256b 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -894,6 +894,7 @@ impl State {
                         min: None,
                         max: None,
                         step: None,
+                        show_when: String::new(),
                     };
                     state.current_dir_mut().children[slot].params.push(param);
                     state.sync_nodes();