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}