graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(attribute): Remap's From range can come from the input
From Range (Manual / Auto) on the Remap operation: Auto measures the
lowest and highest value of the attribute over the Group at every
evaluation, so the range follows the input; From is hidden while it is
on. Under Manual, a Detect Range button measures once at the current
frame and writes From as an undoable edit.
show_when grows ` || ` (looser than ` && `) so From can be shown for
Clip or a Manual Remap.
fix: a params-pane button dispatched by its display key, which since
parameter names became identifiers was `export` — no menu label — so
the Export node's button did nothing. Buttons now dispatch by name
through run_param_button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 27 +++++++++--
nodes/attribute.json | 20 +++++++-
src/app.rs | 93 +++++++++++++++++++++++++++++++++----
src/geometry.rs | 128 +++++++++++++++++++++++++++++++++++++++++++++++----
src/main.rs | 61 +++++++++++++++++++++++-
5 files changed, 306 insertions(+), 23 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 071bc98..69b8fb2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -973,7 +973,19 @@ old text Center a float3 from the load on. Tests build parameters with
`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,
+ right. **Remap's From can come from the input** (2026-10-06): the
+ **From Range** row (`from_range`, Manual / Auto, Remap only) set to Auto
+ measures the lowest and highest value of the Name over the Group — every
+ component counted, since From is one range applied to every component
+ (`geometry::component_range`) — at every evaluation, so the range follows
+ a simulation's values; From is hidden then, and a flat input maps to To's
+ first end rather than failing. Under Manual a **Detect Range** button
+ (`detect_range`) takes the same measure once (`remap_input_range`, off the
+ input as the scene shows it at the current frame) and writes it into From
+ as an undoable edit. A node from before has no row and is Manual.
+ `a_remap_can_take_its_from_range_from_the_input` and
+ `the_remap_from_range_is_detected_from_the_input` are the tests.
+ 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
@@ -1028,7 +1040,9 @@ the node's unnamed run, so a template names only the runs after the first.
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
+clauses, and ` || ` between alternatives, binding looser than ` && `
+(`operation == Clip || operation == Remap && from_range == Manual`; since
+2026-10-06), compared case-insensitively. Empty means always, which is what most
parameters have. `param_visible` evaluates it and `param_display` filters on
it.
@@ -1066,9 +1080,14 @@ The World Unit is a DECLARATION, not a conversion (see the Guides node), and
export keeps that promise: geometry modelled at 20 units across writes as 20,
and the slicer is told those are millimetres.
-Buttons dispatch through `execute_menu_action` by LABEL, which carries no node
-— `run_export` resolves the node from the current selection, which is sound
+A button row of the params pane dispatches through `State::run_param_button`
+by the parameter's NAME (`export`, `detect_range`), which carries no node —
+`run_export` resolves the node from the current selection, which is sound
because the pressed button can only be on the node the pane is showing.
+Until 2026-10-06 the press went to `execute_menu_action` by the pane's key,
+which since the names became identifiers was `export` — a label nothing
+matches — so the Export button silently did nothing for five days.
+`execute_menu_action("Export")` stays for MCP's `menu_action`.
### Transfer carries groups, and Remesh has a copy of it
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 2389391..ac44329 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -108,12 +108,30 @@
"description": "A point group limiting which points are changed or measured. Empty means every point.",
"group": "where"
},
+ {
+ "name": "from_range",
+ "label": "From Range",
+ "type": "choice:Manual,Auto",
+ "default": "Manual",
+ "show_when": "operation == Remap",
+ "description": "Where Remap's input range comes from: Manual uses the From row; Auto measures the input's lowest and highest value (over every component of the points in Group) each time the node evaluates, so the range follows the input as it changes.",
+ "group": "range"
+ },
+ {
+ "name": "detect_range",
+ "label": "Detect Range",
+ "type": "button",
+ "default": "",
+ "show_when": "operation == Remap && from_range == Manual",
+ "description": "Measures the input's lowest and highest value once, at the current frame, and writes them into From. From then stays as written when the input changes; Auto is the range that follows.",
+ "group": "range"
+ },
{
"name": "from",
"label": "From",
"type": "float2",
"default": "0.00:1.00",
- "show_when": "operation == Remap|Clip",
+ "show_when": "operation == Clip || operation == Remap && from_range == Manual",
"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"
},
diff --git a/src/app.rs b/src/app.rs
index 717fa8b..f2a3f72 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -99,12 +99,16 @@ pub fn shellexpand_home(path: &str) -> String {
/// 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.
+///
+/// ` || ` joins alternatives and binds looser than ` && `, so
+/// `operation == Clip || operation == Remap && from_range == Manual` is
+/// Clip, or a Manual Remap.
pub fn param_visible(params: &[ParamDef], cond: &str) -> bool {
let cond = cond.trim();
if cond.is_empty() {
return true;
}
- cond.split("&&").all(|clause| {
+ cond.split("||").any(|alt| alt.split("&&").all(|clause| {
let clause = clause.trim();
let (name, wanted, negated) = match clause.split_once("!=") {
Some((n, v)) => (n.trim(), v.trim(), true),
@@ -122,7 +126,7 @@ pub fn param_visible(params: &[ParamDef], cond: &str) -> bool {
.split('|')
.any(|w| w.trim().eq_ignore_ascii_case(sibling.text().trim()));
matches != negated
- })
+ }))
}
static NODE_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -4631,7 +4635,7 @@ impl State {
p.set_expr(true);
}
if p.kind() == ParamKind::Button && p.text() == "clicked" {
- triggered_buttons.push(p.name.clone());
+ triggered_buttons.push((p.name.clone(), p.shown_name().to_string()));
p.set_text("".to_string());
}
if p.kind() == ParamKind::Toggle {
@@ -4696,7 +4700,7 @@ impl State {
if !triggered_buttons.is_empty() || !display_resets.is_empty() || !rejected.is_empty() {
let mut disp_params = self.param().node_params();
- for btn_name in triggered_buttons.iter().chain(display_resets.iter()) {
+ for btn_name in triggered_buttons.iter().map(|(_, key)| key).chain(display_resets.iter()) {
if let Some(pos) = disp_params.iter().position(|p| p.0 == *btn_name) {
disp_params[pos].1 = if btn_name == "Open" { "- Select -".to_string() } else { "".to_string() };
}
@@ -4721,8 +4725,8 @@ impl State {
self.sync_parameters_pane();
}
- for btn_name in triggered_buttons {
- self.execute_menu_action(&btn_name);
+ for (btn_name, _) in triggered_buttons {
+ self.run_param_button(&btn_name);
}
for pane_name in pane_actions {
self.execute_menu_action(&pane_name);
@@ -4834,11 +4838,82 @@ impl State {
}
}
+ /// A button row of the params pane, pressed. By the parameter's NAME:
+ /// the pane reports its label, and dispatching that — as this did until
+ /// the names became identifiers — sent `export` to `execute_menu_action`,
+ /// which knows no such label, and the Export button did nothing. A
+ /// button carries no node, but the pressed one can only be on the node
+ /// the pane is showing, so the selection is the node.
+ pub fn run_param_button(&mut self, name: &str) {
+ match name {
+ "export" => self.run_export(),
+ "detect_range" => self.detect_remap_range(),
+ _ => {
+ self.execute_menu_action(name);
+ }
+ }
+ }
+
+ /// The Attribute node's Detect Range: its input measured once, at the
+ /// current frame and as the scene shows it, and the lowest and highest
+ /// value of its Name over its Group written into From. An undoable
+ /// edit; From then stays as written, where From Range Auto follows the
+ /// input.
+ pub fn detect_remap_range(&mut self) {
+ let Some(slot) = self.param_editor_selected() else { return };
+ let Some(node) = self.param_editor_dir().children.get(slot).filter(|c| c.node_type == "attribute") else {
+ return;
+ };
+ let node = node.clone();
+ let (frame, start) = (self.sim_frame(), self.sim_start_frame());
+ let mut err = None;
+ // Name and Group may be expressions; read them as they evaluate.
+ let resolved = crate::geometry::resolve_param_refs(&self.fs_root, &node, frame, &mut err).unwrap_or_else(|| node.clone());
+ let Some(input) = crate::geometry::param_node(&self.fs_root, &node, "input") else {
+ self.update_status_text(&format!("{}: Detect Range needs an input", node.name));
+ return;
+ };
+ let mut sim_cache = std::mem::take(&mut self.sim_cache);
+ let geom = {
+ let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
+ crate::geometry::node_geometry_as_shown(&self.fs_root, input, &mut err, &mut sim)
+ };
+ self.sim_cache = sim_cache;
+ let Some(geom) = geom else {
+ self.update_status_text(&format!("{}: Detect Range: the input has no geometry", node.name));
+ return;
+ };
+ let [lo, hi] = match crate::geometry::remap_input_range(&geom, &resolved) {
+ Ok(r) => r,
+ Err(why) => {
+ self.update_status_text(&format!("{}: Detect Range: {why}", node.name));
+ return;
+ }
+ };
+ let dir = self.param_editor_dir_mut();
+ let Some(p) = dir.children[slot].params.iter_mut().find(|p| p.name == "from") else { return };
+ let was = p.clone();
+ p.set_value(ParamValue::Vec2([lo, hi]));
+ p.set_expr(false);
+ let text = p.text().to_string();
+ if !crate::edit_history::same(&was, p) {
+ let before = crate::edit_history::ParamSnapshot {
+ node_id: node.id.clone(),
+ params: vec![was],
+ what: "Detect Range".to_string(),
+ };
+ self.record_params(before, false);
+ }
+ self.sync_nodes();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ self.update_status_text(&format!("{}: From set to {}", node.name, text.replace(':', " .. ")));
+ }
+
pub fn execute_menu_action(&mut self, label: &str) -> bool {
match label {
- // An Export node's button. Buttons dispatch by LABEL, which has no
- // node attached to it — but the pressed button can only be on the
- // node the pane is showing, so the selection is the node.
+ // An Export node's button, reached by its label from MCP's
+ // `menu_action`; the pane's press goes through `run_param_button`.
"Export" => {
self.run_export();
}
diff --git a/src/geometry.rs b/src/geometry.rs
index 4a0d6b9..0f92fa3 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -5110,16 +5110,32 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
// Fit a range onto another range, optionally through a clamp. The
// workhorse: a simulation attribute measured by Analysis is almost
// always remapped before anything reads it.
+ //
+ // From Range Auto measures From off the input at every evaluation,
+ // so the range follows a simulation whose values move every frame.
+ // A Manual From is the row, which the Detect Range button can fill
+ // once from the same measure (`remap_input_range`).
"remap" => {
- let [f0, f1] = node_param_vec2(target, "from", [0.0, 1.0]);
+ let auto = node_param_str(target, "from_range", "Manual").trim().eq_ignore_ascii_case("auto");
let [t0, t1] = node_param_vec2(target, "to", [0.0, 1.0]);
- let span = f1 - f0;
- if span.abs() < 1e-9 {
- fail = format!("From's two ends are both {}", f0);
- } else {
- edit_components(geom, &name, &affected, |v| {
- t0 + (v - f0) / span * (t1 - t0)
- });
+ let from = if auto { component_range(geom, &name, &affected) } else { Some(node_param_vec2(target, "from", [0.0, 1.0])) };
+ match from {
+ None => fail = format!("From Range is Auto and '{}' has no values to measure", name),
+ Some([f0, f1]) => {
+ let span = f1 - f0;
+ if span.abs() >= 1e-9 {
+ edit_components(geom, &name, &affected, |v| t0 + (v - f0) / span * (t1 - t0));
+ } else if auto {
+ // Every value is the same: each is the lowest, and
+ // maps to To's first end. Not an error, since a
+ // measured attribute is often flat on a solve's
+ // first frame and the status line would say so on
+ // every evaluation until it moved.
+ edit_components(geom, &name, &affected, |_| t0);
+ } else {
+ fail = format!("From's two ends are both {}", f0);
+ }
+ }
}
}
// Clamp into a range. From names the bounds, so Remap and Clip read
@@ -5307,6 +5323,41 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
}
}
+/// The lowest and highest value of `name` over `points`, every component
+/// counted — one range, as Remap's From is one range applied alike to every
+/// component. `None` when the attribute is not there or nothing is measured.
+fn component_range(geom: &Detail, name: &str, points: &[usize]) -> Option<[f32; 2]> {
+ geom.points().get(name)?;
+ let mut range: Option<[f32; 2]> = None;
+ for &p in points {
+ let Some(v) = geom.points().value(name, p) else { continue };
+ for c in attrib_components(v).into_iter().filter(|c| c.is_finite()) {
+ range = Some(match range {
+ None => [c, c],
+ Some([lo, hi]) => [lo.min(c), hi.max(c)],
+ });
+ }
+ }
+ range
+}
+
+/// What an Attribute node's Remap would measure as its From range off
+/// `input` — its Name over the points in its Group — for the Detect Range
+/// button, which writes it into From once. The same measure From Range
+/// Auto takes at every evaluation.
+pub fn remap_input_range(input: &Detail, target: &FsNode) -> Result<[f32; 2], String> {
+ let name = node_param_str(target, "attribute_name", "attr1").trim().to_string();
+ if !input.points().has(&name) {
+ return Err(format!("'{}' is not a point attribute of the input", name));
+ }
+ let group = node_param_str(target, "group", "");
+ let group = group.trim();
+ let affected: Vec<usize> = (0..input.num_points())
+ .filter(|&p| group.is_empty() || input.points().in_group(group, p))
+ .collect();
+ component_range(input, &name, &affected).ok_or_else(|| format!("'{}' has no values to measure", name))
+}
+
/// Apply a scalar function to every component of `name` on the given points.
///
/// One place for the "same arithmetic, any width" shape that Remap, Clip and
@@ -7452,6 +7503,67 @@ mod simnet_tests {
assert!(err.is_some(), "a zero-width source range must be reported");
}
+ /// From Range Auto measures From off the input — every component of
+ /// the points in Group — at each evaluation, so the range follows the
+ /// input; Detect Range's measure is the same one. A flat input maps
+ /// to To's first end rather than failing.
+ #[test]
+ fn a_remap_can_take_its_from_range_from_the_input() {
+ let before = ramped_mass();
+ let n = before.num_points();
+ let last = (n - 1) as f32;
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+ let auto = [("operation", "Remap"), ("from_range", "Auto"), ("from", "0.00:1.00"), ("to", "10.00:20.00")];
+
+ let (out, err) = run_attr(&before, &auto);
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(mass(&out, 0), 10.0, "the input's lowest maps to To's first end");
+ assert!((mass(&out, n - 1) - 20.0).abs() < 1e-4, "its highest to To's second, whatever From says");
+
+ // The range follows the input: scaled up, it still lands on 10..20.
+ let mut doubled = before.clone();
+ for p in 0..n {
+ doubled.points_mut().set_value("mass", p, AttribValue::Float(p as f32 * 2.0 + 5.0)).unwrap();
+ }
+ let (out, _) = run_attr(&doubled, &auto);
+ assert_eq!(mass(&out, 0), 10.0);
+ assert!((mass(&out, n - 1) - 20.0).abs() < 1e-4);
+
+ // Manual keeps reading the row.
+ let (out, _) = run_attr(&before, &[("operation", "Remap"), ("from_range", "Manual"), ("from", "0.00:1.00"), ("to", "10.00:20.00")]);
+ assert!((mass(&out, n - 1) - (10.0 + last * 10.0)).abs() < 1e-3);
+
+ // Measured over the Group only.
+ let mut grouped = before.clone();
+ for p in 2..5 {
+ grouped.points_mut().set_in_group("g", p, true);
+ }
+ let mut ps = auto.to_vec();
+ ps.push(("group", "g"));
+ let (out, _) = run_attr(&grouped, &ps);
+ assert_eq!((mass(&out, 2), mass(&out, 4)), (10.0, 20.0));
+ assert_eq!(mass(&out, 0), 0.0, "outside the Group nothing moves");
+ let measured = node("id-a", "A", "attribute", vec![param("attribute_name", "mass"), param("group", "g")], vec![]);
+ assert_eq!(remap_input_range(&grouped, &measured), Ok([2.0, 4.0]), "Detect Range measures what Auto does");
+
+ // A flat input is not an error under Auto; it is under Manual.
+ let mut flat = before.clone();
+ for p in 0..n {
+ flat.points_mut().set_value("mass", p, AttribValue::Float(3.0)).unwrap();
+ }
+ let (out, err) = run_attr(&flat, &auto);
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(mass(&out, n - 1), 10.0);
+ let (_, err) = run_attr(&flat, &[("operation", "Remap"), ("from", "3.00:3.00")]);
+ assert!(err.is_some());
+
+ // An attribute that is not there is said, by both.
+ let (_, err) = run_attr(&before, &[("attribute_name", "missing"), ("operation", "Remap"), ("from_range", "Auto")]);
+ assert!(err.is_some_and(|e| e.contains("no values to measure")));
+ let missing = node("id-b", "B", "attribute", vec![param("attribute_name", "missing")], vec![]);
+ assert!(remap_input_range(&before, &missing).is_err());
+ }
+
/// Value From Attribute: Create and Modify take each point's own value
/// of From Attribute in place of the constant — copied when as wide,
/// one number spread, otherwise as many components as fit and the rest
diff --git a/src/main.rs b/src/main.rs
index 08ab677..994cd1a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6823,7 +6823,7 @@ mod tests {
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("&&") {
+ for clause in p.show_when.split("||").flat_map(|alt| alt.split("&&")) {
let clause = clause.trim();
if clause.is_empty() {
continue;
@@ -7725,6 +7725,65 @@ mod tests {
assert!(!state.history_step(true), "New Project kept the old project's undo");
}
+ /// The Attribute node's Remap takes its From range from the input two
+ /// ways: From Range Auto (the row hides, the measure is taken at every
+ /// evaluation) and the Detect Range button, pressed in the params pane,
+ /// which measures once and writes From as an undoable edit.
+ #[test]
+ fn the_remap_from_range_is_detected_from_the_input() {
+ use crate::app::{param_visible, McpAction};
+ let mut state = State::new(false);
+ let mut redraw = false;
+ state
+ .apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
+ .unwrap();
+ let slot = state.current_dir().children.iter().position(|c| c.node_type == "attribute").unwrap();
+ for (name, value) in [("input", "sphere1"), ("attribute_name", "N"), ("operation", "Remap")] {
+ state
+ .apply_action(McpAction::SetParam { slot, name: name.into(), value: value.into() }, &mut redraw)
+ .unwrap();
+ }
+ state.apply_action(McpAction::Select { slot }, &mut redraw).unwrap();
+ let shown = |state: &State, name: &str| {
+ let params = &state.current_dir().children[slot].params;
+ param_visible(params, ¶ms.iter().find(|p| p.name == name).unwrap().show_when)
+ };
+ let from = |state: &State| state.current_dir().children[slot].params.iter().find(|p| p.name == "from").unwrap().text().to_string();
+ assert!(shown(&state, "from_range") && shown(&state, "detect_range") && shown(&state, "from"));
+ assert_eq!(from(&state), "0.00:1.00");
+
+ // The press, as the pane reports it.
+ let rows: Vec<(String, String, String)> = state
+ .param()
+ .node_params()
+ .into_iter()
+ .map(|(n, v, t)| if n == "Detect Range" { (n, "clicked".to_string(), t) } else { (n, v, t) })
+ .collect();
+ assert!(rows.iter().any(|r| r.0 == "Detect Range"), "the pane shows the button");
+ state.param_mut().set_display_params(&rows);
+ state.sync_parameters_to_project();
+ let [lo, hi] = crate::param::ParamDef::new("from", "float2", from(&state))
+ .value()
+ .and_then(|v| match v {
+ crate::app::ParamValue::Vec2(v) => Some(*v),
+ _ => None,
+ })
+ .expect("From holds two numbers");
+ assert!(lo < -0.9 && hi > 0.9, "a sphere's normals span -1..1, not {lo}..{hi}");
+ assert!(state.last_status_text.contains("From set to"), "{}", state.last_status_text);
+
+ // Undone, From is the row it was.
+ state.edit_history.break_group();
+ assert!(state.history_step(true));
+ assert_eq!(from(&state), "0.00:1.00");
+
+ // Auto hides From and the button; Clip shows From again.
+ state.apply_action(McpAction::SetParam { slot, name: "from_range".into(), value: "Auto".into() }, &mut redraw).unwrap();
+ assert!(!shown(&state, "from") && !shown(&state, "detect_range"));
+ state.apply_action(McpAction::SetParam { slot, name: "operation".into(), value: "Clip".into() }, &mut redraw).unwrap();
+ assert!(shown(&state, "from") && !shown(&state, "from_range"));
+ }
+
/// An edit to a parameter can be taken back however it was made: a row
/// of the pane, `set_param`, the row menu. A drag writes back on every
/// motion and is one step; a press between two drags makes them two.