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

commit30b1cb881be8b11c4a05cbefc98971f21136ed72
parentd1a0282c06
authorLucas Galante <[email protected]>
date2026-08-25 12:16
feat: attribute/group pickers on the param pane (textpick rows)

Selecting a Group or Attribute node upgrades its group/attribute-name text
rows to cce-ui textpick rows: the candidates are read off the node's INPUT
geometry — group names from group: tags, attribute names plus the Pos/Col
built-ins — cached on (input name, geometry version) so pane syncs don't
re-run kernels. No input, failed eval, or an empty list degrades the row
to plain text.

The camera-orbit and pane-toggle paths rebuilt the pane from raw
param_display, silently reverting textpick rows to text mid-interaction
(destroying an open picker); all three now route through
sync_parameters_pane — the one pane-sync path.

 src/app.rs    | 125 +++++++++++++++++++++++++++++++++++++++++++++++-----------
 src/main.rs   |  63 +++++++++++++++++++++++++++++
 src/window.rs |  17 ++------
 3 files changed, 170 insertions(+), 35 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index c88fc47..f971204 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1099,6 +1099,11 @@ pub struct State {
     /// sRGB color of the meta "Point Markers" overlay — the Guides subnet's
     /// "Point Marker Color" control (stored there as hex, like Grid Color).
     pub meta_marker_color: [f32; 3],
+    /// The param pane's completion lists — (input node name, geometry
+    /// version) → (group names, attribute names) read off that input's
+    /// evaluated geometry, feeding the textpick rows on group/attribute
+    /// params. One entry: the selected node's input.
+    pub pick_cache: Option<((String, u64), (Vec<String>, Vec<String>))>,
     /// The raster scene's model-view-projection and the viewport pane rect in
     /// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
     pub last_scene_mvp: Option<Mat4>,
@@ -2075,9 +2080,100 @@ impl State {
         } else {
             vec![]
         };
+        let params = self.add_pick_lists(params);
         self.param_mut().set_display_params(&params);
     }
 
