Skip to main content

frus_widgets/
wheel.rs

1//! [`ListWheel`]: the scrolling cylinder of values you spin to pick one.
2//!
3//! The selected row is in the middle and the rest curve away above and below, packing
4//! together and shrinking as they go. It is the control behind every date, time and
5//! duration picker that is **not** a calendar — and the one an application reaches for
6//! when it needs a picker of its own: a quantity, a font size, a unit.
7//!
8//! ## What a cylinder is, and what this one is
9//!
10//! A true cylinder needs a rotation about a **horizontal** axis and a perspective divide.
11//! The paint here transforms a subtree by an [`frus_core::Affine`], and an affine maps
12//! parallel lines to parallel lines — so it cannot produce the trapezoid a tipped row
13//! actually is, its near edge wider than its far one. **A real cylinder is not
14//! expressible**, and saying it is would be the sort of claim that is found out on a
15//! screen rather than in a test.
16//!
17//! What is expressible is every part of a cylinder that a reader notices:
18//!
19//! - rows **pack together** towards the ends, their centres at `r·sin θ` rather than at
20//!   the flat distance the strip would put them;
21//! - rows **squash** vertically by `cos θ`, which is foreshortening exactly;
22//! - rows **narrow** slightly as they recede, which is the perspective the affine can
23//!   still express — a scale, being uniform across the row, rather than a taper;
24//! - rows **fade** with the same `cos θ`, which is where the missing taper's share of the
25//!   illusion goes.
26//!
27//! What is left out is the taper itself, and at the angles a wheel is read at it is the
28//! one part nobody looks for. Anything past a **quarter turn** has gone over the horizon
29//! and is not drawn at all.
30//!
31//! ## What it is made of
32//!
33//! Nothing new: the spin, the fling and the settling-on-a-row are the paged scrollable's
34//! ([`crate::PageView`]), with each row a page of its own height. So a wheel inherits the
35//! release that springs to the nearest row rather than stopping between two, the
36//! virtualised window — a wheel of three thousand minutes costs what a wheel of ten does
37//! — and the requested-row-on-the-first-frame rule. What a wheel adds is the geometry
38//! below and a way of saying, to a reader who cannot see it, which value is chosen.
39
40use std::f32::consts::FRAC_PI_2;
41
42use frus_core::{Rect, Scene};
43use frus_layout::{Dimension, Style};
44
45use crate::interaction::Status;
46use crate::pageview::PagedView;
47use crate::physics::ScrollPhysics;
48use crate::scroll::Axis;
49use crate::theme::Theme;
50use crate::widget::Widget;
51
52/// The shape of the cylinder rows are laid on.
53#[derive(Copy, Clone, Debug, PartialEq)]
54pub struct WheelGeometry {
55    /// The cylinder's diameter as a multiple of the viewport's height. **Larger is
56    /// flatter**: at a diameter of twice the viewport only the middle thirty degrees of
57    /// the cylinder are ever in front of it, and thirty degrees of a turn is a slope
58    /// rather than a wheel.
59    pub diameter_ratio: f32,
60    /// How much a row narrows per pixel it has receded. `0.0` leaves every row the full
61    /// width, which reads as a fold rather than a turn.
62    pub perspective: f32,
63}
64
65impl Default for WheelGeometry {
66    /// A diameter only a little larger than the wheel is tall, which puts the ends of
67    /// the visible strip most of the way round to the horizon and is what makes a picker
68    /// look like a picker rather than like a list with the ends dimmed.
69    ///
70    /// These are the reference's own numbers for its picker, and its comment on them is
71    /// that they were **eyeballed against a real one**. That is worth repeating rather
72    /// than rederiving: the geometry here is deliberately not a cylinder (see the module
73    /// documentation), so a figure argued from first principles would be arguing about
74    /// the wrong shape.
75    fn default() -> Self {
76        Self {
77            diameter_ratio: 1.07,
78            perspective: 0.003,
79        }
80    }
81}
82
83/// Where one row has ended up on the cylinder, and what it looks like there.
84#[derive(Copy, Clone, Debug, PartialEq)]
85pub struct RowOnWheel {
86    /// How far round the cylinder the row has turned, in radians, signed.
87    pub angle: f32,
88    /// Where the row's **centre** sits, in pixels from the viewport's centre — always
89    /// nearer to it than the flat strip would have put it.
90    pub offset: f32,
91    /// Horizontal scale: the row receding.
92    pub scale_x: f32,
93    /// Vertical scale: the row foreshortened, which is `cos θ`.
94    pub scale_y: f32,
95    /// What is left of the row's opacity.
96    pub opacity: f32,
97}
98
99impl WheelGeometry {
100    /// The cylinder's radius in a viewport `extent` pixels along.
101    pub fn radius(&self, extent: f32) -> f32 {
102        (extent * self.diameter_ratio / 2.0).max(1.0)
103    }
104
105    /// Where a row goes, given the viewport's extent and where the row's centre would
106    /// sit on an **unrolled** strip, measured from the viewport's centre.
107    ///
108    /// `None` once the row has turned past a quarter — it has gone over the horizon, and
109    /// a row drawn there would be a line of text lying edge-on.
110    pub fn row(&self, extent: f32, flat: f32) -> Option<RowOnWheel> {
111        let radius = self.radius(extent);
112        // Arc length over radius: the strip is what is wrapped round the cylinder, so the
113        // flat distance **is** the arc, and this is the angle it subtends.
114        let angle = flat / radius;
115        if angle.abs() >= FRAC_PI_2 {
116            return None;
117        }
118        let cos = angle.cos();
119        // How far the row has receded from the plane of the viewport.
120        let z = radius * (1.0 - cos);
121        Some(RowOnWheel {
122            angle,
123            offset: radius * angle.sin(),
124            scale_x: 1.0 / (1.0 + self.perspective * z),
125            scale_y: cos,
126            opacity: cos,
127        })
128    }
129}
130
131/// A scrolling cylinder of rows, resting on one of them. See the module documentation.
132///
133/// ```
134/// use frus_widgets::{text, ListWheel};
135///
136/// let _minutes: ListWheel<usize> = ListWheel::new(60, 34.0, |i| text(format!("{i:02}")))
137///     .height(180.0)
138///     .selected(15)
139///     .on_selected(|i| i)
140///     .label(|i| format!("{i} minutes"));
141/// ```
142pub struct ListWheel<Msg = crate::callback::Callback> {
143    count: usize,
144    extent: f32,
145    geometry: WheelGeometry,
146    selected: usize,
147    build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
148    on_selected: Option<Box<dyn Fn(usize) -> Msg>>,
149    label: Option<Box<dyn Fn(usize) -> String>>,
150    physics: ScrollPhysics,
151    width: Dimension,
152    height: Dimension,
153    flex: f32,
154}
155
156impl<Msg> ListWheel<Msg> {
157    /// A wheel of `count` rows, each `extent` pixels tall, built on demand.
158    pub fn new<W: Widget<Msg> + 'static>(
159        count: usize,
160        extent: f32,
161        build: impl Fn(usize) -> W + 'static,
162    ) -> Self {
163        Self {
164            count,
165            extent: extent.max(1.0),
166            geometry: WheelGeometry::default(),
167            selected: 0,
168            build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
169            on_selected: None,
170            label: None,
171            physics: ScrollPhysics::default(),
172            width: Dimension::Auto,
173            height: Dimension::Length(180.0),
174            flex: 0.0,
175        }
176    }
177
178    /// The row the application says is chosen. Applied on the first frame and on every
179    /// change, the way [`crate::PageView::page`] is — so a wheel opens on the value it
180    /// holds rather than on the first one and then jumping.
181    pub fn selected(mut self, index: usize) -> Self {
182        self.selected = index;
183        self
184    }
185
186    /// What to send when the wheel comes to rest on a different row.
187    pub fn on_selected<R: crate::callback::IntoMsg<Msg>>(
188        mut self,
189        message: impl Fn(usize) -> R + 'static,
190    ) -> Self {
191        self.on_selected = Some(Box::new(crate::callback::handler1(message)));
192        self
193    }
194
195    /// **What each row is called**, for a reader who cannot see it.
196    ///
197    /// Not optional in spirit: a wheel with no words is a control that announces its
198    /// position as a number out of a count and its value as nothing at all. The index is
199    /// what the caller has; only the caller knows it means half past three.
200    pub fn label(mut self, label: impl Fn(usize) -> String + 'static) -> Self {
201        self.label = Some(Box::new(label));
202        self
203    }
204
205    /// How curved the cylinder is: the diameter as a multiple of the wheel's own height.
206    pub fn diameter_ratio(mut self, ratio: f32) -> Self {
207        self.geometry.diameter_ratio = ratio.max(0.1);
208        self
209    }
210
211    /// How much a receding row narrows. `0.0` for none.
212    pub fn perspective(mut self, perspective: f32) -> Self {
213        self.geometry.perspective = perspective.max(0.0);
214        self
215    }
216
217    /// The scroll physics of the spin — the platform's by default.
218    pub fn physics(mut self, physics: ScrollPhysics) -> Self {
219        self.physics = physics;
220        self
221    }
222
223    /// A fixed width.
224    pub fn width(mut self, width: f32) -> Self {
225        self.width = Dimension::Length(width);
226        self
227    }
228
229    /// A fixed height. A wheel needs one: it is a window onto a cylinder, and a window
230    /// that hugged its content would be the whole strip.
231    pub fn height(mut self, height: f32) -> Self {
232        self.height = Dimension::Length(height);
233        self
234    }
235
236    /// Grows to fill the space its parent has left along the main axis.
237    pub fn flex(mut self, grow: f32) -> Self {
238        self.flex = grow;
239        self
240    }
241
242    /// The row a reader is on, clamped to what there is.
243    fn chosen(&self) -> usize {
244        self.selected.min(self.count.saturating_sub(1))
245    }
246}
247
248impl<Msg: Clone + 'static> Widget<Msg> for ListWheel<Msg> {
249    fn style(&self) -> Style {
250        Style {
251            width: self.width,
252            height: self.height,
253            flex_grow: self.flex,
254            ..Default::default()
255        }
256    }
257
258    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
259        &[]
260    }
261
262    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
263        // The rows are the picture, and the walk draws them.
264    }
265
266    fn on_click(&self) -> Option<Msg> {
267        None
268    }
269
270    fn page_view(&self) -> Option<PagedView<'_, Msg>> {
271        Some(PagedView {
272            count: self.count,
273            axis: Axis::Vertical,
274            viewport_fraction: 1.0,
275            extent: Some(self.extent),
276            requested: self.chosen(),
277            // The first row rests **centred**, not against the top edge: the middle of a
278            // wheel is where the answer is, so that is where row nought has to be able to
279            // get to. This is also what makes the last row reachable at all.
280            pad_ends: true,
281            snapping: true,
282            wheel: Some(self.geometry),
283            build: &*self.build,
284        })
285    }
286
287    fn on_page_changed(&self, index: usize) -> Option<Msg> {
288        self.on_selected.as_ref().map(|f| f(index))
289    }
290
291    fn scroll_physics(&self) -> Option<ScrollPhysics> {
292        Some(self.physics)
293    }
294
295    /// **The wheel is the control, and this is how a reader hears it change.**
296    ///
297    /// Not a list to walk row by row: a wheel is a one-dimensional selector, so it says
298    /// so — the role a platform's own picker reports, the chosen value in the caller's
299    /// words, and the position in the set as the range. A reader gets "half past three,
300    /// 15 of 60", and gets it again, by itself, every time the wheel settles somewhere
301    /// else. Rows carry their own annotations underneath; this is the one that speaks
302    /// for the whole.
303    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
304        let chosen = self.chosen();
305        let mut props = frus_core::SemanticsProperties::new(frus_core::Role::Slider).range(
306            0.0,
307            chosen as f32,
308            self.count.saturating_sub(1) as f32,
309        );
310        props.value = Some(match self.label.as_ref() {
311            Some(label) => label(chosen),
312            // Without words there is still a position, and a position is better than
313            // silence — but it is not an answer to "what is this set to".
314            None => format!("{} of {}", chosen + 1, self.count),
315        });
316        Some(props)
317    }
318
319    fn debug_name(&self) -> &'static str {
320        "ListWheel"
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::physics::ScrollPhysics;
328    use crate::{build_ui, Container, Runtime};
329    use frus_core::{Color, Primitive, Size};
330    use std::cell::Cell;
331    use std::rc::Rc;
332
333    const EXTENT: f32 = 34.0;
334    const VIEWPORT: f32 = 200.0;
335
336    fn wheel(count: usize, selected: usize, built: Rc<Cell<usize>>) -> ListWheel<()> {
337        ListWheel::new(count, EXTENT, move |index| {
338            built.set(built.get() + 1);
339            Container::new()
340                .width(160.0)
341                .height(EXTENT)
342                .color(Color::rgb(index as f32 / 60.0, 0.0, 0.0))
343        })
344        .width(160.0)
345        .height(VIEWPORT)
346        .selected(selected)
347    }
348
349    fn ui_of(widget: &ListWheel<()>, runtime: &Runtime) -> crate::Ui<()> {
350        build_ui(
351            widget,
352            Size::new(160.0, VIEWPORT),
353            runtime,
354            &Theme::default(),
355        )
356    }
357
358    /// **The middle of the wheel is where the answer is**, so the row the application
359    /// says is chosen has to be able to get there — and has to be there on the first
360    /// frame rather than after one that showed row nought.
361    #[test]
362    fn the_chosen_row_rests_across_the_middle() {
363        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
364        let runtime = Runtime::default();
365        let ui = ui_of(&wheel, &runtime);
366        let area = ui.scroll_regions().first().copied().expect("a region");
367        let snap = area.page.expect("a wheel is a paged region");
368        assert_eq!(snap.extent, EXTENT, "one row is one page");
369        assert_eq!(snap.count, 60);
370        assert!(!snap.horizontal, "a wheel turns the short way");
371        // The offset a wheel opens at, and the row it puts across the middle.
372        let along = snap.offset_of(30);
373        assert_eq!(snap.page_at(along), 30);
374        // Both ends are padded by half a viewport less half a row, which is what lets
375        // row nought and row fifty-nine reach the middle at all.
376        let pad = (VIEWPORT - EXTENT) / 2.0;
377        assert!(
378            (area.max_y - (60.0 * EXTENT + 2.0 * pad - VIEWPORT)).abs() < 0.01,
379            "the travel is exactly the last row's own offset: {}",
380            area.max_y
381        );
382        assert!(
383            (area.max_y - snap.offset_of(59)).abs() < 0.01,
384            "which is to say the last row can be reached: {}",
385            area.max_y
386        );
387    }
388
389    /// **A release settles on a row, never between two.** The wheel does not invent this
390    /// — it is the paged scrollable's release — but a wheel that failed to declare
391    /// itself paged would fling like a list and stop wherever it ran out, which is the
392    /// one thing a picker must not do.
393    #[test]
394    fn a_spin_comes_to_rest_on_a_row() {
395        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
396        let mut runtime = Runtime::default();
397        let area = ui_of(&wheel, &runtime)
398            .scroll_regions()
399            .first()
400            .copied()
401            .expect("a region");
402        let snap = area.page.expect("paged");
403        // Let go from exactly halfway between two rows, so nothing but the direction of
404        // the release can decide which one it goes to — and ask it both ways, because a
405        // wheel that settled on a row and always the same row would pass the first half
406        // of this on its own.
407        let between = snap.offset_of(30) + EXTENT / 2.0;
408        let settle = |runtime: &mut Runtime, velocity: f32| {
409            runtime.scroll.insert(area.id, (0.0, between));
410            runtime.scroll_ballistic.remove(&area.id);
411            assert!(
412                runtime.fling_scroll(area, ScrollPhysics::default(), (0.0, velocity)),
413                "a release on a paged region is a spring to a row"
414            );
415            for _ in 0..600 {
416                if !runtime.advance_scroll(&[area], ScrollPhysics::default(), 1.0 / 60.0) {
417                    break;
418                }
419            }
420            runtime.scroll.get(&area.id).copied().unwrap_or_default().1
421        };
422
423        let onwards = settle(&mut runtime, 300.0);
424        let row = (onwards / EXTENT).round();
425        assert!(
426            (onwards - row * EXTENT).abs() < 0.5,
427            "settled on a row and not between two: {onwards} is {} rows",
428            onwards / EXTENT
429        );
430        assert_eq!(
431            snap.page_at(onwards),
432            31,
433            "and on the one it was sent towards"
434        );
435
436        let back = settle(&mut runtime, -300.0);
437        assert!(
438            (back - (back / EXTENT).round() * EXTENT).abs() < 0.5,
439            "and the other way is a row too: {back}"
440        );
441        assert_eq!(snap.page_at(back), 30, "the one behind it");
442    }
443
444    /// **Only the rows the cylinder shows are built.** A wheel of three thousand minutes
445    /// is normal, and one that built three thousand rows a frame would be a picker
446    /// nobody could open.
447    ///
448    /// It is more than a flat strip would show, and that is the point: rows a quarter
449    /// turn away are compressed into the last pixels at either end, so their flat
450    /// positions are well outside the viewport and the window has to be widened to
451    /// reach them. What it must not do is widen without limit.
452    #[test]
453    fn a_wheel_of_three_thousand_builds_a_dozen() {
454        let built = Rc::new(Cell::new(0));
455        let wheel = wheel(3000, 1500, built.clone());
456        let runtime = Runtime::default();
457        ui_of(&wheel, &runtime);
458        let flat = (VIEWPORT / EXTENT).ceil() as usize + 1;
459        assert!(
460            built.get() > flat,
461            "a wheel shows more rows than a flat strip: {} vs {flat}",
462            built.get()
463        );
464        assert!(
465            built.get() < 3 * flat,
466            "and not without limit: {} of three thousand",
467            built.get()
468        );
469    }
470
471    /// **The rows are not where a flat strip would put them.** The one thing that says
472    /// the geometry reached the paint rather than merely being computed: the rows either
473    /// side of the middle are drawn closer in than their own height, and shorter.
474    #[test]
475    fn the_paint_puts_the_rows_on_the_cylinder() {
476        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
477        let runtime = Runtime::default();
478        let ui = ui_of(&wheel, &runtime);
479        // Every row's painted band, by the red channel the builder above numbered them
480        // with. Layers are transformed at compositing time, so the rects inside carry
481        // the flat geometry — what is asked here is the transform the layer holds.
482        let layers: Vec<_> = ui
483            .scene()
484            .primitives()
485            .iter()
486            .filter_map(|p| match p {
487                Primitive::Layer {
488                    transform: Some(t), ..
489                } => Some(t.affine),
490                _ => None,
491            })
492            .collect();
493        assert!(
494            layers.len() >= 5,
495            "one layer per row on the cylinder: {}",
496            layers.len()
497        );
498        // `m` is `[a, b, c, d, e, f]`: `a` and `d` are the two scales. The middle row is
499        // the untouched one — at the centre the cylinder is tangent to the screen, so
500        // its transform is the identity to within rounding.
501        let flat = layers
502            .iter()
503            .filter(|m| (m.m[0] - 1.0).abs() < 1e-3 && (m.m[3] - 1.0).abs() < 1e-3)
504            .count();
505        assert_eq!(flat, 1, "exactly one row is face on");
506        // And nothing is stretched: every row is squashed or unchanged, never taller.
507        assert!(
508            layers.iter().all(|m| m.m[3] <= 1.0 + 1e-3),
509            "a cylinder turns rows away from the reader, never towards"
510        );
511    }
512
513    /// **A reader hears which value is chosen, in the caller's words.**
514    ///
515    /// A wheel is a one-dimensional selector rather than a list to walk row by row, so
516    /// it says so as one: the role a platform's own picker reports, the value spelled
517    /// out, and the position in the set as the range. That is also what makes the
518    /// change announced — it is the same path a slider's value travels.
519    #[test]
520    fn the_wheel_says_what_it_is_set_to() {
521        let wheel = wheel(60, 30, Rc::new(Cell::new(0))).label(|i| format!("{i} minutes"));
522        let runtime = Runtime::default();
523        let ui = ui_of(&wheel, &runtime);
524        let (_, _, props) = ui
525            .semantics()
526            .iter()
527            .find(|(_, _, s)| s.role == frus_core::Role::Slider)
528            .expect("the wheel announces itself as a selector");
529        assert_eq!(props.value.as_deref(), Some("30 minutes"));
530        assert_eq!(props.range, Some((0.0, 30.0, 59.0)), "30 of 60");
531
532        // And moving it says something else, which is the whole of "announced as it
533        // changes": the value a reader is given is a function of where the wheel is.
534        let moved = wheel_at(31);
535        let ui = ui_of(&moved, &Runtime::default());
536        let (_, _, props) = ui
537            .semantics()
538            .iter()
539            .find(|(_, _, s)| s.role == frus_core::Role::Slider)
540            .expect("still a selector");
541        assert_eq!(props.value.as_deref(), Some("31 minutes"));
542    }
543
544    /// A wheel with words on it, at a given row.
545    fn wheel_at(selected: usize) -> ListWheel<()> {
546        wheel(60, selected, Rc::new(Cell::new(0))).label(|i| format!("{i} minutes"))
547    }
548
549    /// Without words, a position is still better than silence — and is still not an
550    /// answer to "what is this set to". Said out loud so that nobody reads the fallback
551    /// as good enough.
552    #[test]
553    fn a_wheel_with_no_words_gives_back_a_position() {
554        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
555        let ui = ui_of(&wheel, &Runtime::default());
556        let (_, _, props) = ui
557            .semantics()
558            .iter()
559            .find(|(_, _, s)| s.role == frus_core::Role::Slider)
560            .expect("a selector");
561        assert_eq!(props.value.as_deref(), Some("31 of 60"));
562    }
563
564    /// The geometry itself, at the three points worth pinning: the middle, a turn, and
565    /// the horizon.
566    #[test]
567    fn the_cylinder_packs_shrinks_and_ends() {
568        let g = WheelGeometry::default();
569        let radius = g.radius(VIEWPORT);
570
571        // Face on: nothing happens at all, which is what makes the chosen row the one
572        // that is drawn as it was written.
573        let middle = g.row(VIEWPORT, 0.0).expect("the middle is on the cylinder");
574        assert_eq!(middle.angle, 0.0);
575        assert_eq!(middle.offset, 0.0);
576        assert!((middle.scale_x - 1.0).abs() < 1e-6);
577        assert!((middle.scale_y - 1.0).abs() < 1e-6);
578        assert!((middle.opacity - 1.0).abs() < 1e-6);
579
580        // A row a third of a radian round: nearer the middle than the flat strip put it,
581        // shorter, narrower and fainter — each of those in the same direction.
582        let flat = radius / 3.0;
583        let turned = g.row(VIEWPORT, flat).expect("still in front");
584        assert!((turned.angle - 1.0 / 3.0).abs() < 1e-5);
585        assert!(
586            turned.offset < flat && turned.offset > 0.0,
587            "packed towards the middle: {} of {flat}",
588            turned.offset
589        );
590        assert!(turned.scale_y < 1.0 && turned.scale_y > 0.9);
591        assert!(turned.scale_x < 1.0);
592        assert!(turned.opacity < 1.0);
593
594        // Symmetric, because a wheel has no top and bottom of its own.
595        let mirrored = g.row(VIEWPORT, -flat).expect("still in front");
596        assert!((mirrored.offset + turned.offset).abs() < 1e-4);
597        assert!((mirrored.scale_y - turned.scale_y).abs() < 1e-6);
598
599        // A quarter turn is the horizon: exactly there and beyond it, a row is a line
600        // seen edge-on and is not drawn.
601        let horizon = radius * std::f32::consts::FRAC_PI_2;
602        assert!(g.row(VIEWPORT, horizon).is_none());
603        assert!(g.row(VIEWPORT, horizon * 1.5).is_none());
604        assert!(g.row(VIEWPORT, horizon * 0.99).is_some());
605    }
606
607    /// **A flatter wheel is a bigger cylinder**, and the numbers have to move that way
608    /// round: it is the one parameter a caller will reach for, and one whose sign is
609    /// backwards is worse than one that does not exist.
610    #[test]
611    fn a_larger_diameter_is_a_flatter_wheel() {
612        let flat_at = |ratio: f32| {
613            WheelGeometry {
614                diameter_ratio: ratio,
615                ..Default::default()
616            }
617            .row(VIEWPORT, 60.0)
618            .expect("in front")
619        };
620        let curved = flat_at(1.07);
621        let flatter = flat_at(6.0);
622        assert!(
623            flatter.angle < curved.angle,
624            "the same row has turned less: {} vs {}",
625            flatter.angle,
626            curved.angle
627        );
628        assert!(
629            flatter.scale_y > curved.scale_y,
630            "so it is squashed less: {} vs {}",
631            flatter.scale_y,
632            curved.scale_y
633        );
634        assert!(
635            flatter.offset > curved.offset,
636            "and packed towards the middle less: {} vs {}",
637            flatter.offset,
638            curved.offset
639        );
640    }
641}