graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a range's two ends are one float2 row
The Attribute node's From Min / From Max and To Min / To Max are From and
To, and Visualize's From / To are Manual Range: a new `float2` parameter
kind, two sliders over a soft span that adapts to the value (the Value
row's rule, kept per parameter and through a drag), so it holds what the
unranged `float` rows held. Normalize's goal, which was To Max, is its own
Normalize To row.
Format 6 joins an older save's pairs as written (an expression half makes
the whole an expression, each component its own) and rewrites the channel
paths that named the old rows, resolved from their holders so a Transfer's
`from` wire is not taken for one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 47 ++++++++++---
nodes/attribute.json | 39 +++++------
nodes/visualize.json | 21 ++----
src/app.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++-
src/geometry.rs | 96 +++++++++++++-------------
src/main.rs | 130 +++++++++++++++++++++++++++++++++--
src/param.rs | 24 ++++++-
src/visualizer.rs | 7 +-
8 files changed, 446 insertions(+), 105 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 268a967..bd82f81 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -871,8 +871,8 @@ old text Center a float3 from the load on. Tests build parameters with
`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.
+ never by hand. By template, not by name: the Attribute node's `From` /
+ `To` are ranges.
- **`attribute` and `group` name a point attribute or a point group on the
node's input** (since 2026-09-28) — read or written, it is the same kind:
Visualize's `Attribute` and Normal's are both `attribute`, Relax's `Pin
@@ -921,7 +921,8 @@ old text Center a float3 from the load on. Tests build parameters with
"the playbar's", Bounds' `Prefix` is a prefix, Curve's `Points` a list of
positions, Export's `File` a path. The same day Attribute's `From Min` /
`From Max` / `To Min` / `To Max` became `float` and Neighbour's `Constant`
- a `float3`, the numbers phase 1 missed.
+ a `float3`, the numbers phase 1 missed (the four are one `float2` pair
+ since 2026-10-06, below).
`template_params_carry_the_kind_they_hold` pins all of it, including the
rows that stay text.
- **A float3 row can carry a trackball** (since 2026-09-28): cce-ui's
@@ -960,6 +961,21 @@ old text Center a float3 from the load on. Tests build parameters with
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.
+- **`float2` is a range's two ends, `lo:hi`** (since 2026-10-06): the
+ Attribute node's From and To (Remap; Clip's bounds are From) and
+ Visualize's Manual Range, which were two `float` rows each. Shown as
+ cce-ui's two-slider `float2` group over a SOFT span around the value —
+ the Value row's rule (`value_row_span`): ± the smallest power of ten
+ whose middle half holds both ends, kept while the value stays in it and
+ through a drag in the pane (`State::present_float2_rows`, per
+ parameter, `State::float2_spans`), and widened rather than clamped by a
+ value typed past an end. So it holds what a `float` held. It has no
+ `range()`: the pane chooses the span. Read with `node_param_vec2`. Each
+ component may be an expression, as a float3's are, and `.x` / `.y` read
+ one. A text that is not two numbers shows as a text box, to be put
+ right. Normalize's goal was To Max and is a row of its own now,
+ **Normalize To** (`float`). Format 6 joins an older save's pairs (see
+ "Parameter expressions"). `a_range_is_one_float2_row` is the test.
- **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.
@@ -1142,15 +1158,15 @@ the kernel path's parent read.
**Paths are Houdini's.** Relative to the node holding the expression: a bare
name is the node's OWN parameter, `..` its parent, `../sphere1/radius` a
-sibling's, a leading `/` the root. `.x` / `.y` / `.z` reads a float3
-component. `ch()` / `chf()` read a number (a toggle 1 or 0, a choice its
+sibling's, a leading `/` the root. `.x` / `.y` / `.z` reads a float2's
+or float3's component. `ch()` / `chf()` read a number (a toggle 1 or 0, a choice its
option INDEX — `chi("../method")` is what lets a subnet's dropdown drive a
child switch's Index), `chi()` truncates, `chb()` is 1 or 0, `chs()` the
string (a choice's option text). The rest is `+ - * / % ^`, comparisons,
`&& || !`, `$F` (the evaluation's frame), strings with `+`, and a fixed
function set (`if(c, a, b)`, `clamp`, `fit`, `lerp`, `min`/`max`, `rand(seed)`,
-the usual math). No ternary — `:` separates a float3's components, which
-are three expressions each (`chf("../a/size.x"):0:0`). An expression that
+the usual math). No ternary — `:` separates a float2's or float3's
+components, which are an expression each (`chf("../a/size.x"):0:0`). An expression that
reads an expression follows the chain; a circle is an error on the node,
never a stack overflow. The written-back value is formatted for the
TARGET row (`format_for_param`): a number into a toggle is `true`/`false`,
@@ -1177,7 +1193,18 @@ not `@UVW`) — and never a choice row, so the Sphere's Method keeps its
**Format 4** (the same day): parameter names are identifiers — see "A
parameter has a name and a label". **Format 5** (2026-10-02): the root is
the object level and an older save's geometry goes into a Geometry node —
-see "The root is the object level". Templates go through
+see "The root is the object level". **Format 6** (2026-10-06): a range is
+one `float2` (`Project::migrate_range_rows`). The Attribute node's From
+Min / From Max join as From, To Min / To Max as To, and To Max is copied to
+Normalize To, which Normalize read it as; Visualize's From / To join as
+Manual Range. Each pair is joined as written, the whole an expression when
+either half was, a missing half the default. A channel path to an old row
+is RESOLVED from its holder and rewritten — `from_max` → `from.y`, `to_max`
+→ `to.y` or `normalize_to` on a node set to Normalize, Visualize's `to` →
+`manual_range.y` — since `from` and `to` are also wires on Transfer, Copy
+and Distance, which are left alone. Checked on the user's project (four
+Attribute nodes, none a Remap): the old build and the new export the same
+mesh at frames 1 and 30. Templates go through
`infer_template_exprs` instead: a default that READS as a reference is one
(`embryo.json` says `chf("../radius")` now). The same inference applies to a
value typed into a plain row or scripted through `set_param`: a reference
@@ -3305,7 +3332,9 @@ ramp or drawn as a line from each point, with NO node in the graph. A
`Visualizer` is the Visualize node's settings under the node's own names
and options (Attribute, Mode, Ramp, Range, From/To, Blend, Opacity, Scale,
Group) plus a switch, and it runs through `geometry::apply_visualize` over
-a node built from them (`Visualizer::as_node`), so a visualizer and a
+a node built from them (`Visualizer::as_node`; From/To are the node's
+Manual Range, a float2, kept as two ends because the dialog edits each on
+a slider of its own and state.kdl names them so), so a visualizer and a
Visualize node cannot disagree about what they draw
(`a_visualizer_reads_as_the_visualize_node_does`). Several apply in order,
the later over the earlier, as a chain of Visualize nodes composites.
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 038e335..2389391 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -109,39 +109,30 @@
"group": "where"
},
{
- "name": "from_min",
- "label": "From Min",
- "type": "float",
- "default": "0.00",
+ "name": "from",
+ "label": "From",
+ "type": "float2",
+ "default": "0.00:1.00",
"show_when": "operation == Remap|Clip",
- "description": "Remap: the input value that maps to To Min. Clip: the lower bound values are clamped to.",
+ "description": "Remap: the input range, its first value mapping to To's first and its second to To's second; the two must differ. Clip: the bounds values are clamped to.",
"group": "range"
},
{
- "name": "from_max",
- "label": "From Max",
- "type": "float",
- "default": "1.00",
- "show_when": "operation == Remap|Clip",
- "description": "Remap: the input value that maps to To Max. Clip: the upper bound values are clamped to. Must differ from From Min for a remap.",
- "group": "range"
- },
- {
- "name": "to_min",
- "label": "To Min",
- "type": "float",
- "default": "0.00",
+ "name": "to",
+ "label": "To",
+ "type": "float2",
+ "default": "0.00:1.00",
"show_when": "operation == Remap",
- "description": "The value that From Min maps to; values between map linearly, and values outside the From range extend past it.",
+ "description": "The output range From maps onto; values between map linearly, and values outside From extend past it.",
"group": "range"
},
{
- "name": "to_max",
- "label": "To Max",
+ "name": "normalize_to",
+ "label": "Normalize To",
"type": "float",
"default": "1.00",
- "show_when": "operation == Remap|Normalize",
- "description": "Remap: the value that From Max maps to. Normalize: the value the chosen measure (sum, maximum or range) is scaled to hit.",
+ "show_when": "operation == Normalize",
+ "description": "The value the chosen measure (sum, maximum or range) is scaled to hit.",
"group": "range"
},
{
@@ -186,7 +177,7 @@
"type": "choice:Sum,Maximum,Range",
"default": "Maximum",
"show_when": "operation == Normalize",
- "description": "What is measured before scaling: the values' sum, their maximum, or their range (max minus min). Every value is scaled so this measure equals To Max.",
+ "description": "What is measured before scaling: the values' sum, their maximum, or their range (max minus min). Every value is scaled so this measure equals Normalize To.",
"group": "normalize"
},
{
diff --git a/nodes/visualize.json b/nodes/visualize.json
index 4f1a5e0..7e6d5b2 100644
--- a/nodes/visualize.json
+++ b/nodes/visualize.json
@@ -40,25 +40,16 @@
"type": "choice:Auto,Manual",
"default": "Auto",
"show_when": "mode == Ramp",
- "description": "Auto spreads the ramp from the attribute's lowest to highest value over all points; Manual uses From and To.",
+ "description": "Auto spreads the ramp from the attribute's lowest to highest value over all points; Manual uses Manual Range.",
"group": "ramp"
},
{
- "name": "from",
- "label": "From",
- "type": "float",
- "default": "0.00",
+ "name": "manual_range",
+ "label": "Manual Range",
+ "type": "float2",
+ "default": "0.00:1.00",
"show_when": "mode == Ramp && range == Manual",
- "description": "The value shown at the low end of the ramp. Values below it are clamped to that end.",
- "group": "ramp"
- },
- {
- "name": "to",
- "label": "To",
- "type": "float",
- "default": "1.00",
- "show_when": "mode == Ramp && range == Manual",
- "description": "The value shown at the high end of the ramp. Values above it are clamped to that end.",
+ "description": "The values shown at the low and high ends of the ramp. Values outside are clamped to the nearer end.",
"group": "ramp"
},
{
diff --git a/src/app.rs b/src/app.rs
index dec0215..f695511 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -382,7 +382,8 @@ pub struct Project {
/// (the same day) their texture coordinates `uv` where they were `UV`;
/// 4 (the same day) parameter names as identifiers; 5 (2026-10-02) the
/// root as the object level, its geometry inside a `geometry` node
- /// (`context::wrap_root_geometry`).
+ /// (`context::wrap_root_geometry`); 6 (2026-10-06) a range's two ends as
+ /// one `float2` (`Project::migrate_range_rows`).
/// `migrate_format` takes a file through each step it is behind, and a
/// step must not run twice.
#[serde(default)]
@@ -390,7 +391,7 @@ pub struct Project {
}
/// The format `Project` saves in — see its `format` field.
-pub const PROJECT_FORMAT: u32 = 5;
+pub const PROJECT_FORMAT: u32 = 6;
/// One entry in a node's right-click context menu, parallel to the visible
/// labels shown via `context_menu::show`.
@@ -686,6 +687,7 @@ pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static
_ => match kind {
ParamKind::Slider | ParamKind::Float => "float",
ParamKind::Spin => "integer",
+ ParamKind::Float2 => "float2",
ParamKind::Float3 => "float3",
ParamKind::Toggle => "boolean",
ParamKind::Choice => "enum",
@@ -785,6 +787,13 @@ pub fn float3_row(min: f32, max: f32, trackball: bool) -> String {
format!("float3:{}:{}{}", min, max, if trackball { ":trackball" } else { "" })
}
+/// A `float2` parameter's display type over ± `span`: two sliders, with a
+/// soft range, so a value typed past an end widens it rather than being
+/// clamped.
+pub fn float2_row(span: f32) -> String {
+ format!("float2:{}:{}:soft", -span, span)
+}
+
/// The control the Attribute node's Value row is presented as, for a target
/// `width` components wide, the row's text as that control shows it, and
/// the half-span it runs over: a slider for one, the float group with two,
@@ -993,6 +1002,15 @@ fn param_row(p: &ParamDef) -> (String, String, String) {
// `textpick` row by `add_pick_lists` when the input has names
// to offer.
"text".to_string()
+ } else if kind == ParamKind::Float2 {
+ // Two sliders over a soft range around the value, as the
+ // Attribute node's Value row is; the pane's own pass keeps the
+ // span a row already has (`State::present_float2_rows`). A text
+ // that is not two numbers stays a text box, to be put right.
+ match p.value() {
+ Some(ParamValue::Vec2(v)) => float2_row(value_row_span(v, None)),
+ _ => "text".to_string(),
+ }
} else if p.ty() == "slider" {
let (min, max, _) = p.range().expect("a slider has a range");
format!("slider:{}:{}", min, max)
@@ -1273,9 +1291,122 @@ impl Project {
if self.format < 5 {
crate::context::wrap_root_geometry(self);
}
+ if self.format < 6 {
+ self.migrate_range_rows();
+ }
self.format = PROJECT_FORMAT;
}
+ /// Format 5 → 6: a range is one `float2` row where it was two numbers.
+ /// The Attribute node's From Min / From Max become From, To Min / To
+ /// Max become To, and To Max is also Normalize To, the goal Normalize
+ /// read off it; Visualize's From / To become Manual Range. Each pair's
+ /// two texts are joined as they were written (`0.00:1.00`), and when
+ /// either was an expression so is the whole — a float2's components are
+ /// each an expression, as a float3's are. A half the save lacks takes
+ /// the default. What names the old rows follows: a channel path to
+ /// `from_min` reads `from.x`, to `to_max` `to.y` (or `normalize_to` on
+ /// a node set to Normalize, which is what it meant there), Visualize's
+ /// `from` `manual_range.x`. Paths are RESOLVED, from where their holder
+ /// stands — `from` and `to` are wires on Transfer, Copy and Distance,
+ /// and those are not touched.
+ fn migrate_range_rows(&mut self) {
+ // What a retired row is now, on the node that has it.
+ fn now(node: &FsNode, param: &str) -> Option<&'static str> {
+ if !node.params.iter().any(|p| p.name == param) {
+ return None;
+ }
+ let normalize = node_param_str(node, "operation", "").eq_ignore_ascii_case("normalize");
+ Some(match (node.node_type.to_ascii_lowercase().as_str(), param) {
+ ("attribute", "from_min") => "from.x",
+ ("attribute", "from_max") => "from.y",
+ ("attribute", "to_min") => "to.x",
+ ("attribute", "to_max") if normalize => "normalize_to",
+ ("attribute", "to_max") => "to.y",
+ ("visualize", "from") if visualize_pair(node) => "manual_range.x",
+ ("visualize", "to") if visualize_pair(node) => "manual_range.y",
+ _ => return None,
+ })
+ }
+ // Visualize's From and To are the numbers this step joins, not a
+ // later row of the same name.
+ fn visualize_pair(node: &FsNode) -> bool {
+ !node.params.iter().any(|p| p.name == "manual_range")
+ }
+ // Pass one, over the tree as it stands: every path that names one.
+ fn collect(root: &FsNode, node: &FsNode, edits: &mut Vec<(String, String, String)>) {
+ for p in node.params.iter().filter(|p| p.is_expr() || p.kind() == ParamKind::Code) {
+ let text = crate::expr::rewrite_paths(p.text(), |path| {
+ let (id, _, param) = crate::geometry::ref_path_target(root, node, path)?;
+ let target = crate::viewer_state::find_node_by_id(root, &id)?;
+ let new = now(target, ¶m)?;
+ Some(match path.trim().rsplit_once('/') {
+ Some((head, _)) => format!("{head}/{new}"),
+ None => new.to_string(),
+ })
+ });
+ if text != p.text() {
+ edits.push((node.id.clone(), p.name.clone(), text));
+ }
+ }
+ for c in &node.children {
+ collect(root, c, edits);
+ }
+ }
+ let mut edits = Vec::new();
+ collect(&self.root, &self.root, &mut edits);
+ for (id, name, text) in edits {
+ let node = if self.root.id == id { Some(&mut self.root) } else { crate::viewer_state::find_node_by_id_mut(&mut self.root, &id) };
+ if let Some(p) = node.and_then(|n| n.params.iter_mut().find(|p| p.name == name)) {
+ p.set_text(text);
+ }
+ }
+
+ // Pass two: the rows themselves.
+ fn join(node: &mut FsNode, lo: &str, hi: &str, name: &str, keep_hi: Option<&str>) {
+ let Some(at) = node.params.iter().position(|p| p.name == lo || p.name == hi) else { return };
+ let half = |node: &FsNode, n: &str, default: &str| {
+ node.params.iter().find(|p| p.name == n).map_or((default.to_string(), false), |p| (p.text().trim().to_string(), p.is_expr()))
+ };
+ let (a, ae) = half(node, lo, "0.00");
+ let (b, be) = half(node, hi, "1.00");
+ let mut joined = ParamDef::new(name, "float2", format!("{a}:{b}"));
+ if ae || be {
+ joined.set_expr(true);
+ }
+ let mut extra = None;
+ if let Some(extra_name) = keep_hi {
+ if let Some(p) = node.params.iter().find(|p| p.name == hi) {
+ let mut q = ParamDef::new(extra_name, "float", p.text());
+ q.set_expr(p.is_expr());
+ extra = Some(q);
+ }
+ }
+ node.params.retain(|p| p.name != lo && p.name != hi);
+ let at = at.min(node.params.len());
+ node.params.insert(at, joined);
+ if let Some(q) = extra {
+ node.params.insert(at + 1, q);
+ }
+ }
+ fn walk(node: &mut FsNode) {
+ match node.node_type.to_ascii_lowercase().as_str() {
+ "attribute" => {
+ join(node, "from_min", "from_max", "from", None);
+ join(node, "to_min", "to_max", "to", Some("normalize_to"));
+ }
+ "visualize" if visualize_pair(node) && node.params.iter().any(|p| p.name == "from" || p.name == "to") => {
+ join(node, "from", "to", "manual_range", None);
+ }
+ _ => {}
+ }
+ for c in &mut node.children {
+ walk(c);
+ }
+ }
+ walk(&mut self.root);
+ }
+
/// Format 3 → 4: a parameter's NAME is an identifier
/// (`base_resolution`), where it was the text the pane showed (`Base
/// Resolution`) — that is the template's label now. Every parameter is
@@ -3064,6 +3195,9 @@ pub struct State {
/// node it is for (`value_row_span`): kept between pane syncs so the row
/// re-scales only when its value leaves it.
pub value_row_span: Option<(String, f32)>,
+ /// The same for the shown node's `float2` rows, by parameter name, with
+ /// the node they are for (`present_float2_rows`).
+ pub float2_spans: Option<(String, Vec<(String, f32)>)>,
/// The displayed scene's point attributes as last built, with their
/// ranges: what the visualizer editor offers.
pub scene_attributes: Vec<crate::visualizer::SceneAttribute>,
@@ -4970,10 +5104,58 @@ impl State {
vec![]
};
let params = self.add_pick_lists(params);
+ let params = self.present_float2_rows(params);
self.param_mut().set_display_params(¶ms);
self.param_pane_source = if self.is_detached_network { None } else { self.param_pane_target() };
}
+ /// The shown node's `float2` rows over the span each already has:
+ /// `param_display` chooses one from the value alone, and this keeps the
+ /// span in use while the value stays inside it, and outright through a
+ /// drag in the pane — a new span is a new row type, which rebuilds the
+ /// pane and would drop the slider being held. The Value row's rule
+ /// ([`value_row_span`]), kept per parameter.
+ fn present_float2_rows(&mut self, mut params: Vec<(String, String, String)>) -> Vec<(String, String, String)> {
+ let rows: Vec<(String, String, [f32; 2])> = {
+ if self.is_detached_network {
+ return params;
+ }
+ let Some(slot) = self.param_editor_selected() else { return params };
+ let Some(node) = self.param_editor_dir().children.get(slot) else { return params };
+ let rows = node
+ .params
+ .iter()
+ .filter(|p| !p.is_expr())
+ .filter_map(|p| match p.value() {
+ Some(ParamValue::Vec2(v)) => Some((p.shown_name().to_string(), p.name.clone(), *v)),
+ _ => None,
+ })
+ .collect::<Vec<_>>();
+ if rows.is_empty() {
+ return params;
+ }
+ let id = node.id.clone();
+ if self.float2_spans.as_ref().is_none_or(|(n, _)| *n != id) {
+ self.float2_spans = Some((id, Vec::new()));
+ }
+ rows
+ };
+ let held = self.drag_widget == Some(crate::slots::PARAM_IDX);
+ let Some((_, spans)) = self.float2_spans.as_mut() else { return params };
+ for (key, name, v) in rows {
+ let Some(row) = params.iter_mut().find(|r| r.0 == key && r.2.starts_with("float2")) else { continue };
+ let kept = spans.iter().find(|(n, _)| *n == name).map(|(_, r)| *r);
+ let span = match (held, kept) {
+ (true, Some(r)) => r,
+ _ => value_row_span(&v, kept),
+ };
+ row.2 = float2_row(span);
+ spans.retain(|(n, _)| *n != name);
+ spans.push((name, span));
+ }
+ params
+ }
+
/// Upgrade the selected node's `attribute`- and `group`-kind text rows
/// to `textpick` rows carrying the candidates read off the node's INPUT
/// geometry (the Houdini attribute/group chooser). Rows stay plain text
@@ -8065,6 +8247,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
visualizers: crate::visualizer::decode(&settings.viewport.visualizers),
vis_editing: None,
value_row_span: None,
+ float2_spans: None,
scene_attributes: Vec::new(),
scene_base: None,
scene_groups: Vec::new(),
diff --git a/src/geometry.rs b/src/geometry.rs
index 40514cc..4a0d6b9 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -575,6 +575,24 @@ pub fn node_param_vec3(node: &FsNode, name: &str, fallback: Vec3) -> Vec3 {
fallback
}
+/// A float2's two numbers — a range's two ends — read like
+/// [`node_param_vec3`]: the parsed value, else the text as `x:y`, else
+/// `fallback`.
+pub fn node_param_vec2(node: &FsNode, name: &str, fallback: [f32; 2]) -> [f32; 2] {
+ use crate::app::ParamValue;
+ let Some(p) = find_param(node, name) else { return fallback };
+ if let Some(ParamValue::Vec2(v)) = p.value() {
+ return *v;
+ }
+ let parts: Vec<&str> = p.text().split(':').collect();
+ if let [x, y] = parts[..] {
+ if let (Ok(x), Ok(y)) = (x.trim().parse::<f32>(), y.trim().parse::<f32>()) {
+ return [x, y];
+ }
+ }
+ 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
@@ -904,13 +922,18 @@ impl Evaluated {
}
}
-/// One parameter's expression evaluated. A float3 is three expressions
-/// separated by `:` — each component its own, as Houdini's channels are —
-/// so `chf("../a/size.x"):0:0` reads naturally.
+/// One parameter's expression evaluated. A float2 or float3 is two or
+/// 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<Evaluated, String> {
- if p.kind() == crate::app::ParamKind::Float3 {
+ let width = match p.kind() {
+ crate::app::ParamKind::Float2 => 2,
+ crate::app::ParamKind::Float3 => 3,
+ _ => 0,
+ };
+ if width > 0 {
let parts: Vec<&str> = p.text().split(':').collect();
- if parts.len() == 3 {
+ if parts.len() == width {
let mut out = [0.0f32; 3];
for (c, part) in out.iter_mut().zip(parts) {
let t = part.trim();
@@ -922,7 +945,10 @@ fn eval_param_value(scope: &mut TreeScope, p: &ParamDef) -> Result<Evaluated, St
}
};
}
- return Ok(Evaluated::Value(crate::app::ParamValue::Vec3(out)));
+ return Ok(Evaluated::Value(match width {
+ 2 => crate::app::ParamValue::Vec2([out[0], out[1]]),
+ _ => crate::app::ParamValue::Vec3(out),
+ }));
}
}
let e = crate::expr::parse(p.text()).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
@@ -4090,10 +4116,8 @@ pub(crate) fn apply_visualize(geom: &mut Detail, target: &FsNode, ocl_error: &mu
// colours should sit where they belong on the whole picture's scale, or
// two Visualize nodes over two groups would each claim the full ramp.
let (from, to) = if node_param_str(target, "range", "Auto").eq_ignore_ascii_case("manual") {
- (
- node_param_f32(target, "from", 0.0),
- node_param_f32(target, "to", 1.0),
- )
+ let [from, to] = node_param_vec2(target, "manual_range", [0.0, 1.0]);
+ (from, to)
} else {
let vals: Vec<f32> = (0..geom.num_points())
.filter_map(|p| geom.points().value(&name, p))
@@ -5087,34 +5111,25 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
// workhorse: a simulation attribute measured by Analysis is almost
// always remapped before anything reads it.
"remap" => {
- let (f0, f1) = (
- node_param_f32(target, "from_min", 0.0),
- node_param_f32(target, "from_max", 1.0),
- );
- let (t0, t1) = (
- node_param_f32(target, "to_min", 0.0),
- node_param_f32(target, "to_max", 1.0),
- );
+ let [f0, f1] = node_param_vec2(target, "from", [0.0, 1.0]);
+ let [t0, t1] = node_param_vec2(target, "to", [0.0, 1.0]);
let span = f1 - f0;
if span.abs() < 1e-9 {
- fail = format!("From Min and From Max are both {}", f0);
+ fail = format!("From's two ends are both {}", f0);
} else {
edit_components(geom, &name, &affected, |v| {
t0 + (v - f0) / span * (t1 - t0)
});
}
}
- // Clamp into a range. From Min / From Max name the bounds, so Remap
- // and Clip read the same way and chain without renaming anything.
+ // Clamp into a range. From names the bounds, so Remap and Clip read
+ // the same way and chain without renaming anything.
"clip" => {
- let (lo, hi) = (
- node_param_f32(target, "from_min", 0.0),
- node_param_f32(target, "from_max", 1.0),
- );
+ let [lo, hi] = node_param_vec2(target, "from", [0.0, 1.0]);
let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
edit_components(geom, &name, &affected, |v| v.clamp(lo, hi));
}
- // Rescale so the attribute's sum, maximum or range hits To Max.
+ // Rescale so the attribute's sum, maximum or range hits Normalize To.
// Unlike Remap this MEASURES first, so it needs no knowledge of what
// the values happen to be — which is what makes it survive a
// simulation whose range moves every frame.
@@ -5124,7 +5139,7 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
.filter_map(|&p| geom.points().value(&name, p))
.map(|v| v.as_f32())
.collect();
- let goal = node_param_f32(target, "to_max", 1.0);
+ let goal = node_param_f32(target, "normalize_to", 1.0);
let measure = match node_param_str(target, "target", "Maximum").to_lowercase().as_str() {
"sum" => vals.iter().sum::<f32>(),
"range" => {
@@ -7415,24 +7430,15 @@ mod simnet_tests {
let (remapped, err) = run_attr(
&before,
- &[
- ("operation", "Remap"),
- ("from_min", "0.00"),
- ("from_max", &last.to_string()),
- ("to_min", "0.00"),
- ("to_max", "1.00"),
- ],
+ &[("operation", "Remap"), ("from", &format!("0.00:{last}")), ("to", "0.00:1.00")],
);
assert!(err.is_none(), "{err:?}");
assert_eq!(mass(&remapped, 0), 0.0);
assert!((mass(&remapped, n - 1) - 1.0).abs() < 1e-5);
- // Clip reads the same From Min / From Max, so the two chain without
- // renaming anything between them.
- let (clipped, err) = run_attr(
- &before,
- &[("operation", "Clip"), ("from_min", "2.00"), ("from_max", "5.00")],
- );
+ // Clip reads the same From, so the two chain without renaming
+ // anything between them.
+ let (clipped, err) = run_attr(&before, &[("operation", "Clip"), ("from", "2.00:5.00")]);
assert!(err.is_none(), "{err:?}");
assert_eq!(mass(&clipped, 0), 2.0);
assert_eq!(mass(&clipped, 3), 3.0);
@@ -7441,7 +7447,7 @@ mod simnet_tests {
// A degenerate source range is refused rather than dividing by zero.
let (_, err) = run_attr(
&before,
- &[("operation", "Remap"), ("from_min", "1.00"), ("from_max", "1.00")],
+ &[("operation", "Remap"), ("from", "1.00:1.00")],
);
assert!(err.is_some(), "a zero-width source range must be reported");
}
@@ -7497,7 +7503,7 @@ mod simnet_tests {
let (_, err) = run(&[("operation", "Create"), ("attribute_name", "w3")], "");
assert!(err.is_some());
// An operation that has no Value is untouched by the switch.
- let (_, err) = run(&[("operation", "Clip"), ("from_min", "0"), ("from_max", "1")], "nope");
+ let (_, err) = run(&[("operation", "Clip"), ("from", "0:1")], "nope");
assert!(err.is_none(), "{err:?}");
}
@@ -7510,11 +7516,11 @@ mod simnet_tests {
// Unlike Remap, Normalize needs no knowledge of the values — which is
// what lets it sit in a solve whose range moves every frame.
- let (by_max, err) = run_attr(&before, &[("operation", "Normalize"), ("target", "Maximum"), ("to_max", "1.00")]);
+ let (by_max, err) = run_attr(&before, &[("operation", "Normalize"), ("target", "Maximum"), ("normalize_to", "1.00")]);
assert!(err.is_none(), "{err:?}");
assert!((mass(&by_max, n - 1) - 1.0).abs() < 1e-5);
- let (by_sum, _) = run_attr(&before, &[("operation", "Normalize"), ("target", "Sum"), ("to_max", "1.00")]);
+ let (by_sum, _) = run_attr(&before, &[("operation", "Normalize"), ("target", "Sum"), ("normalize_to", "1.00")]);
assert!((sum(&by_sum) - 1.0).abs() < 1e-4, "sum is {}", sum(&by_sum));
// An all-zero attribute has no scale to hit, and says so instead of
@@ -7871,7 +7877,7 @@ mod simnet_tests {
}
// Multiply darkens toward the ramp, Add brightens away from it.
- let mid = run_vis(&before, &[("ramp", "Grayscale"), ("range", "Manual"), ("from", "0.00"), ("to", "2.00")]).0;
+ let mid = run_vis(&before, &[("ramp", "Grayscale"), ("range", "Manual"), ("manual_range", "0.00:2.00")]).0;
let (mul, _) = run_vis(&mid, &[("attribute", "heat"), ("ramp", "Grayscale"), ("blend", "Multiply")]);
let (add, _) = run_vis(&mid, &[("attribute", "heat"), ("ramp", "Grayscale"), ("blend", "Add")]);
assert!(mul.color(0)[0] <= mid.color(0)[0] + 1e-6);
diff --git a/src/main.rs b/src/main.rs
index 2825721..fc189a6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4754,7 +4754,7 @@ mod tests {
// Visualize: its Attribute and Group rows are pickers because the
// template says what they name, not because this node is listed
- // anywhere; From and To are numbers and stay text boxes.
+ // anywhere; its Manual Range is a range's two ends, two sliders.
state.graph_mut().set_selected_node(Some(3));
state.sync_parameters_pane();
let rows = state.param_mut().node_params();
@@ -4763,7 +4763,7 @@ mod tests {
};
assert!(row("Attribute").starts_with("textpick:") && row("Attribute").contains("Pos"), "got {}", row("Attribute"));
assert_eq!(row("Group"), "textpick:group1");
- assert_eq!(row("From"), "text");
+ assert_eq!(row("Manual Range"), "float2:-10:10:soft");
assert_eq!(row("Input"), "text");
}
@@ -5304,8 +5304,7 @@ mod tests {
assert_eq!(kind(ty, name), K::Float3, "{ty}'s {name}");
}
for (ty, name) in [
- ("cull", "threshold"), ("group", "threshold"), ("copy", "scale"), ("visualize", "from"), ("visualize", "to"),
- ("attribute", "from_min"), ("attribute", "from_max"), ("attribute", "to_min"), ("attribute", "to_max"),
+ ("cull", "threshold"), ("group", "threshold"), ("copy", "scale"), ("attribute", "normalize_to"),
] {
assert_eq!(kind(ty, name), K::Float, "{ty}'s {name}");
}
@@ -6883,7 +6882,7 @@ mod tests {
// find their numbers still there.
let mut params = vec![
pd("operation", "Remap", ""),
- pd("to_max", "7.5", "operation == Remap"),
+ pd("to", "7.5", "operation == Remap"),
];
assert_eq!(param_display(¶ms).len(), 2);
params[0].set_text("Clip");
@@ -9613,6 +9612,127 @@ mod tests {
assert!(proj.view_state.current_path.is_empty());
}
+ /// A range's two ends are one `float2` row (format 6, 2026-10-06): the
+ /// Attribute node's From and To, Normalize's own Normalize To, and
+ /// Visualize's Manual Range. An older save's pairs are joined as they
+ /// were written — an expression half makes the whole an expression,
+ /// each component its own — and a channel path to an old row follows,
+ /// RESOLVED, so a Transfer's `from` wire is not mistaken for one. The
+ /// pane shows two sliders over a soft span around the value, kept while
+ /// the value stays in it and through a drag; an expression is text.
+ #[test]
+ fn a_range_is_one_float2_row() {
+ use crate::app::{ParamKind, ParamValue, Project, PROJECT_FORMAT};
+ let templates_root = crate::app::load_fs_tree();
+ let kind = |ty: &str, name: &str| {
+ templates_root.children.iter().find(|t| t.node_type == ty).unwrap().params.iter().find(|p| p.name == name).map(|p| p.kind())
+ };
+ assert_eq!(kind("attribute", "from"), Some(ParamKind::Float2));
+ assert_eq!(kind("attribute", "to"), Some(ParamKind::Float2));
+ assert_eq!(kind("attribute", "normalize_to"), Some(ParamKind::Float));
+ assert_eq!(kind("visualize", "manual_range"), Some(ParamKind::Float2));
+ for gone in ["from_min", "from_max", "to_min", "to_max"] {
+ assert_eq!(kind("attribute", gone), None, "{gone} is retired");
+ }
+ assert_eq!((kind("visualize", "from"), kind("visualize", "to")), (None, None));
+
+ // An older save.
+ let attr = |id: &str, op: &str, rows: Vec<(&'static str, &'static str, &'static str)>| {
+ let mut params = vec![("input", "node", "sphere1"), ("operation", "choice:Remap,Normalize", op)];
+ params.extend(rows);
+ ref_node(id, id, "attribute", params, vec![])
+ };
+ let geometry = ref_node("g", "geometry1", "geometry", vec![], vec![
+ ref_node("sphere1", "sphere1", "sphere", vec![("radius", "slider", "0.5")], vec![]),
+ attr("remap1", "Remap", vec![
+ ("from_min", "float", "2.00"),
+ ("from_max", "float", "5.00"),
+ ("to_min", "float", "0.00"),
+ ("to_max", "float", "chf(\"../sphere1/radius\") * 4"),
+ ]),
+ attr("norm1", "Normalize", vec![("to_max", "float", "3.00")]),
+ ref_node("vis1", "vis1", "visualize", vec![("input", "node", "remap1"), ("from", "float", "-1.00"), ("to", "float", "1.00")], vec![]),
+ ref_node("xfer", "xfer", "transfer", vec![("input", "node", "remap1"), ("from", "node", "norm1")], vec![]),
+ ref_node("w", "w", "wrangle", vec![("code", "code", "@a = ch(\"../remap1/from_max\") + ch(\"../norm1/to_max\") + ch(\"../vis1/to\") + ch(\"../xfer/from\");")], vec![]),
+ ref_node("ball", "ball", "sphere", vec![("radius", "slider", "ch(\"../remap1/to_max\") + ch(\"../remap1/from_min\")")], vec![]),
+ ]);
+ let root = ref_node("root", "root", "node", vec![], vec![geometry]);
+ let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 5 };
+ proj.migrate_format();
+ assert_eq!(proj.format, PROJECT_FORMAT);
+ let g = geo(&proj.root);
+ let node = |name: &str| g.children.iter().find(|c| c.name == name).unwrap();
+ let names = |n: &FsNode| n.params.iter().map(|p| p.name.clone()).collect::<Vec<_>>();
+ let param = |n: &str, p: &str| node(n).params.iter().find(|q| q.name == p).unwrap().clone();
+
+ assert_eq!(names(node("remap1")), ["input", "operation", "from", "to", "normalize_to"]);
+ assert_eq!(param("remap1", "from").value(), Some(&ParamValue::Vec2([2.0, 5.0])));
+ let to = param("remap1", "to");
+ assert!(to.is_expr(), "a half that was an expression makes the whole one");
+ assert_eq!(to.text(), "0.00:chf(\"../sphere1/radius\") * 4");
+ assert!(param("remap1", "normalize_to").is_expr(), "To Max is Normalize To's too");
+ assert_eq!(param("norm1", "to").text(), "0.00:3.00", "a missing half is the default");
+ assert_eq!(param("norm1", "normalize_to").value(), Some(&ParamValue::Number(3.0)));
+ assert_eq!(names(node("vis1")), ["input", "manual_range"]);
+ assert_eq!(param("vis1", "manual_range").value(), Some(&ParamValue::Vec2([-1.0, 1.0])));
+ assert_eq!(param("xfer", "from").text(), "norm1", "a wire called from is not a range");
+
+ assert_eq!(
+ param("w", "code").text(),
+ "@a = ch(\"../remap1/from.y\") + ch(\"../norm1/normalize_to\") + ch(\"../vis1/manual_range.y\") + ch(\"../xfer/from\");",
+ "To Max is Normalize To on a Normalize node, and a path not to a range is left alone"
+ );
+ assert_eq!(param("ball", "radius").text(), "ch(\"../remap1/to.y\") + ch(\"../remap1/from.x\")");
+
+ // Each component evaluates on its own, and a path reads one.
+ let mut err = None;
+ let resolved = crate::geometry::resolve_param_refs(&proj.root, node("remap1"), 1, &mut err).unwrap();
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(resolved.params.iter().find(|p| p.name == "to").unwrap().value(), Some(&ParamValue::Vec2([0.0, 2.0])));
+ let resolved = crate::geometry::resolve_param_refs(&proj.root, node("ball"), 1, &mut err).unwrap();
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(resolved.params.iter().find(|p| p.name == "radius").unwrap().value(), Some(&ParamValue::Number(4.0)));
+
+ // Once.
+ let before = serde_json::to_string(&proj).unwrap();
+ proj.migrate_format();
+ assert_eq!(serde_json::to_string(&proj).unwrap(), before);
+
+ // The pane: two sliders over a soft span around the value.
+ let find = |name: &str| templates_root.children.iter().find(|t| t.name == name).unwrap();
+ let mut remap = find("Attribute").clone();
+ remap.id = "r".into();
+ remap.name = "remap1".into();
+ for (p, v) in [("operation", "Remap"), ("from", "2.00:5.00")] {
+ remap.params.iter_mut().find(|q| q.name == p).unwrap().set_text(v.to_string());
+ }
+ let mut state = State::new(false);
+ state.current_dir_mut().children = vec![remap];
+ state.sync_nodes();
+ state.graph_mut().set_selected_node(Some(0));
+ let row = |state: &mut State, key: &str| {
+ state.sync_parameters_pane();
+ state.param_mut().node_params().iter().find(|r| r.0 == key).unwrap_or_else(|| panic!("a {key} row")).2.clone()
+ };
+ let set = |state: &mut State, val: &str| {
+ state.current_dir_mut().children[0].params.iter_mut().find(|p| p.name == "from").unwrap().set_text(val.to_string());
+ };
+ assert_eq!(row(&mut state, "From"), "float2:-10:10:soft");
+ assert_eq!(row(&mut state, "To"), "float2:-10:10:soft");
+ set(&mut state, "300:400");
+ assert_eq!(row(&mut state, "From"), "float2:-1000:1000:soft", "the span follows the value");
+ state.drag_widget = Some(crate::slots::PARAM_IDX);
+ set(&mut state, "3000:4000");
+ assert_eq!(row(&mut state, "From"), "float2:-1000:1000:soft", "but not under the pointer");
+ state.drag_widget = None;
+ assert_eq!(row(&mut state, "From"), "float2:-10000:10000:soft", "and on the release it does");
+ state.current_dir_mut().children[0].params.iter_mut().find(|p| p.name == "from").unwrap().set_expr(true);
+ assert_eq!(row(&mut state, "From"), "text", "an expression is shown as its text");
+ state.current_dir_mut().children[0].params.iter_mut().find(|p| p.name == "from").unwrap().set_expr(false);
+ set(&mut state, "abc");
+ assert_eq!(row(&mut state, "From"), "text", "a text that is not two numbers is a text box, to be put right");
+ }
+
/// The root is the object level: each Geometry node there is shown or
/// not by its own flag, and several draw at once, where inside one the
/// flag is exclusive as it always was. A Geometry node resolves to what
diff --git a/src/param.rs b/src/param.rs
index 750c9be..3382fc0 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -45,6 +45,11 @@ pub enum ParamKind {
Slider,
/// A whole number, stepped.
Spin,
+ /// Two numbers, `x:y` — a range's two ends (a Remap's From and To,
+ /// Visualize's Manual Range). Shown as two sliders over a SOFT range
+ /// that adapts to the value (`app::value_row_span`), since like a
+ /// `float` it holds numbers no fixed range would.
+ Float2,
/// Three numbers, `x:y:z`.
Float3,
/// One of a fixed set of options, stored as the option's text.
@@ -76,7 +81,7 @@ 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", "attribute", "group"];
+ &["text", "float", "slider", "spinbox", "float2", "float3", "choice", "toggle", "button", "code", "node", "attribute", "group"];
/// The kind's name — the type-string head that names it.
pub fn name(self) -> &'static str {
@@ -85,6 +90,7 @@ impl ParamKind {
Self::Float => "float",
Self::Slider => "slider",
Self::Spin => "spinbox",
+ Self::Float2 => "float2",
Self::Float3 => "float3",
Self::Choice => "choice",
Self::Toggle => "toggle",
@@ -104,6 +110,7 @@ impl ParamKind {
"float" => Self::Float,
"slider" => Self::Slider,
"spinbox" => Self::Spin,
+ "float2" => Self::Float2,
"float3" => Self::Float3,
"choice" => Self::Choice,
"toggle" => Self::Toggle,
@@ -124,6 +131,7 @@ pub enum ParamValue {
Number(f32),
/// Spin: a whole number.
Int(i64),
+ Vec2([f32; 2]),
Vec3([f32; 3]),
Bool(bool),
/// The option, spelled as the options list spells it (the text may
@@ -141,6 +149,7 @@ impl ParamValue {
match self {
ParamValue::Number(n) => fmt_num(*n as f64),
ParamValue::Int(i) => i.to_string(),
+ ParamValue::Vec2(v) => v.iter().map(|c| fmt_num(*c as f64)).collect::<Vec<_>>().join(":"),
ParamValue::Vec3(v) => v.iter().map(|c| fmt_num(*c as f64)).collect::<Vec<_>>().join(":"),
ParamValue::Bool(b) => b.to_string(),
ParamValue::Choice(s) | ParamValue::Text(s) => s.clone(),
@@ -351,6 +360,16 @@ pub fn parse_value(kind: ParamKind, text: &str, options: &[String]) -> Result<Pa
Ok(_) => Err(format!("'{text}' is not a whole number")),
Err(_) => Err(format!("'{text}' is not a number")),
},
+ ParamKind::Float2 => {
+ let parts: Vec<&str> = t.split(':').collect();
+ match parts[..] {
+ [x, y] => match (number(x.trim()), number(y.trim())) {
+ (Some(x), Some(y)) => Ok(ParamValue::Vec2([x, y])),
+ _ => Err(format!("'{text}' is not two numbers")),
+ },
+ _ => Err(format!("'{text}' is not x:y")),
+ }
+ }
ParamKind::Float3 => {
let parts: Vec<&str> = t.split(':').collect();
match parts[..] {
@@ -521,7 +540,8 @@ impl ParamDef {
}
/// The range the pane holds this parameter to, as `(min, max, step)`,
- /// for the kinds that have one — Slider, Float3 and Spin. An inline
+ /// for the kinds that have one — Slider, Float3 and Spin (a Float2's
+ /// adapts to its value, and is the pane's to choose). An inline
/// detail (`slider:-2:2`) wins, then the template's `min` / `max`, then
/// the pane's own defaults (0..2, -10..10, 1..10000), which are what a
/// row with none declared clamps to. `param_display` builds the pane's
diff --git a/src/visualizer.rs b/src/visualizer.rs
index 59ff996..a80afb8 100644
--- a/src/visualizer.rs
+++ b/src/visualizer.rs
@@ -55,7 +55,9 @@ pub struct Visualizer {
pub mode: String,
pub ramp: String,
/// `Auto` spreads the ramp over the attribute's range in the scene;
- /// `Manual` over From..To.
+ /// `Manual` over From..To — the node's Manual Range, a float2, kept as
+ /// its two ends because the dialog edits each on a slider of its own
+ /// (it has no float2 control) and the stored form names them so.
pub range: String,
pub from: f32,
pub to: f32,
@@ -114,8 +116,7 @@ impl Visualizer {
p("mode", "choice", self.mode.clone()),
p("ramp", "choice", self.ramp.clone()),
p("range", "choice", self.range.clone()),
- p("from", "float", self.from.to_string()),
- p("to", "float", self.to.to_string()),
+ p("manual_range", "float2", format!("{}:{}", self.from, self.to)),
p("blend", "choice", self.blend.clone()),
p("opacity", "float", self.opacity.to_string()),
p("scale", "float", self.scale.to_string()),