graphic design tool
git clone https://git.lucas.co/cce-designer.git
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(¶ms);
}
+ /// 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(¶ms);
+ // 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(¶ms);
+ // 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(¶ms);
+ // Sync Parameters pane with selected node — through the one
+ // pane-sync path, so textpick rows survive this rebuild.
+ state.sync_parameters_pane();
}