graphic design tool
git clone https://git.lucas.co/cce-designer.git
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();
}