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

commit5cd22b0ab25f0ca787fe17fd936817724d0fdfa7
parent540939e515
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 12:31
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, &params.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.