+    /// Upgrade a selected group/attribute node's group- and attribute-name
+    /// text rows to `textpick` rows carrying the candidates read off the
+    /// node's INPUT geometry (the Houdini attribute/group chooser). Rows stay
+    /// plain text when there is no input, evaluation fails, or the list is
+    /// empty — the picker degrades to nothing rather than an empty menu.
+    fn add_pick_lists(
+        &mut self,
+        mut params: Vec<(String, String, String)>,
+    ) -> Vec<(String, String, String)> {
+        let (node_type, input_name) = {
+            if self.is_detached_network {
+                return params;
+            }
+            let Some(slot) = self.graph().selected_node() else { return params };
+            let dir = self.current_dir();
+            let Some(node) = dir.children.get(slot) else { return params };
+            let nt = node.node_type.to_lowercase();
+            if nt != "attribute" && nt != "group" {
+                return params;
+            }
+            (nt, node_param_str(node, "Input", ""))
+        };
+        if input_name.is_empty() {
+            return params;
+        }
+        let (groups, attrs) = self.input_pick_lists(&input_name);
+        for row in params.iter_mut() {
+            let list = match (node_type.as_str(), row.0.as_str()) {
+                ("attribute", "Attribute Name") => &attrs,
+                ("attribute", "Group") => &groups,
+                ("group", "Group Name") => &groups,
+                _ => continue,
+            };
+            if row.2 == "text" && !list.is_empty() {
+                row.2 = format!("textpick:{}", list.join(","));
+            }
+        }
+        params
+    }
+
+    /// The (groups, attributes) present on `input_name`'s evaluated geometry,
+    /// cached on (name, geometry version). Attribute names get the Pos/Col
+    /// built-ins appended (the Attribute node can Modify them); names carrying
+    /// a comma are dropped — they cannot ride the type spec-string.
+    fn input_pick_lists(&mut self, input_name: &str) -> (Vec<String>, Vec<String>) {
+        let key = (input_name.to_string(), self.rt_geometry_version);
+        if let Some((k, lists)) = &self.pick_cache {
+            if *k == key {
+                return lists.clone();
+            }
+        }
+        let (frame, start) = (self.sim_frame(), self.sim_start_frame());
+        let mut groups = std::collections::BTreeSet::new();
+        let mut attrs = std::collections::BTreeSet::new();
+        let mut sim_cache = std::mem::take(&mut self.sim_cache);
+        {
+            let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
+            if let Some(input_node) = find_node_by_name(&self.fs_root, input_name) {
+                let mut visited = Vec::new();
+                let mut err = None;
+                if let Some(geom) = generate_single_node_geometry_with_errors(
+                    &self.fs_root,
+                    input_node,
+                    &mut visited,
+                    &mut err,
+                    &mut sim,
+                ) {
+                    for v in &geom.vertices {
+                        for k in v.attributes.keys() {
+                            if let Some(g) = k.strip_prefix("group:") {
+                                if !g.contains(',') {
+                                    groups.insert(g.to_string());
+                                }
+                            } else if !k.contains(',') {
+                                attrs.insert(k.clone());
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        self.sim_cache = sim_cache;
+        let mut attrs: Vec<String> = attrs.into_iter().collect();
+        attrs.push("Pos".to_string());
+        attrs.push("Col".to_string());
+        let lists = (groups.into_iter().collect(), attrs);
+        self.pick_cache = Some((key, lists.clone()));
+        lists
+    }
+
     pub fn current_path_names(&self) -> Vec<String> {
         let mut node = &self.fs_root;
         let mut names = Vec::new();
@@ -2652,17 +2748,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 p.default = format!("{:.2}:{:.2}:{:.2}", rx, ry, rz);
 
                 self.sync_nodes();
-                let params = if !self.is_detached_network {
-                    self.graph().selected_node().and_then(|sel_idx| {
-                        let dir = self.current_dir();
-                        if sel_idx < dir.children.len() {
-                            Some(param_display(&dir.children[sel_idx].params))
-                        } else { None }
-                    }).unwrap_or_default()
-                } else {
-                    vec![]
-                };
-                self.param_mut().set_display_params(&params);
+                // Through the one pane-sync path, so the pick-list upgrade
+                // (textpick rows) survives this rebuild.
+                self.sync_parameters_pane();
 
                 return true;
             }
@@ -2680,17 +2768,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             if let Some(p) = node.params.iter_mut().find(|p| p.name == "Rotation") {
                 p.default = "0.00:0.00:0.00".to_string();
                 self.sync_nodes();
-                let params = if !self.is_detached_network {
-                    self.graph().selected_node().and_then(|sel_idx| {
-                        let dir = self.current_dir();
-                        if sel_idx < dir.children.len() {
-                            Some(param_display(&dir.children[sel_idx].params))
-                        } else { None }
-                    }).unwrap_or_default()
-                } else {
-                    vec![]
-                };
-                self.param_mut().set_display_params(&params);
+                // Through the one pane-sync path, so the pick-list upgrade
+                // (textpick rows) survives this rebuild.
+                self.sync_parameters_pane();
                 return true;
             }
         }
@@ -3282,6 +3362,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             meta_normal_count: 0,
             meta_marker_size: 0.02,
             meta_marker_color: [0.85, 0.85, 1.0],
+            pick_cache: None,
             last_scene_mvp: None,
             last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
             last_viewport_rt_mode: false,
diff --git a/src/main.rs b/src/main.rs
index 55106ad..5246276 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1166,6 +1166,69 @@ mod tests {
         assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
     }
 
+    /// The param pane's attribute/group pickers: selecting an Attribute node
+    /// upgrades its name/group text rows to textpick rows whose candidates
+    /// are read off the INPUT geometry (groups from group: tags, attributes
+    /// plus the Pos/Col built-ins); a Group node with a group-less input
+    /// keeps a plain text row (no empty menu).
+    #[test]
+    fn test_param_pane_pick_lists() {
+        let templates_root = crate::app::load_fs_tree();
+        let find = |name: &str| {
+            templates_root.children.iter().find(|t| t.name == name).unwrap()
+        };
+        let instance = |template: &FsNode, id: &str, name: &str, params: &[(&str, &str)]| {
+            let mut inst = template.clone();
+            inst.id = id.to_string();
+            inst.name = name.to_string();
+            for child in &mut inst.children {
+                child.id = format!("{}_{}", inst.id, child.name);
+            }
+            for (pname, val) in params {
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
+                    val.to_string();
+            }
+            inst
+        };
+
+        let mut state = State::new(false);
+        state.fs_root.children = vec![
+            instance(find("Sphere"), "s", "Sphere 1", &[]),
+            instance(find("Group"), "g", "Group 1", &[
+                ("Input", "Sphere 1"),
+                ("Center", "0.00:0.80:0.00"),
+                ("Size", "2.00:0.50:2.00"),
+            ]),
+            instance(find("Attribute"), "a", "Attr 1", &[("Input", "Group 1")]),
+        ];
+        state.sync_nodes();
+
+        // The Attribute node: attrs from the input (+Pos/Col), groups from
+        // the Group node it consumes.
+        state.graph_mut().set_selected_node(Some(2));
+        state.sync_parameters_pane();
+        let rows = state.param_mut().node_params();
+        let row = |name: &str| {
+            rows.iter().find(|r| r.0 == name).unwrap_or_else(|| panic!("row {name}")).2.clone()
+        };
+        let attr_ty = row("Attribute Name");
+        assert!(attr_ty.starts_with("textpick:"), "got {attr_ty}");
+        for expected in ["Norm", "UV", "Pos", "Col"] {
+            assert!(attr_ty.contains(expected), "{expected} missing from {attr_ty}");
+        }
+        assert_eq!(row("Group"), "textpick:group1");
+        // The Input row stays plain text.
+        assert_eq!(row("Input"), "text");
+
+        // The Group node's own Group Name: its input (the sphere) carries no
+        // groups, so the row degrades to plain text.
+        state.graph_mut().set_selected_node(Some(1));
+        state.sync_parameters_pane();
+        let rows = state.param_mut().node_params();
+        let gn = rows.iter().find(|r| r.0 == "Group Name").unwrap();
+        assert_eq!(gn.2, "text");
+    }
+
     /// The per-node meta (preferences) child: ensure adds it to every
     /// geometry-producing node (idempotently, restoring stripped params),
     /// leaves cameras and the Session tree alone, evaluation ignores it,
diff --git a/src/window.rs b/src/window.rs
index 9e6954c..cd0c288 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -10,7 +10,7 @@ use std::path::Path;
 
 use cce_ui::widget::WidgetHost;
 use crate::shortcut::Action;
-use crate::app::{State, CustomEvent, McpAction, TouchPhase, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
+use crate::app::{State, CustomEvent, McpAction, TouchPhase, get_next_visible_pane, Project, ProjectViewState, ParamDef};
 use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT};
 
 #[derive(Debug, Clone, Copy)]
@@ -544,18 +544,9 @@ impl State {
 
                 state.sync_nodes();
 
-                // Sync Parameters pane with selected node
-                let params = if !state.is_detached_network {
-                    state.graph().selected_node().and_then(|sel_idx| {
-                        let dir = state.current_dir();
-                        if sel_idx < dir.children.len() {
-                            Some(param_display(&dir.children[sel_idx].params))
-                        } else { None }
-                    }).unwrap_or_default()
-                } else {
-                    vec![]
-                };
-                state.param_mut().set_display_params(&params);
+                // Sync Parameters pane with selected node — through the one
+                // pane-sync path, so textpick rows survive this rebuild.
+                state.sync_parameters_pane();
 
             }