tourbillon.rs
25.2 kB · rust · 712 lines
1use crate::core::error::{value_error, Result};2use crate::core::Rng;3use crate::math::spin::Blend;4use crate::num::factor::{factorize, gcd, mobius, squarefree};5use crate::num::prime::{is_prime, primes, squares};6use serde::{Deserialize, Serialize};78const SCALE_CAP: usize = 199;9const SIZE_FLOOR: usize = 16;10const SIZE_CAP: usize = 1024;11const SHOWN: usize = 8;12const PEAKS: usize = 3;13const LADDER: usize = 1000;14const PLANE_CAP: usize = 64_000_000;15const PERIOD_CAP: usize = 360;16const TIGHT: f64 = 1e-9;1718// THE LAYERS1920/// One carpet of a stack: the odd scale it is drawn at, the weight the linear blends carry it at and the turn it takes about the centre, in degrees.21#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]22pub struct Layer {23 /// The odd scale, the number of cells across the carpet.24 pub scale: usize,25 /// The weight, scaled so the magnitudes average one.26 pub weight: f64,27 /// The turn about the centre, in degrees.28 pub degrees: f64,29}3031/// The readings of a spun stack: its layers, the first eight scales and angles, the mean and RMS contrast over the disc, that contrast times the root of the layer count, the exact centre value, whether the blend carries the weights, the span the raster covers, the sites inside the disc and the brightest three.32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]33pub struct Stats {34 /// The count of layers in the stack.35 pub layers: usize,36 /// The first eight scales.37 pub scales: Vec<usize>,38 /// The first eight angles, in degrees.39 pub angles: Vec<f64>,40 /// The mean over the disc.41 pub mean: f64,42 /// The RMS contrast over the disc.43 pub rms: f64,44 /// The RMS contrast times the root of the layer count.45 pub faded: f64,46 /// The exact value at the centre, computed and never sampled.47 pub centre: f64,48 /// Whether the blend carries the weights.49 pub weighted: bool,50 /// The smallest value over the disc.51 pub low: f64,52 /// The largest value over the disc.53 pub high: f64,54 /// The count of sites inside the disc.55 pub inside: usize,56 /// The brightest three sites, each as its unit coordinates and its value.57 pub peaks: Vec<[f64; 3]>,58 /// The least whole number of increments that closes a quarter turn, none past the cap.59 pub period: Option<usize>,60 /// The count of distinct angle classes the layers fall in, read a quarter turn apart.61 pub classes: usize,62 /// The count of layer pairs sharing an angle class.63 pub pairs: usize,64}6566/// One angle of the quarter-turn lattice: the turn in degrees and the ninety a over q that names it.67#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]68pub struct Eye {69 /// The turn in degrees.70 pub angle: f64,71 /// The numerator, ninety times a.72 pub numer: usize,73 /// The denominator q.74 pub denom: usize,75}7677fn golden() -> f64 {78 180.0 * (3.0 - 5f64.sqrt())79}8081// THE QUARTER TURN8283/// The angles a quarter turn shares with itself: ninety a over q for every q up to the cap and every a from zero to four q coprime to it, sorted by angle.84///85/// The odd carpet is unchanged by a quarter turn, so two layers whose angles agree modulo ninety stand on one exact lattice of nodes and the stack stops moving there.86///87/// ```88/// let list = mrlyrs::math::tourbillon::eyes(2);89/// assert_eq!(list.len(), 9);90/// assert_eq!(list[1].angle, 45.0);91/// ```92pub fn eyes(qmax: usize) -> Vec<Eye> {93 let mut out = Vec::new();94 for denom in 1..=qmax {95 for step in 0..=4 * denom {96 if gcd(step as u128, denom as u128) != 1 {97 continue;98 }99 out.push(Eye {100 angle: 90.0 * step as f64 / denom as f64,101 numer: 90 * step,102 denom,103 });104 }105 }106 out.sort_by(|a, b| a.angle.total_cmp(&b.angle));107 out108}109110/// The least whole number of increments that closes a quarter turn, none once the count passes the cap.111///112/// ```113/// assert_eq!(mrlyrs::math::tourbillon::period(18.0), Some(5));114/// assert_eq!(mrlyrs::math::tourbillon::period(1.0), Some(90));115/// ```116pub fn period(increment: f64) -> Option<usize> {117 (1..=PERIOD_CAP).find(|count| {118 let turns = *count as f64 * increment / 90.0;119 (turns - turns.round()).abs() <= TIGHT120 })121}122123fn quarter(degrees: f64) -> f64 {124 let angle = degrees.rem_euclid(90.0);125 if 90.0 - angle <= TIGHT {126 0.0127 } else {128 angle129 }130}131132/// The angle classes of a stack read a quarter turn apart: how many the layers fall in, and how many layer pairs share one.133pub fn sharing(list: &[Layer]) -> (usize, usize) {134 let mut angles: Vec<f64> = list.iter().map(|layer| quarter(layer.degrees)).collect();135 angles.sort_by(f64::total_cmp);136 let mut counts: Vec<usize> = Vec::new();137 let mut held = f64::NEG_INFINITY;138 for angle in angles {139 match counts.last_mut() {140 Some(count) if angle - held <= TIGHT => *count += 1,141 _ => {142 counts.push(1);143 held = angle;144 }145 }146 }147 let pairs = counts.iter().map(|count| count * (count - 1) / 2).sum();148 (counts.len(), pairs)149}150151fn odd_top(top: usize) -> Result<usize> {152 if !(3..=SCALE_CAP).contains(&top) || top.is_multiple_of(2) {153 return value_error(format!(154 "the top scale must be odd between 3 and {SCALE_CAP}."155 ));156 }157 Ok(top)158}159160fn side(size: usize) -> Result<usize> {161 if !(SIZE_FLOOR..=SIZE_CAP).contains(&size) {162 return value_error(format!(163 "the raster must be between {SIZE_FLOOR} and {SIZE_CAP} pixels."164 ));165 }166 Ok(size)167}168169fn blend_of(name: &str) -> Result<Blend> {170 match Blend::named(name) {171 Some(blend) => Ok(blend),172 None => value_error(format!(173 "blend {name:?} is not mean, sum, union, meet, parity or difference."174 )),175 }176}177178fn linear(blend: Blend) -> bool {179 matches!(blend, Blend::Mean | Blend::Sum)180}181182fn chosen(scale: usize, set: &str) -> Result<bool> {183 match set {184 "odd" => Ok(true),185 "primes" => Ok(is_prime(scale)),186 "squarefree" => Ok(squarefree(scale)),187 "prime powers" => Ok(factorize(scale).len() == 1),188 _ => value_error(format!(189 "layer set {set:?} is not odd, primes, squarefree or prime powers."190 )),191 }192}193194fn weight_of(scale: usize, weights: &str) -> Result<f64> {195 match weights {196 "plain" => Ok(1.0),197 "mobius" => Ok(f64::from(mobius(scale))),198 "harmonic" => Ok(1.0 / scale as f64),199 _ => value_error(format!(200 "weights {weights:?} are not plain, mobius or harmonic."201 )),202 }203}204205/// The layers of a stack: every scale one, three, five up to the top the set keeps, each with its weight and its angle.206///207/// The schedule is unspun, degrees, golden, primes, random or gaussian, the layer set is odd, primes, squarefree or prime powers and the weights are plain, mobius or harmonic. The weights are rescaled so their magnitudes sum to the layer count, so the mean blend stays paper coverage and the sum blend stays the inked layer count.208///209/// # Errors210///211/// Errors at an even top or one out of range, or for a schedule, set or weights name the stack does not know.212pub fn layers(213 top: usize,214 schedule: &str,215 increment: f64,216 set: &str,217 weights: &str,218 seed: u32,219) -> Result<Vec<Layer>> {220 let top = odd_top(top)?;221 let ladder = primes(LADDER);222 let mut rng = Rng::new(u64::from(seed));223 let mut out: Vec<Layer> = Vec::new();224 let mut index = 0usize;225 for scale in (1..=top).step_by(2) {226 if !chosen(scale, set)? {227 continue;228 }229 index += 1;230 let degrees = match schedule {231 "unspun" => 0.0,232 "degrees" => index as f64 * increment,233 "golden" => index as f64 * golden(),234 "primes" => match index {235 1 => 0.0,236 _ => ladder.get(index - 2).copied().unwrap_or(0) as f64,237 },238 "random" => rng.unit() * 360.0,239 "gaussian" => {240 squares(scale).map_or(0.0, |(a, b)| (b as f64).atan2(a as f64).to_degrees())241 }242 _ => {243 return value_error(format!(244 "schedule {schedule:?} is not unspun, degrees, golden, primes, random or gaussian."245 ))246 }247 };248 out.push(Layer {249 scale,250 weight: weight_of(scale, weights)?,251 degrees,252 });253 }254 let mass: f64 = out.iter().map(|layer| layer.weight.abs()).sum();255 if mass > 0.0 {256 let factor = out.len() as f64 / mass;257 for layer in &mut out {258 layer.weight *= factor;259 }260 }261 Ok(out)262}263264fn lit(scale: usize) -> f32 {265 f32::from(u8::from(((scale as f64 * 0.5).floor() as i64) & 1 == 1))266}267268fn sample(list: &[Layer], blend: Blend) -> Vec<f32> {269 list.iter()270 .map(|layer| {271 let ink = lit(layer.scale);272 if linear(blend) {273 layer.weight as f32 * ink274 } else {275 ink276 }277 })278 .collect()279}280281// THE RASTER282283fn disc(size: usize) -> Vec<bool> {284 let mut out = vec![false; size * size];285 for row in 0..size {286 let v = (row as f64 + 0.5) / size as f64 - 0.5;287 for column in 0..size {288 let u = (column as f64 + 0.5) / size as f64 - 0.5;289 out[row * size + column] = u * u + v * v <= 0.25;290 }291 }292 out293}294295fn cells(layer: &Layer, size: usize, inside: &[bool], ink: &mut [u8]) {296 let (sin, cos) = layer.degrees.to_radians().sin_cos();297 let scale = layer.scale as f64;298 let step = 1.0 / size as f64;299 let (dx, dy) = (cos * step, -sin * step);300 let edge = 0.5 * step - 0.5;301 for row in 0..size {302 let v = (row as f64 + 0.5) * step - 0.5;303 let mut x = cos * edge + sin * v + 0.5;304 let mut y = cos * v - sin * edge + 0.5;305 for column in 0..size {306 let at = row * size + column;307 let held = inside[at] && (0.0..1.0).contains(&x) && (0.0..1.0).contains(&y);308 ink[at] = u8::from(309 held && (scale * x).floor() as i64 & 1 == 1 && (scale * y).floor() as i64 & 1 == 1,310 );311 x += dx;312 y += dy;313 }314 }315}316317fn edges(size: usize, inside: &[bool], ink: &[u8], mark: &mut [u8]) {318 for row in 0..size {319 for column in 0..size {320 let at = row * size + column;321 if !inside[at] {322 mark[at] = 0;323 continue;324 }325 let right = column + 1 < size && inside[at + 1] && ink[at + 1] != ink[at];326 let below = row + 1 < size && inside[at + size] && ink[at + size] != ink[at];327 mark[at] = u8::from(right || below);328 }329 }330}331332fn corners(layer: &Layer, size: usize, inside: &[bool], mark: &mut [u8]) {333 mark.fill(0);334 let (sin, cos) = layer.degrees.to_radians().sin_cos();335 let scale = layer.scale;336 for down in (1..scale).step_by(2) {337 for across in (1..scale).step_by(2) {338 for (vx, vy) in [339 (across, down),340 (across + 1, down),341 (across, down + 1),342 (across + 1, down + 1),343 ] {344 let x = vx as f64 / scale as f64 - 0.5;345 let y = vy as f64 / scale as f64 - 0.5;346 let u = cos * x - sin * y;347 let v = sin * x + cos * y;348 if u * u + v * v > 0.25 {349 continue;350 }351 let column = ((u + 0.5) * size as f64 - 0.5).round() as i64;352 let row = ((v + 0.5) * size as f64 - 0.5).round() as i64;353 for dy in -1..=1i64 {354 for dx in -1..=1i64 {355 let (a, b) = (column + dx, row + dy);356 if a < 0 || b < 0 || a >= size as i64 || b >= size as i64 {357 continue;358 }359 let at = b as usize * size + a as usize;360 if inside[at] {361 mark[at] = 1;362 }363 }364 }365 }366 }367 }368}369370/// Rasters the layers onto a square of the size, every one turned about the centre by its own angle and masked to the inscribed disc, then merged site by site.371///372/// The render mode is cells, the lit squares of the carpet, edges, the boundaries between its cells, or corners, the vertices of its lit squares. The weights ride on the layers under the linear blends only. Sites outside the disc are NaN.373///374/// # Errors375///376/// Errors for a mode that is not cells, edges or corners, or a raster past the site budget.377pub fn stack(list: &[Layer], size: usize, mode: &str, blend: Blend) -> Result<Vec<f32>> {378 if !["cells", "edges", "corners"].contains(&mode) {379 return value_error(format!(380 "render mode {mode:?} is not cells, edges or corners."381 ));382 }383 if list.len() * size * size > PLANE_CAP {384 return value_error(format!(385 "{} layers on a {size} raster passes the {PLANE_CAP} site budget.",386 list.len()387 ));388 }389 let inside = disc(size);390 let mut out = vec![f32::NAN; size * size];391 if list.is_empty() {392 return Ok(out);393 }394 let mut ink = vec![0u8; size * size];395 let mut planes: Vec<Vec<u8>> = Vec::with_capacity(list.len());396 for layer in list {397 let mut mark = vec![0u8; size * size];398 match mode {399 "corners" => corners(layer, size, &inside, &mut mark),400 "edges" => {401 cells(layer, size, &inside, &mut ink);402 edges(size, &inside, &ink, &mut mark);403 }404 _ => cells(layer, size, &inside, &mut mark),405 }406 planes.push(mark);407 }408 let scaled: Vec<f32> = list409 .iter()410 .map(|layer| {411 if linear(blend) {412 layer.weight as f32413 } else {414 1.0415 }416 })417 .collect();418 let mut buffer = vec![0f32; list.len()];419 for at in 0..size * size {420 if !inside[at] {421 continue;422 }423 for (slot, plane) in planes.iter().enumerate() {424 buffer[slot] = scaled[slot] * f32::from(plane[at]);425 }426 out[at] = blend.fold(&buffer);427 }428 Ok(out)429}430431fn peaks(field: &[f32], size: usize, top: usize) -> Vec<[f64; 3]> {432 let block = (size / top.max(1)).max(1);433 let wide = size.div_ceil(block);434 let mut best: Vec<(f64, usize, usize)> = Vec::with_capacity(wide * wide);435 for tile in 0..wide * wide {436 let (bx, by) = (tile % wide * block, tile / wide * block);437 let mut hit: Option<(f64, usize, usize)> = None;438 for row in by..(by + block).min(size) {439 for column in bx..(bx + block).min(size) {440 let value = field[row * size + column];441 if value.is_nan() {442 continue;443 }444 let value = f64::from(value);445 if hit.is_none_or(|(seen, _, _)| value > seen) {446 hit = Some((value, column, row));447 }448 }449 }450 if let Some(found) = hit {451 best.push(found);452 }453 }454 best.sort_by(|a, b| b.0.total_cmp(&a.0));455 let apart = block as f64 / size as f64;456 let mut out: Vec<[f64; 3]> = Vec::new();457 for (value, column, row) in best {458 let x = (column as f64 + 0.5) / size as f64;459 let y = (row as f64 + 0.5) / size as f64;460 if out461 .iter()462 .all(|site| (site[0] - x).hypot(site[1] - y) >= apart)463 {464 out.push([x, y, value]);465 }466 if out.len() == PEAKS {467 break;468 }469 }470 out471}472473// THE STACK474475/// Spins the odd parity carpets at the scales one, three, five up to the top into one stack on a square of the size, every layer turned about the centre by its own angle and masked to the inscribed disc, so every pixel sees every layer.476///477/// The schedule is unspun, degrees, golden, primes, random or gaussian, the layer set is odd, primes, squarefree or prime powers, the weights are plain, mobius or harmonic, the render mode is cells, edges or corners and the blend is mean, sum, union, meet, parity or difference. The weights are scaled to average magnitude one and apply only under the linear blends, so mean is paper coverage and sum is the inked layer count. Sites outside the disc are NaN.478///479/// # Errors480///481/// Errors at a raster or top out of range, or for a schedule, set, weights or blend name the stack does not know.482#[allow(clippy::too_many_arguments)]483pub fn field(484 top: usize,485 size: usize,486 schedule: &str,487 increment: f64,488 set: &str,489 weights: &str,490 mode: &str,491 blend: &str,492 seed: u32,493) -> Result<Vec<f32>> {494 let size = side(size)?;495 let blend = blend_of(blend)?;496 let list = layers(top, schedule, increment, set, weights, seed)?;497 stack(&list, size, mode, blend)498}499500/// Reads a spun stack against the schedule that made it: the layer count, the first eight scales and angles, the mean and RMS contrast over the disc, that contrast times the root of the layer count, the exact centre value, whether the blend carries the weights, the span the raster covers and the brightest three sites.501///502/// The centre is computed from the layers at the exact half-half point, never sampled off the raster, so no turn of the layers moves it.503///504/// # Errors505///506/// Errors when the field is not size by size, or a name the stack does not know.507#[allow(clippy::too_many_arguments)]508pub fn stats(509 field: &[f32],510 size: usize,511 top: usize,512 schedule: &str,513 increment: f64,514 set: &str,515 weights: &str,516 blend: &str,517 seed: u32,518) -> Result<Stats> {519 let size = side(size)?;520 if field.len() != size * size {521 return value_error("the field must be size by size with size at least 1.");522 }523 let blend = blend_of(blend)?;524 let list = layers(top, schedule, increment, set, weights, seed)?;525 let seen: Vec<f64> = field526 .iter()527 .filter(|value| !value.is_nan())528 .map(|value| f64::from(*value))529 .collect();530 let count = seen.len().max(1) as f64;531 let mean = seen.iter().sum::<f64>() / count;532 let rms = (seen.iter().map(|value| (value - mean).powi(2)).sum::<f64>() / count).sqrt();533 let low = seen.iter().cloned().fold(f64::INFINITY, f64::min);534 let high = seen.iter().cloned().fold(f64::NEG_INFINITY, f64::max);535 let centre = if list.is_empty() {536 0.0537 } else {538 f64::from(blend.fold(&sample(&list, blend)))539 };540 let (classes, pairs) = sharing(&list);541 Ok(Stats {542 layers: list.len(),543 scales: list.iter().take(SHOWN).map(|layer| layer.scale).collect(),544 angles: list.iter().take(SHOWN).map(|layer| layer.degrees).collect(),545 mean,546 rms,547 faded: rms * (list.len() as f64).sqrt(),548 centre,549 weighted: linear(blend),550 low: if low.is_finite() { low } else { 0.0 },551 high: if high.is_finite() { high } else { 0.0 },552 inside: seen.len(),553 peaks: peaks(field, size, top),554 period: period(increment),555 classes,556 pairs,557 })558}559560#[cfg(test)]561mod tests {562 use super::*;563564 #[test]565 fn the_schedule_keeps_the_odd_scales_and_turns_them_by_the_primes() {566 let odd = layers(55, "primes", 0.0, "odd", "plain", 1).unwrap();567 assert_eq!(odd.len(), 28);568 let scales: Vec<usize> = odd.iter().take(SHOWN).map(|layer| layer.scale).collect();569 assert_eq!(scales, vec![1, 3, 5, 7, 9, 11, 13, 15]);570 let angles: Vec<f64> = odd.iter().take(SHOWN).map(|layer| layer.degrees).collect();571 assert_eq!(angles, vec![0.0, 2.0, 3.0, 5.0, 7.0, 11.0, 13.0, 17.0]);572 assert_eq!(573 layers(199, "unspun", 0.0, "primes", "plain", 1)574 .unwrap()575 .len(),576 45577 );578 let counts: Vec<usize> = ["odd", "primes", "squarefree", "prime powers"]579 .iter()580 .map(|set| layers(55, "unspun", 0.0, set, "plain", 1).unwrap().len())581 .collect();582 assert_eq!(counts, vec![28, 15, 23, 19]);583 assert!(layers(54, "unspun", 0.0, "odd", "plain", 1).is_err());584 assert!(layers(201, "unspun", 0.0, "odd", "plain", 1).is_err());585 assert!(layers(55, "spiral", 0.0, "odd", "plain", 1).is_err());586 assert!(layers(55, "unspun", 0.0, "even", "plain", 1).is_err());587 assert!(layers(55, "unspun", 0.0, "odd", "zeta", 1).is_err());588 }589590 #[test]591 fn the_unspun_stack_counts_eighteen_layers_at_its_brightest() {592 let list = layers(55, "unspun", 0.0, "odd", "plain", 1).unwrap();593 let raster = stack(&list, 512, "cells", Blend::Sum).unwrap();594 assert_eq!(raster.len(), 512 * 512);595 let high = raster596 .iter()597 .filter(|value| !value.is_nan())598 .cloned()599 .fold(f32::NEG_INFINITY, f32::max);600 assert_eq!(high, 18.0);601 assert!(raster[0].is_nan());602 let read = stats(&raster, 512, 55, "unspun", 0.0, "odd", "plain", "sum", 1).unwrap();603 assert_eq!((read.high, read.low), (18.0, 0.0));604 assert!(stack(&list, 512, "dots", Blend::Sum).is_err());605 }606607 #[test]608 fn the_field_and_the_stats_hold_the_centre_at_half_the_layers() {609 let raster = field(55, 512, "unspun", 0.0, "odd", "plain", "cells", "mean", 1).unwrap();610 let read = stats(&raster, 512, 55, "unspun", 0.0, "odd", "plain", "mean", 1).unwrap();611 assert_eq!(read.layers, 28);612 assert_eq!(read.centre, 14.0 / 28.0);613 assert_eq!(format!("{:.6}", read.high), format!("{:.6}", 18.0 / 28.0));614 assert!(read.weighted);615 let counted = stats(&raster, 512, 55, "unspun", 0.0, "odd", "plain", "sum", 1).unwrap();616 assert_eq!(counted.centre, 14.0);617 let folded = stats(&raster, 512, 55, "unspun", 0.0, "odd", "plain", "parity", 1).unwrap();618 assert_eq!(folded.centre, 0.0);619 assert!(!folded.weighted);620 assert!(field(55, 4096, "unspun", 0.0, "odd", "plain", "cells", "mean", 1).is_err());621 assert!(field(55, 512, "unspun", 0.0, "odd", "plain", "cells", "blur", 1).is_err());622 assert!(stats(&raster, 256, 55, "unspun", 0.0, "odd", "plain", "mean", 1).is_err());623 let odd = field(51, 64, "unspun", 0.0, "odd", "plain", "cells", "sum", 1).unwrap();624 assert_eq!(625 stats(&odd, 64, 51, "unspun", 0.0, "odd", "plain", "sum", 1)626 .unwrap()627 .centre,628 13.0629 );630 assert_eq!(631 stats(&odd, 64, 51, "unspun", 0.0, "odd", "plain", "parity", 1)632 .unwrap()633 .centre,634 1.0635 );636 }637638 #[test]639 fn no_turn_of_the_layers_moves_the_centre() {640 let centre = |schedule: &str, increment: f64| {641 let raster = field(642 55, 64, schedule, increment, "odd", "plain", "cells", "mean", 1,643 )644 .unwrap();645 stats(646 &raster, 64, 55, schedule, increment, "odd", "plain", "mean", 1,647 )648 .unwrap()649 .centre650 };651 for step in 0..8 {652 assert_eq!(centre("degrees", f64::from(step)), 14.0 / 28.0);653 }654 for schedule in ["golden", "primes", "random", "gaussian"] {655 assert_eq!(centre(schedule, 0.0), 14.0 / 28.0);656 }657 }658659 #[test]660 fn the_three_modes_read_the_same_layers_apart() {661 let shares: Vec<String> = ["cells", "edges", "corners"]662 .iter()663 .map(|mode| {664 let raster =665 field(55, 256, "golden", 0.0, "odd", "plain", mode, "mean", 1).unwrap();666 let read =667 stats(&raster, 256, 55, "golden", 0.0, "odd", "plain", "mean", 1).unwrap();668 format!("{:.6}", read.mean)669 })670 .collect();671 assert_eq!(shares.join(" "), "0.231722 0.111924 0.141211");672 }673674 #[test]675 fn the_quarter_turn_lattice_lists_its_angles_smallest_first() {676 let list = eyes(12);677 assert_eq!(list.len(), 185);678 let first: Vec<String> = list679 .iter()680 .take(8)681 .map(|eye| format!("{:.6}", eye.angle))682 .collect();683 assert_eq!(684 first.join(" "),685 "0.000000 7.500000 8.181818 9.000000 10.000000 11.250000 12.857143 15.000000"686 );687 assert_eq!((list[1].numer, list[1].denom), (90, 12));688 assert!(list.windows(2).all(|pair| pair[0].angle <= pair[1].angle));689 }690691 #[test]692 fn a_ninetieth_increment_folds_the_layers_into_angle_classes() {693 let read = |increment: f64, schedule: &str| {694 let raster = field(695 55, 64, schedule, increment, "odd", "plain", "cells", "mean", 1,696 )697 .unwrap();698 stats(699 &raster, 64, 55, schedule, increment, "odd", "plain", "mean", 1,700 )701 .unwrap()702 };703 let coarse = read(18.0, "degrees");704 assert_eq!(coarse.period, Some(5));705 assert_eq!((coarse.classes, coarse.pairs), (5, 65));706 let fine = read(7.5, "degrees");707 assert_eq!(fine.period, Some(12));708 assert_eq!(fine.classes, 12);709 let loose = read(0.0, "golden");710 assert_eq!((loose.classes, loose.pairs), (28, 0));711 }712}