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: Option<ScrollPhysics>,
151 width: Dimension,
152 height: Dimension,
153 /// Whether the width and the height were given; an axis that was not takes the room on
154 /// offer, as the reference's does (milestone 590).
155 sized: (bool, bool),
156 flex: f32,
157}
158
159impl<Msg> ListWheel<Msg> {
160 /// A wheel of `count` rows, each `extent` pixels tall, built on demand.
161 pub fn new<W: Widget<Msg> + 'static>(
162 count: usize,
163 extent: f32,
164 build: impl Fn(usize) -> W + 'static,
165 ) -> Self {
166 Self {
167 count,
168 extent: extent.max(1.0),
169 geometry: WheelGeometry::default(),
170 selected: 0,
171 build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
172 on_selected: None,
173 label: None,
174 physics: None,
175 width: Dimension::Auto,
176 height: Dimension::Length(180.0),
177 sized: (false, false),
178 flex: 0.0,
179 }
180 }
181
182 /// The row the application says is chosen. Applied on the first frame and on every
183 /// change, the way [`crate::PageView::page`] is — so a wheel opens on the value it
184 /// holds rather than on the first one and then jumping.
185 pub fn selected(mut self, index: usize) -> Self {
186 self.selected = index;
187 self
188 }
189
190 /// What to send when the wheel comes to rest on a different row.
191 pub fn on_selected<R: crate::callback::IntoMsg<Msg>>(
192 mut self,
193 message: impl Fn(usize) -> R + 'static,
194 ) -> Self {
195 self.on_selected = Some(Box::new(crate::callback::handler1(message)));
196 self
197 }
198
199 /// **What each row is called**, for a reader who cannot see it.
200 ///
201 /// Not optional in spirit: a wheel with no words is a control that announces its
202 /// position as a number out of a count and its value as nothing at all. The index is
203 /// what the caller has; only the caller knows it means half past three.
204 pub fn label(mut self, label: impl Fn(usize) -> String + 'static) -> Self {
205 self.label = Some(Box::new(label));
206 self
207 }
208
209 /// How curved the cylinder is: the diameter as a multiple of the wheel's own height.
210 pub fn diameter_ratio(mut self, ratio: f32) -> Self {
211 self.geometry.diameter_ratio = ratio.max(0.1);
212 self
213 }
214
215 /// How much a receding row narrows. `0.0` for none.
216 pub fn perspective(mut self, perspective: f32) -> Self {
217 self.geometry.perspective = perspective.max(0.0);
218 self
219 }
220
221 /// The scroll physics of the spin — its scroll behaviour's by default
222 /// ([`ScrollBehavior`](crate::ScrollBehavior)).
223 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
224 self.physics = Some(physics);
225 self
226 }
227
228 /// A fixed width.
229 pub fn width(mut self, width: f32) -> Self {
230 self.width = Dimension::Length(width);
231 self.sized.0 = true;
232 self
233 }
234
235 /// A fixed height. A wheel needs one: it is a window onto a cylinder, and a window
236 /// that hugged its content would be the whole strip.
237 pub fn height(mut self, height: f32) -> Self {
238 self.height = Dimension::Length(height);
239 self.sized.1 = true;
240 self
241 }
242
243 /// Grows to fill the space its parent has left along the main axis.
244 pub fn flex(mut self, grow: f32) -> Self {
245 self.flex = grow;
246 self
247 }
248
249 /// The row a reader is on, clamped to what there is.
250 fn chosen(&self) -> usize {
251 self.selected.min(self.count.saturating_sub(1))
252 }
253}
254
255impl<Msg: Clone + 'static> Widget<Msg> for ListWheel<Msg> {
256 fn style(&self) -> Style {
257 Style {
258 width: self.width,
259 height: self.height,
260 flex_grow: self.flex,
261 ..Default::default()
262 }
263 }
264
265 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
266 &[]
267 }
268
269 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
270 // The rows are the picture, and the walk draws them.
271 }
272
273 fn on_click(&self) -> Option<Msg> {
274 None
275 }
276
277 /// **All the room on offer** on an axis nobody sized, as the reference's takes
278 /// (milestone 590) — unless it was told to grow, which says the same thing already.
279 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
280 if self.flex > 0.0 {
281 return crate::widget::FillAxes::NONE;
282 }
283 crate::widget::FillAxes {
284 horizontal: !self.sized.0,
285 vertical: !self.sized.1,
286 }
287 }
288
289 /// The default size is only a default: the room replaces it wherever the layout can
290 /// give the room, and it stands where the reference would have nothing to give.
291 fn soft_extent(&self) -> crate::widget::FillAxes {
292 crate::widget::FillAxes {
293 horizontal: false,
294 vertical: !self.sized.1,
295 }
296 }
297
298 fn page_view(&self) -> Option<PagedView<'_, Msg>> {
299 Some(PagedView {
300 count: self.count,
301 axis: Axis::Vertical,
302 viewport_fraction: 1.0,
303 extent: Some(self.extent),
304 requested: self.chosen(),
305 // The first row rests **centred**, not against the top edge: the middle of a
306 // wheel is where the answer is, so that is where row nought has to be able to
307 // get to. This is also what makes the last row reachable at all.
308 pad_ends: true,
309 snapping: true,
310 wheel: Some(self.geometry),
311 build: &*self.build,
312 })
313 }
314
315 fn on_page_changed(&self, index: usize) -> Option<Msg> {
316 self.on_selected.as_ref().map(|f| f(index))
317 }
318
319 fn scroll_physics(&self) -> Option<ScrollPhysics> {
320 self.physics
321 }
322
323 /// **The wheel is the control, and this is how a reader hears it change.**
324 ///
325 /// Not a list to walk row by row: a wheel is a one-dimensional selector, so it says
326 /// so — the role a platform's own picker reports, the chosen value in the caller's
327 /// words, and the position in the set as the range. A reader gets "half past three,
328 /// 15 of 60", and gets it again, by itself, every time the wheel settles somewhere
329 /// else. Rows carry their own annotations underneath; this is the one that speaks
330 /// for the whole.
331 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
332 let chosen = self.chosen();
333 let mut props = frus_core::SemanticsProperties::new(frus_core::Role::Slider).range(
334 0.0,
335 chosen as f32,
336 self.count.saturating_sub(1) as f32,
337 );
338 props.value = Some(match self.label.as_ref() {
339 Some(label) => label(chosen),
340 // Without words there is still a position, and a position is better than
341 // silence — but it is not an answer to "what is this set to".
342 None => format!("{} of {}", chosen + 1, self.count),
343 });
344 Some(props)
345 }
346
347 fn debug_name(&self) -> &'static str {
348 "ListWheel"
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::{build_ui, Container, Runtime};
356 use frus_core::{Color, Primitive, Size};
357 use std::cell::Cell;
358 use std::rc::Rc;
359
360 const EXTENT: f32 = 34.0;
361 const VIEWPORT: f32 = 200.0;
362
363 fn wheel(count: usize, selected: usize, built: Rc<Cell<usize>>) -> ListWheel<()> {
364 ListWheel::new(count, EXTENT, move |index| {
365 built.set(built.get() + 1);
366 Container::new()
367 .width(160.0)
368 .height(EXTENT)
369 .color(Color::rgb(index as f32 / 60.0, 0.0, 0.0))
370 })
371 .width(160.0)
372 .height(VIEWPORT)
373 .selected(selected)
374 }
375
376 fn ui_of(widget: &ListWheel<()>, runtime: &Runtime) -> crate::Ui<()> {
377 build_ui(
378 widget,
379 Size::new(160.0, VIEWPORT),
380 runtime,
381 &Theme::default(),
382 )
383 }
384
385 /// **The middle of the wheel is where the answer is**, so the row the application
386 /// says is chosen has to be able to get there — and has to be there on the first
387 /// frame rather than after one that showed row nought.
388 #[test]
389 fn the_chosen_row_rests_across_the_middle() {
390 let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
391 let runtime = Runtime::default();
392 let ui = ui_of(&wheel, &runtime);
393 let area = ui.scroll_regions().first().copied().expect("a region");
394 let snap = area.page.expect("a wheel is a paged region");
395 assert_eq!(snap.extent, EXTENT, "one row is one page");
396 assert_eq!(snap.count, 60);
397 assert!(!snap.horizontal, "a wheel turns the short way");
398 // The offset a wheel opens at, and the row it puts across the middle.
399 let along = snap.offset_of(30);
400 assert_eq!(snap.page_at(along), 30);
401 // Both ends are padded by half a viewport less half a row, which is what lets
402 // row nought and row fifty-nine reach the middle at all.
403 let pad = (VIEWPORT - EXTENT) / 2.0;
404 assert!(
405 (area.max_y - (60.0 * EXTENT + 2.0 * pad - VIEWPORT)).abs() < 0.01,
406 "the travel is exactly the last row's own offset: {}",
407 area.max_y
408 );
409 assert!(
410 (area.max_y - snap.offset_of(59)).abs() < 0.01,
411 "which is to say the last row can be reached: {}",
412 area.max_y
413 );
414 }
415
416 /// **A release settles on a row, never between two.** The wheel does not invent this
417 /// — it is the paged scrollable's release — but a wheel that failed to declare
418 /// itself paged would fling like a list and stop wherever it ran out, which is the
419 /// one thing a picker must not do.
420 #[test]
421 fn a_spin_comes_to_rest_on_a_row() {
422 let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
423 let mut runtime = Runtime::default();
424 let area = ui_of(&wheel, &runtime)
425 .scroll_regions()
426 .first()
427 .copied()
428 .expect("a region");
429 let snap = area.page.expect("paged");
430 // Let go from exactly halfway between two rows, so nothing but the direction of
431 // the release can decide which one it goes to — and ask it both ways, because a
432 // wheel that settled on a row and always the same row would pass the first half
433 // of this on its own.
434 let between = snap.offset_of(30) + EXTENT / 2.0;
435 let settle = |runtime: &mut Runtime, velocity: f32| {
436 runtime.scroll.insert(area.id, (0.0, between));
437 runtime.scroll_ballistic.remove(&area.id);
438 assert!(
439 runtime.fling_scroll(area, area.physics, (0.0, velocity)),
440 "a release on a paged region is a spring to a row"
441 );
442 for _ in 0..600 {
443 if !runtime.advance_scroll(&[area], 1.0 / 60.0) {
444 break;
445 }
446 }
447 runtime.scroll.get(&area.id).copied().unwrap_or_default().1
448 };
449
450 let onwards = settle(&mut runtime, 300.0);
451 let row = (onwards / EXTENT).round();
452 assert!(
453 (onwards - row * EXTENT).abs() < 0.5,
454 "settled on a row and not between two: {onwards} is {} rows",
455 onwards / EXTENT
456 );
457 assert_eq!(
458 snap.page_at(onwards),
459 31,
460 "and on the one it was sent towards"
461 );
462
463 let back = settle(&mut runtime, -300.0);
464 assert!(
465 (back - (back / EXTENT).round() * EXTENT).abs() < 0.5,
466 "and the other way is a row too: {back}"
467 );
468 assert_eq!(snap.page_at(back), 30, "the one behind it");
469 }
470
471 /// **Only the rows the cylinder shows are built.** A wheel of three thousand minutes
472 /// is normal, and one that built three thousand rows a frame would be a picker
473 /// nobody could open.
474 ///
475 /// It is more than a flat strip would show, and that is the point: rows a quarter
476 /// turn away are compressed into the last pixels at either end, so their flat
477 /// positions are well outside the viewport and the window has to be widened to
478 /// reach them. What it must not do is widen without limit.
479 #[test]
480 fn a_wheel_of_three_thousand_builds_a_dozen() {
481 let built = Rc::new(Cell::new(0));
482 let wheel = wheel(3000, 1500, built.clone());
483 let runtime = Runtime::default();
484 ui_of(&wheel, &runtime);
485 let flat = (VIEWPORT / EXTENT).ceil() as usize + 1;
486 assert!(
487 built.get() > flat,
488 "a wheel shows more rows than a flat strip: {} vs {flat}",
489 built.get()
490 );
491 assert!(
492 built.get() < 3 * flat,
493 "and not without limit: {} of three thousand",
494 built.get()
495 );
496 }
497
498 /// **The rows are not where a flat strip would put them.** The one thing that says
499 /// the geometry reached the paint rather than merely being computed: the rows either
500 /// side of the middle are drawn closer in than their own height, and shorter.
501 #[test]
502 fn the_paint_puts_the_rows_on_the_cylinder() {
503 let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
504 let runtime = Runtime::default();
505 let ui = ui_of(&wheel, &runtime);
506 // Every row's painted band, by the red channel the builder above numbered them
507 // with. Layers are transformed at compositing time, so the rects inside carry
508 // the flat geometry — what is asked here is the transform the layer holds.
509 let layers: Vec<_> = ui
510 .scene()
511 .primitives()
512 .iter()
513 .filter_map(|p| match p {
514 Primitive::Layer {
515 transform: Some(t), ..
516 } => Some(t.affine),
517 _ => None,
518 })
519 .collect();
520 assert!(
521 layers.len() >= 5,
522 "one layer per row on the cylinder: {}",
523 layers.len()
524 );
525 // `m` is `[a, b, c, d, e, f]`: `a` and `d` are the two scales. The middle row is
526 // the untouched one — at the centre the cylinder is tangent to the screen, so
527 // its transform is the identity to within rounding.
528 let flat = layers
529 .iter()
530 .filter(|m| (m.m[0] - 1.0).abs() < 1e-3 && (m.m[3] - 1.0).abs() < 1e-3)
531 .count();
532 assert_eq!(flat, 1, "exactly one row is face on");
533 // And nothing is stretched: every row is squashed or unchanged, never taller.
534 assert!(
535 layers.iter().all(|m| m.m[3] <= 1.0 + 1e-3),
536 "a cylinder turns rows away from the reader, never towards"
537 );
538 }
539
540 /// **A reader hears which value is chosen, in the caller's words.**
541 ///
542 /// A wheel is a one-dimensional selector rather than a list to walk row by row, so
543 /// it says so as one: the role a platform's own picker reports, the value spelled
544 /// out, and the position in the set as the range. That is also what makes the
545 /// change announced — it is the same path a slider's value travels.
546 #[test]
547 fn the_wheel_says_what_it_is_set_to() {
548 let wheel = wheel(60, 30, Rc::new(Cell::new(0))).label(|i| format!("{i} minutes"));
549 let runtime = Runtime::default();
550 let ui = ui_of(&wheel, &runtime);
551 let (_, _, props) = ui
552 .semantics()
553 .iter()
554 .find(|(_, _, s)| s.role == frus_core::Role::Slider)
555 .expect("the wheel announces itself as a selector");
556 assert_eq!(props.value.as_deref(), Some("30 minutes"));
557 assert_eq!(props.range, Some((0.0, 30.0, 59.0)), "30 of 60");
558
559 // And moving it says something else, which is the whole of "announced as it
560 // changes": the value a reader is given is a function of where the wheel is.
561 let moved = wheel_at(31);
562 let ui = ui_of(&moved, &Runtime::default());
563 let (_, _, props) = ui
564 .semantics()
565 .iter()
566 .find(|(_, _, s)| s.role == frus_core::Role::Slider)
567 .expect("still a selector");
568 assert_eq!(props.value.as_deref(), Some("31 minutes"));
569 }
570
571 /// A wheel with words on it, at a given row.
572 fn wheel_at(selected: usize) -> ListWheel<()> {
573 wheel(60, selected, Rc::new(Cell::new(0))).label(|i| format!("{i} minutes"))
574 }
575
576 /// Without words, a position is still better than silence — and is still not an
577 /// answer to "what is this set to". Said out loud so that nobody reads the fallback
578 /// as good enough.
579 #[test]
580 fn a_wheel_with_no_words_gives_back_a_position() {
581 let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
582 let ui = ui_of(&wheel, &Runtime::default());
583 let (_, _, props) = ui
584 .semantics()
585 .iter()
586 .find(|(_, _, s)| s.role == frus_core::Role::Slider)
587 .expect("a selector");
588 assert_eq!(props.value.as_deref(), Some("31 of 60"));
589 }
590
591 /// The geometry itself, at the three points worth pinning: the middle, a turn, and
592 /// the horizon.
593 #[test]
594 fn the_cylinder_packs_shrinks_and_ends() {
595 let g = WheelGeometry::default();
596 let radius = g.radius(VIEWPORT);
597
598 // Face on: nothing happens at all, which is what makes the chosen row the one
599 // that is drawn as it was written.
600 let middle = g.row(VIEWPORT, 0.0).expect("the middle is on the cylinder");
601 assert_eq!(middle.angle, 0.0);
602 assert_eq!(middle.offset, 0.0);
603 assert!((middle.scale_x - 1.0).abs() < 1e-6);
604 assert!((middle.scale_y - 1.0).abs() < 1e-6);
605 assert!((middle.opacity - 1.0).abs() < 1e-6);
606
607 // A row a third of a radian round: nearer the middle than the flat strip put it,
608 // shorter, narrower and fainter — each of those in the same direction.
609 let flat = radius / 3.0;
610 let turned = g.row(VIEWPORT, flat).expect("still in front");
611 assert!((turned.angle - 1.0 / 3.0).abs() < 1e-5);
612 assert!(
613 turned.offset < flat && turned.offset > 0.0,
614 "packed towards the middle: {} of {flat}",
615 turned.offset
616 );
617 assert!(turned.scale_y < 1.0 && turned.scale_y > 0.9);
618 assert!(turned.scale_x < 1.0);
619 assert!(turned.opacity < 1.0);
620
621 // Symmetric, because a wheel has no top and bottom of its own.
622 let mirrored = g.row(VIEWPORT, -flat).expect("still in front");
623 assert!((mirrored.offset + turned.offset).abs() < 1e-4);
624 assert!((mirrored.scale_y - turned.scale_y).abs() < 1e-6);
625
626 // A quarter turn is the horizon: exactly there and beyond it, a row is a line
627 // seen edge-on and is not drawn.
628 let horizon = radius * std::f32::consts::FRAC_PI_2;
629 assert!(g.row(VIEWPORT, horizon).is_none());
630 assert!(g.row(VIEWPORT, horizon * 1.5).is_none());
631 assert!(g.row(VIEWPORT, horizon * 0.99).is_some());
632 }
633
634 /// **A flatter wheel is a bigger cylinder**, and the numbers have to move that way
635 /// round: it is the one parameter a caller will reach for, and one whose sign is
636 /// backwards is worse than one that does not exist.
637 #[test]
638 fn a_larger_diameter_is_a_flatter_wheel() {
639 let flat_at = |ratio: f32| {
640 WheelGeometry {
641 diameter_ratio: ratio,
642 ..Default::default()
643 }
644 .row(VIEWPORT, 60.0)
645 .expect("in front")
646 };
647 let curved = flat_at(1.07);
648 let flatter = flat_at(6.0);
649 assert!(
650 flatter.angle < curved.angle,
651 "the same row has turned less: {} vs {}",
652 flatter.angle,
653 curved.angle
654 );
655 assert!(
656 flatter.scale_y > curved.scale_y,
657 "so it is squashed less: {} vs {}",
658 flatter.scale_y,
659 curved.scale_y
660 );
661 assert!(
662 flatter.offset > curved.offset,
663 "and packed towards the middle less: {} vs {}",
664 flatter.offset,
665 curved.offset
666 );
667 }
668}