graphic design tool
git clone https://git.lucas.co/cce-designer.git
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(¶ms).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(¶ms, "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(¶ms, "Mode == Align && Target == Constant"));
+ assert!(!param_visible(¶ms, "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(¶ms, "mode == ALIGN"));
+ // An empty condition always holds — that is what most parameters have.
+ assert!(param_visible(¶ms, ""));
+ assert!(param_visible(¶ms, " "));
+ }
+
+ #[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(¶ms, "Moed == Twist"));
+ assert!(!param_visible(¶ms, "Mode"));
+ assert!(!param_visible(¶ms, "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(¶ms).len(), 2);
+ params[0].default = "Clip".into();
+ assert_eq!(param_display(¶ms).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(¶ms)[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();