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

commit33047a0ddd79e6c551e88c7adea74a2dbf0d1db4
parent81bcb09a0f
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 00:00
feat: the trackball on float3 rows — on the pull node's vector by default

A float3 row can show cce-ui's trackball beside its three sliders: the
ball turns the vector's direction and keeps its length. It is on by
default where the three numbers are a vector — the Attribute node's
Value aimed at Pos or a Float3 attribute, the pull node's — and off for
Col and for every float3 parameter (a Center, a Size); the row menu's
Show Trackball / Hide Trackball chooses either way.

The choice is ParamDef::view, the one piece of UI metadata the instance
owns: the template merge fills it only where the instance has not
chosen, and it is serialized only when set, so a file that never used it
is byte-identical. The row menu reads such a row as `Control: trackball
and sliders`, `Type: float3`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md    | 15 ++++++++++
 src/app.rs   | 53 +++++++++++++++++++++++++++++++----
 src/main.rs  | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/param.rs | 31 +++++++++++++++++++++
 4 files changed, 183 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a711454..7040a01 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -581,6 +581,21 @@ old text Center a float3 from the load on. Tests build parameters with
   a `float3`, the numbers phase 1 missed.
   `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
+  ball beside the three sliders, which turns the vector's DIRECTION and
+  keeps its length, where the sliders set a component at a time. The
+  display type is `float3:lo:hi:trackball` (`float3_row`). It is on by
+  default where the three numbers are a vector — the Attribute node's
+  Value aimed at Pos or a Float3 attribute, the pull node's — and off for
+  Col (a colour points nowhere) and for every `float3` parameter (a
+  Center, a Size); the row menu's **Show Trackball** / **Hide Trackball**
+  chooses either way. The choice is `ParamDef::view` (`trackball`,
+  `sliders`, empty for the default), the one piece of UI metadata the
+  INSTANCE owns: `adopt_ui_from` fills it from the template only where the
+  instance has not chosen, and it is serialized only when set, so a file
+  that never used it is byte-identical. The row menu reads such a row as
+  `Control: trackball and sliders`, `Type: float3`. With the ball on the
+  row writes three decimals.
 - **`float` is a number with no range.** The pane's slider and float3 rows
   hold a FRACTION of their range and clamp to it, so a threshold, a scale
   factor or a manual ramp end cannot be a slider without losing values
diff --git a/src/app.rs b/src/app.rs
index a993d60..76b03a5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -383,6 +383,10 @@ pub enum ParamMenuAction {
     EditExpression,
     /// Houdini's Delete Channels: the expression's CURRENT value, as a value.
     DeleteExpression,
+    /// Show a float3 row's trackball beside its sliders, or the sliders
+    /// alone — the parameter's `view`, kept with the instance.
+    ShowTrackball,
+    HideTrackball,
     Separator,
     /// A header row that reads something out — the control's kind, the
     /// value's type — and runs nothing.
@@ -565,6 +569,7 @@ pub struct NodeTemplate {
 pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static str) {
     let head = shown.split(':').next().unwrap_or("");
     let control = match head {
+        "float3" if shown.split(':').nth(3) == Some("trackball") => "trackball and sliders",
         "slider" | "float3" => "slider",
         "spinbox" => "spinbox",
         "choice" => "dropdown",
@@ -632,6 +637,12 @@ pub struct PickLists {
     pub widths: Vec<(String, usize)>,
 }
 
+/// A float3 row's display type: `float3:lo:hi`, with `:trackball` for the
+/// ball beside the sliders (cce-ui's `Float3::set_trackball`).
+pub fn float3_row(min: f32, max: f32, trackball: bool) -> String {
+    format!("float3:{}:{}{}", min, max, if trackball { ":trackball" } else { "" })
+}
+
 pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
     // Rows whose condition does not hold are not shown. Write-back resolves a
     // row by its display key rather than by position, so a hidden parameter
@@ -661,8 +672,10 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
             let (min, max, _) = p.range().expect("a slider has a range");
             format!("slider:{}:{}", min, max)
         } else if p.ty() == "float3" {
+            // A position or a size is set a component at a time: the ball
+            // is there for the asking (the row menu), not by default.
             let (min, max, _) = p.range().expect("a float3 has a range");
-            format!("float3:{}:{}", min, max)
+            float3_row(min, max, p.wants_trackball(false))
         } else if p.ty() == "spinbox" {
             let (min, max, step) = p.range().expect("a spinbox has a range");
             format!("spinbox:{}:{}:{}", min as i32, max as i32, step.unwrap_or(1.0) as i32)
@@ -4067,7 +4080,7 @@ impl State {
                 row.2 = format!("textpick:{}", list.join(","));
             }
         }
-        if let Some((key, target)) = value_row {
+        if let Some((key, target, ball)) = value_row {
             let width = match target {
                 ValueTarget::Width(w) => w,
                 ValueTarget::Named(name) => lists
@@ -4078,7 +4091,7 @@ impl State {
             };
             if width == 3 {
                 if let Some(row) = params.iter_mut().find(|r| r.0 == key && r.2 == "text") {
-                    row.2 = format!("float3:{}:{}", VALUE_ROW_RANGE.0, VALUE_ROW_RANGE.1);
+                    row.2 = float3_row(VALUE_ROW_RANGE.0, VALUE_ROW_RANGE.1, ball);
                 }
             }
         }
@@ -4090,7 +4103,12 @@ impl State {
     /// off the input for — when the row is one the float3 presentation can
     /// take: a plain (non-expression) text holding three numbers. `None`
     /// for any other node, operation, or text.
-    fn attribute_value_target(node: &FsNode) -> Option<(String, ValueTarget)> {
+    ///
+    /// The third member is whether the row shows the TRACKBALL: the
+    /// parameter's own choice (`ParamDef::view`), else on for a vector —
+    /// a displacement of Pos, a Float3 attribute — and off for Col, whose
+    /// three numbers are a colour and point nowhere.
+    fn attribute_value_target(node: &FsNode) -> Option<(String, ValueTarget, bool)> {
         if !node.node_type.eq_ignore_ascii_case("attribute") {
             return None;
         }
@@ -4111,6 +4129,7 @@ impl State {
         let key = if p.label.is_empty() { p.name.clone() } else { p.label.clone() };
         let name = node_param_str(node, "Attribute Name", "");
         let name = name.trim().to_string();
+        let ball = p.wants_trackball(!name.eq_ignore_ascii_case("Col"));
         let target = match node_param_str(node, "Operation", "Create").to_lowercase().as_str() {
             "create" => ValueTarget::Width(match node_param_str(node, "Type", "Float").to_lowercase().as_str() {
                 "float3" => 3,
@@ -4122,7 +4141,7 @@ impl State {
             "modify" => ValueTarget::Named(name),
             _ => return None,
         };
-        Some((key, target))
+        Some((key, target, ball))
     }
 
     /// The (groups, attributes) present on the evaluated geometry of node
@@ -4769,6 +4788,17 @@ impl State {
         }
         options.push("-".to_string());
         actions.push(ParamMenuAction::Separator);
+        // A row shown as a float3 can carry the trackball; the entry names
+        // what picking it does.
+        if shown.starts_with("float3") {
+            if shown.split(':').nth(3) == Some("trackball") {
+                options.push("Hide Trackball".to_string());
+                actions.push(ParamMenuAction::HideTrackball);
+            } else {
+                options.push("Show Trackball".to_string());
+                actions.push(ParamMenuAction::ShowTrackball);
+            }
+        }
         if is_expr {
             options.push("Delete Expression".to_string());
             actions.push(ParamMenuAction::DeleteExpression);
@@ -4884,6 +4914,19 @@ impl State {
                 }
                 self.update_status_text(&format!("{pname} = {value}"));
             }
+            ParamMenuAction::ShowTrackball | ParamMenuAction::HideTrackball => {
+                let show = action == ParamMenuAction::ShowTrackball;
+                if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
+                    .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
+                {
+                    p.view = if show { "trackball" } else { "sliders" }.to_string();
+                }
+                self.update_status_text(&if show {
+                    format!("{pname}: drag the ball to turn the vector; its length is kept.")
+                } else {
+                    format!("{pname}: sliders only.")
+                });
+            }
             ParamMenuAction::EditExpression => {
                 if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
                     .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
diff --git a/src/main.rs b/src/main.rs
index 9abdc05..a91ea21 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7882,7 +7882,7 @@ mod tests {
         }
         show(&mut state, pull);
         let (rows, _, h) = state.param_menu_rows(pull, "Value");
-        let want: Vec<String> = ["Name: Value", "Control: slider", "Type: float3", "Expression: false"].iter().map(|s| s.to_string()).collect();
+        let want: Vec<String> = ["Name: Value", "Control: trackball and sliders", "Type: float3", "Expression: false"].iter().map(|s| s.to_string()).collect();
         assert_eq!(&rows[..4], &want[..], "{rows:?}");
         assert!(rows[..h].contains(&"Range: -1000..1000".to_string()), "{rows:?}");
         assert!(rows[..h].iter().all(|r| !r.starts_with("Value:")), "{rows:?}");
@@ -12861,7 +12861,8 @@ mod tests {
             state.sync_parameters_pane();
             state.param_mut().node_params().iter().find(|r| r.0 == "Value").expect("a Value row").2.clone()
         };
-        let wide = format!("float3:{}:{}", crate::app::VALUE_ROW_RANGE.0, crate::app::VALUE_ROW_RANGE.1);
+        // A vector gets the trackball beside its sliders by default.
+        let wide = crate::app::float3_row(crate::app::VALUE_ROW_RANGE.0, crate::app::VALUE_ROW_RANGE.1, true);
         assert_eq!(value_row(&mut state), wide, "Modify on Pos");
         assert!(crate::app::VALUE_ROW_RANGE.0 <= -100.0 && crate::app::VALUE_ROW_RANGE.1 >= 100.0, "a wide range");
 
@@ -12942,4 +12943,90 @@ mod tests {
         assert_eq!((v[0], v[2]), (0.0, 0.0), "X and Z hold: {v:?}");
         assert_ne!(v[1], 0.0, "Y turned, and the node's Value followed: {v:?}");
     }
+
+    /// The trackball is a float3 row's second control: on by default where
+    /// the three numbers are a VECTOR (the pull node's Value aimed at Pos),
+    /// off where they are a colour or a position, and the row menu's Show /
+    /// Hide Trackball chooses either way. The choice is the instance's — it
+    /// rides the file, only when made — and dragging the ball turns the
+    /// node's vector, keeping its length.
+    #[test]
+    fn the_trackball_turns_the_pull_nodes_vector() {
+        use crate::app::ParamMenuAction as A;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton, ParametersBg};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.param_editor = crate::slots::CONTENT_IDX;
+        let mut redraw = false;
+        state.apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: Some("pull1".into()), x: 5.0, y: 8.0 }, &mut redraw).unwrap();
+        state.apply_action(McpAction::AddNode { template_name: "Group".into(), name: Some("group1".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+        let slot_of = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+        let (pull, group) = (slot_of(&state, "pull1"), slot_of(&state, "group1"));
+        for (name, value) in [("Input", "sphere1"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", "0.00:0.00:0.06")] {
+            state.apply_action(McpAction::SetParam { slot: pull, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+        }
+        let show = |state: &mut State, slot: usize| {
+            state.graph_mut().set_selected_node(Some(slot));
+            state.sync_parameters_pane();
+            state.rebuild_positions();
+            state.apply_layout();
+        };
+        let row = |state: &mut State, name: &str| state.param_mut().node_params().iter().find(|r| r.0 == name).expect("the row").2.clone();
+        let entries = |state: &State, slot: usize, pname: &str| state.param_menu_rows(slot, pname).1;
+        let (lo, hi) = crate::app::VALUE_ROW_RANGE;
+
+        // The pull's Value: a vector, so the ball is there; the menu hides it.
+        show(&mut state, pull);
+        assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, true));
+        assert!(entries(&state, pull, "Value").contains(&A::HideTrackball));
+        let pull_id = state.current_dir().children[pull].id.clone();
+        state.run_param_action(&pull_id, "Value", A::HideTrackball);
+        assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, false));
+        assert!(entries(&state, pull, "Value").contains(&A::ShowTrackball));
+        let saved = serde_json::to_string(&state.current_dir().children[pull]).unwrap();
+        assert!(saved.contains("\"view\":\"sliders\""), "the choice rides the file: {saved}");
+        state.run_param_action(&pull_id, "Value", A::ShowTrackball);
+        assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, true));
+
+        // Aimed at Col the three numbers are a colour: no ball by default.
+        state.apply_action(McpAction::SetParam { slot: pull, name: "Attribute Name".into(), value: "Col".into() }, &mut redraw).unwrap();
+        state.current_dir_mut().children[pull].params.iter_mut().find(|p| p.name == "Value").unwrap().view.clear();
+        show(&mut state, pull);
+        assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, false));
+        state.apply_action(McpAction::SetParam { slot: pull, name: "Attribute Name".into(), value: "Pos".into() }, &mut redraw).unwrap();
+
+        // A position (the Group node's Center): no ball until asked, and a
+        // parameter that never chose writes no `view` at all.
+        show(&mut state, group);
+        assert!(row(&mut state, "Center").starts_with("float3:") && !row(&mut state, "Center").ends_with(":trackball"));
+        let untouched = serde_json::to_string(&state.current_dir().children[group]).unwrap();
+        assert!(!untouched.contains("\"view\""), "{untouched}");
+        let group_id = state.current_dir().children[group].id.clone();
+        state.run_param_action(&group_id, "Center", A::ShowTrackball);
+        assert!(row(&mut state, "Center").ends_with(":trackball"));
+        // A slider row is not a float3: it is offered neither.
+        assert!(!entries(&state, slot_of(&state, "sphere1"), "Radius").iter().any(|a| matches!(a, A::ShowTrackball | A::HideTrackball)));
+
+        // Drag the ball a quarter turn to the right: the pull, pointing at
+        // the viewer, swings onto +X at the length it had.
+        show(&mut state, pull);
+        let (cx, cy, r) = {
+            let pane: &ParametersBg = state.slots.param.inner();
+            pane.float3s.iter().flatten().next().expect("the Value row").ball_circle().expect("its ball")
+        };
+        let at = |x: f32, y: f32| WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } };
+        state.handle_event(&at(cx, cy));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        for i in 1..=20 {
+            state.handle_event(&at(cx + r * std::f32::consts::FRAC_PI_2 * i as f32 / 20.0, cy));
+        }
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        let v: Vec<f32> = state.current_dir().children[pull].params.iter().find(|p| p.name == "Value").unwrap()
+            .text().split(':').map(|c| c.parse().unwrap()).collect();
+        assert!((v[0] - 0.06).abs() < 2e-3 && v[1].abs() < 2e-3 && v[2].abs() < 2e-3, "the pull points along +X: {v:?}");
+    }
 }
diff --git a/src/param.rs b/src/param.rs
index b6ec600..9d75589 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -186,6 +186,16 @@ pub struct ParamDef {
     /// because a template author is describing when a control APPLIES, and
     /// stating that directly is easier to get right than stating its negation.
     pub show_when: String,
+    /// How a float3-valued row is SHOWN, where there is a choice:
+    /// `trackball` for the ball beside the three sliders, `sliders` for
+    /// the sliders alone, empty for the row's default
+    /// ([`Self::wants_trackball`]). The row menu's Show / Hide Trackball
+    /// writes it. It is the one piece of UI metadata the INSTANCE owns —
+    /// a preference about a control, set by the person using it — so the
+    /// template merge fills it only where the instance has not chosen.
+    /// Serialized only when set, so a file that never used it is
+    /// byte-identical to what it was.
+    pub view: String,
 }
 
 /// The file shape of a [`ParamDef`] — the struct as it was before the value
@@ -218,6 +228,9 @@ struct ParamDefRepr {
     /// expression too; Houdini makes the same choice.
     #[serde(default, skip_serializing_if = "std::ops::Not::not")]
     expr: bool,
+    /// [`ParamDef::view`]; absent unless one was chosen.
+    #[serde(default, skip_serializing_if = "String::is_empty")]
+    view: String,
 }
 
 fn default_param_type() -> String {
@@ -238,6 +251,7 @@ impl<'de> Deserialize<'de> for ParamDef {
             max: r.max,
             step: r.step,
             show_when: r.show_when,
+            view: r.view,
         };
         if !r.expr {
             p.reparse();
@@ -259,6 +273,7 @@ impl Serialize for ParamDef {
             step: self.step,
             show_when: self.show_when.clone(),
             expr: self.is_expr(),
+            view: self.view.clone(),
         }
         .serialize(s)
     }
@@ -326,11 +341,22 @@ impl ParamDef {
             max: None,
             step: None,
             show_when: String::new(),
+            view: String::new(),
         };
         p.reparse();
         p
     }
 
+    /// Whether a float3-valued row of this parameter shows the trackball:
+    /// the instance's choice when it made one, `default` otherwise.
+    pub fn wants_trackball(&self, default: bool) -> bool {
+        match self.view.as_str() {
+            "trackball" => true,
+            "sliders" => false,
+            _ => default,
+        }
+    }
+
     pub fn with_label(mut self, label: impl Into<String>) -> Self {
         self.label = label.into();
         self
@@ -534,6 +560,11 @@ impl ParamDef {
         self.max = template.max;
         self.step = template.step;
         self.show_when = template.show_when.clone();
+        // The view is the instance's to choose; the template's is what it
+        // starts from.
+        if self.view.is_empty() {
+            self.view = template.view.clone();
+        }
         if !self.is_expr() {
             self.reparse();
         }