GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: a float3 group can carry a trackball
Three sliders set a vector a component at a time, which is the wrong
handle for its direction. Float3::set_trackball (a ParametersBg row typed
float3:lo:hi:trackball) stands a ball left of the rows, as tall as they
are, with the vector drawn on it from the centre — X right, Y up, Z
toward the viewer; bright on the near side, dim pointing away. Dragging
the ball rolls it under the pointer, turning the vector and keeping its
length. The rows stay, for a component typed or a length changed.
The drag turns its own full-precision copy: the rows hold the vector
rounded and a host writes the rounded string back between moves, and
turning that loses every step smaller than a readout tick. With the ball
on the rows read to three decimals. The ball is painted through the
host's scene path, since a sphere is not a prim the flat views carry,
and counts as chrome when the pane decides its label layout. A vector of
no length is given a length of one by the first drag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 20 +++
src/widget/container/parameters_bg.rs | 56 ++++++-
src/widget/display/float3.rs | 285 +++++++++++++++++++++++++++++++++-
src/widget/input/slider.rs | 6 +
4 files changed, 355 insertions(+), 12 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6ee4789..56a7e2b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -975,3 +975,23 @@ the old 40 only from a hundred up. By magnitude rather than a finer flat
step: one fine enough to set 0.06 takes thousands of notches to reach
1000, and this takes under sixty. A drag still maps the pointer to the
whole range; the readout is still where an exact value is typed.
+
+### A float3 group can carry a trackball
+
+`Float3::set_trackball` (a `ParametersBg` row typed
+`float3:lo:hi:trackball`) stands a ball left of the three rows, as tall as
+they are. The vector is drawn on it from the centre — X right, Y up, Z
+toward the viewer; bright on the near side, dim pointing away — and
+dragging the ball rolls it under the pointer, turning the vector and
+keeping its length. The rows stay: a direction is turned on the ball, a
+component typed or a length changed on a row. Three things to know. The
+drag turns its OWN full-precision copy (`BallDrag`), because the rows hold
+the vector rounded and a host writes the rounded string back between
+moves; turning that loses every step smaller than a readout tick. With
+the ball on the rows read to three decimals, since at two a vector of
+length 0.06 has seven directions. And the ball is painted by
+`paint_ball` through the host's scene path (`paint_scene_rows`), not
+`Paint::paint`: a sphere is not a prim the legacy flat views carry. A
+vector of no length is given a length of one by the first drag. The ball
+counts as chrome in `ParametersBg::control_chrome`, so the label layout
+is decided by the track it leaves.
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 571bdce..0be2dc8 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -304,6 +304,12 @@ impl ParametersBg {
/// box, dropdown, colour) is measured whole.
pub const MIN_INLINE_TRACK_W: f32 = 120.0;
+ /// Whether a float3 row's type asks for the trackball: a fourth segment,
+ /// `float3:lo:hi:trackball`.
+ fn has_trackball(t: &str) -> bool {
+ t.starts_with("float3") && t.split(':').nth(3) == Some("trackball")
+ }
+
/// What a row of type `t` spends of its control rect on everything but
/// its track: a slider's readout and gap, plus a float3's axis column.
/// Zero for the controls that have no track.
@@ -311,7 +317,8 @@ impl ParametersBg {
if t.starts_with("slider") {
crate::widget::input::slider::Slider::readout_chrome()
} else if t.starts_with("float3") {
- crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
+ let ball = if Self::has_trackball(t) { Float3::trackball_chrome() } else { 0.0 };
+ ball + crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
} else {
0.0
}
@@ -1915,11 +1922,17 @@ impl ParametersBg {
let hidden = self.hidden_rows();
let dummy = UiContext::new();
for (i, p) in self.display_params.iter().enumerate() {
- if hidden[i] || p.2 != "ramp" {
+ if hidden[i] {
continue;
}
- if let Some(rp) = &self.ramps[i] {
- rp.paint_self(&dummy, pc);
+ if p.2 == "ramp" {
+ if let Some(rp) = &self.ramps[i] {
+ rp.paint_self(&dummy, pc);
+ }
+ } else if let Some(f) = self.float3s[i].as_ref().filter(|f| f.has_trackball()) {
+ // A float3's trackball: a sphere is not a prim the flat
+ // views carry, so it rides the scene path like the ramp.
+ f.paint_ball(pc);
}
}
}
@@ -3457,7 +3470,7 @@ impl ParamController for ParametersBg {
if p.2.starts_with("float3") {
let (min, max) = parse_slider_range(&p.2);
let vals = parse_float3_value(&p.1, min, max);
- let f = Float3::new().with_values(vals).with_range(min, max);
+ let f = Float3::new().with_values(vals).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
Some(if inline { f } else { f.with_label(&p.0) })
} else {
None
@@ -3910,6 +3923,39 @@ mod tests {
assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
}
+ /// A `float3:lo:hi:trackball` row builds its group with the ball, counts
+ /// the ball as chrome when the label layout is decided, paints it
+ /// through the scene path, and a press-and-drag on it through the
+ /// pane's own drag protocol turns the row's value.
+ #[test]
+ fn a_trackball_row_turns_its_value_through_the_pane() {
+ use crate::scene::paint::Prim;
+ let mut p = panel_with(&[("Name", "x", "text"), ("Pull", "0.000:0.000:2.000", "float3:-10:10:trackball"), ("Plain", "0:0:0", "float3:-10:10")]);
+ WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
+ assert!(p.float3s[1].as_ref().unwrap().has_trackball());
+ assert!(!p.float3s[2].as_ref().unwrap().has_trackball());
+ assert_eq!(
+ ParametersBg::control_chrome("float3:-10:10:trackball") - ParametersBg::control_chrome("float3:-10:10"),
+ Float3::trackball_chrome(),
+ "the ball is chrome: the tracks are what is left of it"
+ );
+
+ let mut pc = crate::scene::paint::PaintCtx::new();
+ p.inner().paint_scene_rows(&mut pc);
+ let spheres = pc.finish().items.into_iter().filter(|i| matches!(i.prim, Prim::Sphere { .. })).count();
+ assert_eq!(spheres, 1, "one ball, for the one row that asks");
+
+ let (cx, cy, r) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
+ let rect = Rect { x: 0.0, y: 0.0, width: 500.0, height: 400.0 };
+ Input::drag_begin(p.inner_mut(), cx, cy, rect);
+ assert!(Input::is_dragging(p.inner()), "the pane is dragging the row");
+ assert!(Input::drag_update(p.inner_mut(), cx + r * std::f32::consts::FRAC_PI_2, cy, rect));
+ Input::drag_end(p.inner_mut());
+ let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
+ assert!((v[0] - 2.0).abs() < 2e-3 && v[1].abs() < 2e-3 && v[2].abs() < 2e-3, "a quarter turn right: {v:?}");
+ assert_eq!(p.display_params[2].1, "0:0:0", "the plain row is untouched");
+ }
+
#[test]
fn ramp_row_builds_from_spec_and_edits_serialize_back() {
let mut ctx = UiContext::new();
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 249608a..c29c4c1 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -29,6 +29,23 @@ const ROW_GAP: f32 = 4.0;
pub(crate) const AXIS_W: f32 = 16.0;
/// Readout / edit-buffer precision of the rows, and of [`Float3::value_string`].
const DECIMALS: usize = 2;
+/// The same with the trackball on. Turning a vector is a small change to
+/// each of its components, and at two decimals a short one — a pull of 0.06
+/// — has seven directions it can point in.
+const BALL_DECIMALS: usize = 3;
+/// Gap between the trackball and the axis-letter column.
+const BALL_GAP: f32 = 10.0;
+
+/// A trackball drag in progress: where the pointer last was, and the vector
+/// being turned at FULL precision. The rows hold it rounded to their
+/// readouts, and a host may write the rounded string back between moves;
+/// turning that instead would lose every step smaller than a readout tick.
+#[derive(Debug, Clone, Copy)]
+struct BallDrag {
+ last: (f32, f32),
+ dir: [f32; 3],
+ len: f32,
+}
pub struct Float3 {
/// The assigned (label-inclusive) rect.
@@ -37,6 +54,10 @@ pub struct Float3 {
axes: [&'static str; 3],
label: Option<String>,
dragging_idx: Option<usize>,
+ /// Whether the group carries a TRACKBALL left of its rows: a ball the
+ /// vector is drawn on and turned by. See [`Float3::set_trackball`].
+ ball: bool,
+ ball_drag: Option<BallDrag>,
}
impl Float3 {
@@ -48,9 +69,154 @@ impl Float3 {
axes: ["X", "Y", "Z"],
label: None,
dragging_idx: None,
+ ball: false,
+ ball_drag: None,
})
}
+ /// Give the group a trackball, or take it away.
+ ///
+ /// The three sliders set a vector a component at a time, which is the
+ /// wrong handle for its DIRECTION: pointing a pull somewhere else means
+ /// moving all three, by amounts no one can do in their head. The ball
+ /// is that handle. It sits left of the rows, as tall as they are, with
+ /// the vector drawn on it from the centre — bright on the near side,
+ /// dim when it points away — and dragging the ball rolls it under the
+ /// pointer, turning the vector with it and keeping its length. The rows
+ /// stay: they are still how a component is typed or a length changed.
+ ///
+ /// The view is fixed: X to the right, Y up, Z toward the viewer, the
+ /// axes the rows are lettered by. A vector of no length has no
+ /// direction to turn, so the first drag gives it a length of one.
+ pub fn set_trackball(&mut self, on: bool) {
+ self.ball = on;
+ self.ball_drag = None;
+ let decimals = self.decimals();
+ for s in self.sliders.iter_mut() {
+ s.set_decimals(decimals);
+ }
+ self.layout_rows();
+ }
+
+ pub fn has_trackball(&self) -> bool {
+ self.ball
+ }
+
+ fn decimals(&self) -> usize {
+ if self.ball { BALL_DECIMALS } else { DECIMALS }
+ }
+
+ /// The ball's diameter: the three rows' height, so it costs the group
+ /// no height of its own.
+ pub fn ball_diameter() -> f32 {
+ 3.0 * crate::layout::slider_height() + 2.0 * ROW_GAP
+ }
+
+ /// What the trackball takes of the group's width: the ball and its gap.
+ /// A host measuring the rows' tracks counts it as chrome.
+ pub fn trackball_chrome() -> f32 {
+ Self::ball_diameter() + BALL_GAP
+ }
+
+ /// The ball as `(cx, cy, radius)`, when the group has one.
+ pub fn ball_circle(&self) -> Option<(f32, f32, f32)> {
+ if !self.ball {
+ return None;
+ }
+ let r = Self::ball_diameter() * 0.5;
+ Some((self.rect.x + r, self.rect.y + self.label_top() + r, r))
+ }
+
+ pub fn ball_hit(&self, px: f32, py: f32) -> bool {
+ self.ball_circle().is_some_and(|(cx, cy, r)| (px - cx).powi(2) + (py - cy).powi(2) <= r * r)
+ }
+
+ /// The vector the rows hold, in their scaled values.
+ pub fn vector(&self) -> [f32; 3] {
+ [0, 1, 2].map(|i| self.sliders[i].get_scaled_value())
+ }
+
+ /// Roll the ball by a pointer delta: `dx` turns the vector about the
+ /// vertical axis, `dy` about the horizontal one, by the angle that
+ /// much of the ball's surface subtends — so the point under the
+ /// pointer stays under it. Pure, for the tests.
+ pub fn rolled(dir: [f32; 3], dx: f32, dy: f32, radius: f32) -> [f32; 3] {
+ let (s, c) = (dx / radius).sin_cos();
+ let (x, y, z) = (dir[0] * c + dir[2] * s, dir[1], -dir[0] * s + dir[2] * c);
+ // Screen y runs down and the vector's Y up: a pull downward turns
+ // the near point toward -Y.
+ let (s, c) = (dy / radius).sin_cos();
+ let out = [x, y * c - z * s, y * s + z * c];
+ let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2]).sqrt();
+ if len > 0.0 { out.map(|v| v / len) } else { dir }
+ }
+
+ fn ball_begin(&mut self, px: f32, py: f32) {
+ let v = self.vector();
+ let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
+ let (dir, len) = if len > 1e-6 { (v.map(|c| c / len), len) } else { ([0.0, 0.0, 1.0], 1.0) };
+ self.ball_drag = Some(BallDrag { last: (px, py), dir, len });
+ }
+
+ fn ball_roll(&mut self, px: f32, py: f32) -> bool {
+ let (Some(mut drag), Some((_, _, r))) = (self.ball_drag, self.ball_circle()) else {
+ return false;
+ };
+ let (dx, dy) = (px - drag.last.0, py - drag.last.1);
+ if dx == 0.0 && dy == 0.0 {
+ return false;
+ }
+ drag.dir = Self::rolled(drag.dir, dx, dy, r);
+ drag.last = (px, py);
+ self.ball_drag = Some(drag);
+ for (s, c) in self.sliders.iter_mut().zip(drag.dir) {
+ s.set_scaled_value(c * drag.len);
+ }
+ true
+ }
+
+ /// The trackball: the ball, and the vector on it. Emitted through the
+ /// host's scene path (`ParametersBg::paint_scene_rows`) rather than
+ /// [`Paint::paint`], because a sphere is not a prim the legacy flat
+ /// views carry.
+ pub fn paint_ball(&self, ctx: &mut PaintCtx) {
+ let Some((cx, cy, r)) = self.ball_circle() else {
+ return;
+ };
+ let held = self.ball_drag.is_some();
+ let body = if held { [0.26, 0.29, 0.40, 1.0] } else { [0.19, 0.21, 0.29, 1.0] };
+ ctx.sphere(cx, cy, r, &crate::scene::material::Material::from_fill(body));
+ ctx.arc(cx, cy, r, 1.0, 0.0, std::f32::consts::TAU, [0.42, 0.45, 0.58, 0.9]);
+
+ // While held the ball shows the vector it is turning, not the one
+ // the rows rounded it to.
+ let v = match self.ball_drag {
+ Some(d) => d.dir.map(|c| c * d.len),
+ None => self.vector(),
+ };
+ let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
+ // The accent's HUE at alphas of the ball's own: the configured
+ // accent carries an alpha meant for washes, and at that alpha the
+ // near side drew paler than the far one.
+ let accent = crate::color::highlight_primary_color();
+ let accent = |a: f32| [accent[0], accent[1], accent[2], a];
+ if len <= 1e-6 {
+ // No length, no direction: a dim hub and nothing on the ball.
+ ctx.circle(cx, cy, 2.5, accent(0.35));
+ return;
+ }
+ let d = v.map(|c| c / len);
+ // The tip sits on the ball's surface as seen from +Z, a little in
+ // from the rim so a vector lying in the screen plane stays on it.
+ let reach = r - 5.0;
+ let (tx, ty) = (cx + d[0] * reach, cy - d[1] * reach);
+ let near = d[2] >= 0.0;
+ let col = accent(if near { 1.0 } else { 0.35 });
+ ctx.vector(cx, cy, tx, ty, 2.0, col, crate::scene::paint::Cap::Round);
+ ctx.circle(cx, cy, 2.0, col);
+ ctx.circle(tx, ty, if near { 4.5 } else { 3.0 }, col);
+ }
+
/// The height a labeled (`labeled`) group lays out to: the detached label band plus three slider rows and their gaps — the row-height table entry.
pub fn preferred_height(labeled: bool) -> f32 {
let top = if labeled { crate::layout::control_label_strip() } else { 0.0 };
@@ -78,7 +244,8 @@ impl Float3 {
/// stores — the one formatter for every host sync.
pub fn value_string(&self) -> String {
let v = |i: usize| self.sliders[i].get_scaled_value();
- format!("{:.*}:{:.*}:{:.*}", DECIMALS, v(0), DECIMALS, v(1), DECIMALS, v(2))
+ let d = self.decimals();
+ format!("{:.*}:{:.*}:{:.*}", d, v(0), d, v(1), d, v(2))
}
/// The three rows, X/Y/Z — for hosts that draw this group through the legacy flat views
@@ -98,8 +265,9 @@ impl Float3 {
/// right of the axis-letter column. Each is exactly the rect its sub-slider is assigned.
pub fn get_row_rects(&self) -> Vec<(f32, f32, f32, f32)> {
let top = self.rect.y + self.label_top();
- let x = self.rect.x + AXIS_W;
- let w = (self.rect.width - AXIS_W).max(10.0);
+ let ball = if self.ball { Self::trackball_chrome() } else { 0.0 };
+ let x = self.rect.x + ball + AXIS_W;
+ let w = (self.rect.width - ball - AXIS_W).max(10.0);
let h = crate::layout::slider_height();
(0..3).map(|i| (x, top + i as f32 * (h + ROW_GAP), w, h)).collect()
}
@@ -158,6 +326,12 @@ impl Float3 {
}
impl Adapted<Float3> {
+ /// See [`Float3::set_trackball`].
+ pub fn with_trackball(mut self, on: bool) -> Self {
+ self.set_trackball(on);
+ self
+ }
+
pub fn with_values(mut self, values: [f32; 3]) -> Self {
self.set_values(values);
self
@@ -227,17 +401,22 @@ impl Paint for Float3 {
impl Input for Float3 {
fn draggable(&self, _rect: Rect) -> bool {
- self.dragging_idx.is_some()
+ self.dragging_idx.is_some() || self.ball_drag.is_some()
}
fn is_dragging(&self) -> bool {
- self.dragging_idx.is_some()
+ self.dragging_idx.is_some() || self.ball_drag.is_some()
}
/// A host-driven drag begins on the row under the pointer — unless a press already
- /// started one (the pane's press path), in which case that row keeps it.
+ /// started one (the pane's press path), in which case that row keeps it. The ball
+ /// comes first: it stands beside the rows, inside their span of y.
fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
- if self.dragging_idx.is_some() {
+ if self.dragging_idx.is_some() || self.ball_drag.is_some() {
+ return;
+ }
+ if self.ball_hit(px, py) {
+ self.ball_begin(px, py);
return;
}
let rows = self.get_row_rects();
@@ -251,6 +430,9 @@ impl Input for Float3 {
}
fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
+ if self.ball_drag.is_some() {
+ return self.ball_roll(px, py);
+ }
match self.dragging_idx {
Some(i) => self.sliders[i].drag_update(px, py),
None => false,
@@ -258,6 +440,7 @@ impl Input for Float3 {
}
fn drag_end(&mut self) {
+ self.ball_drag = None;
if let Some(i) = self.dragging_idx.take() {
self.sliders[i].drag_end();
}
@@ -290,6 +473,12 @@ impl Input for Float3 {
let mut dummy = UiContext::new();
match state {
ElementState::Pressed => {
+ // A press on the ball takes hold of it; the drag
+ // that follows turns the vector (`drag_update`).
+ if self.ball_hit(*px, *py) {
+ self.ball_begin(*px, *py);
+ return true;
+ }
let rows = self.get_row_rects();
for (i, r) in rows.into_iter().enumerate() {
if *py < r.1 || *py > r.1 + r.3 {
@@ -324,6 +513,9 @@ impl Input for Float3 {
if self.dragging_idx.take().is_some() {
any = true;
}
+ if self.ball_drag.take().is_some() {
+ any = true;
+ }
any
}
}
@@ -401,4 +593,83 @@ mod tests {
f.drag_end();
assert!(!f.is_dragging());
}
+
+ /// The trackball stands left of the rows, as tall as they are, and
+ /// dragging it rolls the vector: a quarter turn of the ball's surface
+ /// to the right carries a vector pointing at the viewer onto +X, one
+ /// downward onto -Y, and the length is kept. The rows read to a third
+ /// decimal, and a vector of no length is given one on the first drag.
+ #[test]
+ fn the_trackball_turns_the_vector_and_keeps_its_length() {
+ let mut ctx = UiContext::new();
+ let quarter = |r: f32| r * std::f32::consts::FRAC_PI_2;
+ let group = |v: [f32; 3]| {
+ // Range -10..10: a scaled value v is (v + 10) / 20 normalized.
+ let mut f = Float3::new().with_range(-10.0, 10.0).with_values(v.map(|c| (c + 10.0) / 20.0)).with_trackball(true);
+ WidgetHost::set_rect(&mut f, 0.0, 0.0, 400.0, Float3::preferred_height(false));
+ f
+ };
+ let close = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
+
+ let plain = {
+ let mut f = Float3::new();
+ WidgetHost::set_rect(&mut f, 0.0, 0.0, 400.0, Float3::preferred_height(false));
+ f.get_row_rects()[0]
+ };
+ let mut f = group([0.0, 0.0, 2.0]);
+ let (cx, cy, r) = f.ball_circle().expect("a ball");
+ assert_eq!(r * 2.0, Float3::ball_diameter());
+ let row = f.get_row_rects()[0];
+ assert_eq!(row.0, plain.0 + Float3::trackball_chrome(), "the rows start past the ball");
+ assert_eq!(row.2, plain.2 - Float3::trackball_chrome());
+ assert_eq!(f.value_string(), "0.000:0.000:2.000", "three decimals with the ball on");
+
+ // A press on the ball takes hold; the drag rolls it a quarter turn right.
+ assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, cx, cy, &mut ctx));
+ assert!(f.is_dragging());
+ assert!(f.drag_update(cx + quarter(r), cy));
+ assert!(close(f.vector(), [2.0, 0.0, 0.0]), "{:?}", f.vector());
+ f.drag_end();
+ assert!(!f.is_dragging());
+
+ // Downward: the near point turns toward -Y.
+ let mut f = group([0.0, 0.0, 2.0]);
+ f.drag_begin(cx, cy);
+ assert!(f.drag_update(cx, cy + quarter(r)));
+ assert!(close(f.vector(), [0.0, -2.0, 0.0]), "{:?}", f.vector());
+
+ // Many small moves add up to what one large one does: the drag
+ // turns its own full-precision copy, not the rounded rows.
+ let mut f = group([0.0, 0.0, 0.06]);
+ f.drag_begin(cx, cy);
+ for i in 1..=100 {
+ f.drag_update(cx + quarter(r) * i as f32 / 100.0, cy);
+ }
+ assert!(close(f.vector(), [0.06, 0.0, 0.0]), "{:?}", f.vector());
+
+ // No length: the first drag gives it one.
+ let mut f = group([0.0, 0.0, 0.0]);
+ f.drag_begin(cx, cy);
+ assert!(f.drag_update(cx + quarter(r), cy));
+ assert!(close(f.vector(), [1.0, 0.0, 0.0]), "{:?}", f.vector());
+
+ // Off the ball a press is the rows', as before.
+ let mut f = group([0.0, 0.0, 2.0]);
+ let row = f.get_row_rects()[0];
+ assert!(!f.ball_hit(row.0 + 20.0, row.1 + row.3 * 0.5));
+ assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, row.0 + 20.0, row.1 + row.3 * 0.5, &mut ctx));
+ assert!(f.is_dragging());
+ f.drag_update(row.0 + 60.0, row.1 + row.3 * 0.5);
+ assert!(f.vector()[1] == 0.0 && f.vector()[2] == 2.0, "only X moved: {:?}", f.vector());
+
+ // The ball paints a sphere and the vector on it; without one, nothing.
+ let mut pc = PaintCtx::new();
+ group([0.0, 0.0, 2.0]).paint_ball(&mut pc);
+ let prims: Vec<_> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+ assert!(prims.iter().any(|p| matches!(p, crate::scene::paint::Prim::Sphere { .. })), "{prims:?}");
+ assert!(prims.iter().any(|p| matches!(p, crate::scene::paint::Prim::Vector { .. })));
+ let mut pc = PaintCtx::new();
+ Float3::new().paint_ball(&mut pc);
+ assert!(pc.finish().items.is_empty());
+ }
}
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 417b846..10783f4 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -108,6 +108,12 @@ impl Slider {
(self.min, self.max)
}
+ /// Readout precision, for a host that changes it after construction
+ /// (a float3 group gaining a trackball reads to a third decimal).
+ pub fn set_decimals(&mut self, decimals: usize) {
+ self.decimals = decimals;
+ }
+
/// The widest range a notch steps a flat 2% of. Wider than this, the
/// step follows the value's magnitude instead ([`Self::notch_step`]).
pub const FINE_SPAN: f32 = 20.0;