spin.rs
17.0 kB · rust · 475 lines
1use crate::core::error::{shape_error, Result};2use crate::num::factor::gcd;3use serde::{Deserialize, Serialize};4use std::f64::consts::{PI, SQRT_2};56fn centre(size: usize) -> f64 {7 size as f64 / 2.08}910/// The radius of the corner circle of a square raster of the side, the last radius a profile reads.11pub fn reach(size: usize) -> f64 {12 size as f64 / SQRT_213}1415/// The arcs of the circle of the radius about the raster's centre: each as its start angle, end angle and the value of the one cell it lies in, zero outside.16///17/// ```18/// let arcs = mrlyrs::math::spin::arcs(&[1.0, 1.0, 1.0, 1.0], 2, 0.5).unwrap();19/// assert!(arcs.iter().all(|arc| arc.2 == 1.0));20/// ```21///22/// # Errors23///24/// Errors when the sample count is not the side squared.25pub fn arcs(data: &[f32], size: usize, radius: f64) -> Result<Vec<(f64, f64, f32)>> {26 if data.len() != size * size {27 return shape_error(format!(28 "a raster of side {size} needs {} samples, got {}.",29 size * size,30 data.len()31 ));32 }33 if size == 0 {34 return Ok(Vec::new());35 }36 let c = centre(size);37 let r = radius.max(1e-9);38 let mut cuts = vec![0.0, 2.0 * PI];39 let low = (c - r).floor().max(0.0) as usize;40 let high = ((c + r).ceil() as usize).min(size);41 for line in low..=high {42 let u = (line as f64 - c) / r;43 if u.abs() < 1.0 {44 let a = u.acos();45 cuts.push(a);46 cuts.push(2.0 * PI - a);47 let b = u.asin();48 cuts.push(b.rem_euclid(2.0 * PI));49 cuts.push((PI - b).rem_euclid(2.0 * PI));50 }51 }52 cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));53 let mut out = Vec::with_capacity(cuts.len());54 for pair in cuts.windows(2) {55 let (a, b) = (pair[0], pair[1]);56 if b <= a {57 continue;58 }59 let mid = (a + b) / 2.0;60 let x = c + r * mid.cos();61 let y = c + r * mid.sin();62 let mut value = 0.0;63 if x >= 0.0 && y >= 0.0 {64 let (col, row) = (x as usize, y as usize);65 if col < size && row < size {66 value = data[row * size + col];67 }68 }69 out.push((a, b, value));70 }71 Ok(out)72}7374/// The exact mean of a square raster over the circle of the radius about its centre, each cell read as a constant and the outside as zero.75///76/// ```77/// let solid = vec![1.0; 16];78/// assert!((mrlyrs::math::spin::ring(&solid, 4, 1.0).unwrap() - 1.0).abs() < 1e-12);79/// assert!(mrlyrs::math::spin::ring(&solid, 4, 3.0).unwrap().abs() < 1e-12);80/// ```81///82/// # Errors83///84/// Errors when the sample count is not the side squared.85pub fn ring(data: &[f32], size: usize, radius: f64) -> Result<f64> {86 Ok(arcs(data, size, radius)?87 .iter()88 .map(|&(a, b, v)| v as f64 * (b - a))89 .sum::<f64>()90 / (2.0 * PI))91}9293/// The circular-harmonic power of a raster: for every order `m` up to the last, the energy `sum |c_m(r)|^2 2 pi r dr` of its `m`-th harmonic over rings radii, each ring's coefficient exact from its arcs.94///95/// # Errors96///97/// Errors when the sample count is not the side squared.98pub fn harmonics(data: &[f32], size: usize, rings: usize, orders: usize) -> Result<Vec<f64>> {99 let rings = rings.max(2);100 let far = reach(size);101 let step = far / (rings - 1) as f64;102 let mut power = vec![0.0; orders + 1];103 for k in 0..rings {104 let r = k as f64 * step;105 let pieces = arcs(data, size, r)?;106 let mut re = vec![0.0; orders + 1];107 let mut im = vec![0.0; orders + 1];108 for &(a, b, v) in &pieces {109 if v == 0.0 {110 continue;111 }112 let v = v as f64;113 re[0] += v * (b - a);114 for m in 1..=orders {115 let f = m as f64;116 re[m] += v * ((f * b).sin() - (f * a).sin()) / f;117 im[m] += v * ((f * b).cos() - (f * a).cos()) / f;118 }119 }120 for m in 0..=orders {121 let (x, y) = (re[m] / (2.0 * PI), im[m] / (2.0 * PI));122 power[m] += (x * x + y * y) * 2.0 * PI * r * step;123 }124 }125 Ok(power)126}127128/// The rotation order a harmonic power spectrum reveals: the gcd of the orders carrying more than a ten-thousandth of the power, the share pixel aliasing stays under, or zero when none does.129pub fn turns(power: &[f64]) -> usize {130 let total: f64 = power.iter().sum();131 power132 .iter()133 .enumerate()134 .skip(1)135 .filter(|&(_, &p)| p > 1e-4 * total)136 .fold(0, |g, (m, _)| gcd(g as u128, m as u128) as usize)137}138139/// The petals a full radial stack of the copies shows on a design of the rotation order: their least common multiple.140pub fn petals(copies: usize, order: usize) -> usize {141 if copies == 0 || order == 0 {142 return 0;143 }144 copies / gcd(copies as u128, order as u128) as usize * order145}146147/// The way radial copies merge: their mean, their sum, their union, their meet, their parity or what the first keeps that no other has.148#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]149pub enum Blend {150 /// The mean of the copies.151 Mean,152 /// The sum of the copies.153 Sum,154 /// The largest copy.155 Union,156 /// The smallest copy.157 Meet,158 /// The sum folded to its parity.159 Parity,160 /// The first copy less the largest of the rest, floored at zero.161 Difference,162}163164impl Blend {165 /// Reads a blend by name: mean, sum, union, meet, parity or difference.166 pub fn named(name: &str) -> Option<Blend> {167 match name {168 "mean" => Some(Blend::Mean),169 "sum" => Some(Blend::Sum),170 "union" => Some(Blend::Union),171 "meet" => Some(Blend::Meet),172 "parity" => Some(Blend::Parity),173 "difference" => Some(Blend::Difference),174 _ => None,175 }176 }177178 /// Merges one site's copies into the blended value.179 pub fn fold(self, values: &[f32]) -> f32 {180 let sum: f32 = values.iter().sum();181 let max = values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);182 match self {183 Blend::Mean => sum / values.len() as f32,184 Blend::Sum => sum,185 Blend::Union => max,186 Blend::Meet => values.iter().cloned().fold(f32::INFINITY, f32::min),187 Blend::Parity => 1.0 - (sum.rem_euclid(2.0) - 1.0).abs(),188 Blend::Difference => {189 let rest = values[1..].iter().cloned().fold(0.0, f32::max);190 (values[0] - rest).max(0.0)191 }192 }193 }194}195196/// Stacks a raster radially: copies turned by multiples of the step, in turns, about the centre and merged by the blend, on an output raster of the side whose inscribed circle is the source's corner circle, every pixel the mean of samples by samples points.197///198/// # Errors199///200/// Errors when the sample count is not the side squared.201pub fn radial(202 data: &[f32],203 size: usize,204 out: usize,205 copies: usize,206 step: f64,207 blend: Blend,208 samples: usize,209) -> Result<Vec<f32>> {210 if data.len() != size * size {211 return shape_error(format!(212 "a raster of side {size} needs {} samples, got {}.",213 size * size,214 data.len()215 ));216 }217 let copies = copies.max(1);218 let samples = samples.max(1);219 let c = centre(size);220 let scale = 2.0 * reach(size) / out as f64;221 let turns: Vec<(f64, f64)> = (0..copies)222 .map(|k| {223 let angle = 2.0 * PI * step * k as f64;224 (angle.cos(), angle.sin())225 })226 .collect();227 let mut values = vec![0.0f32; copies];228 let mut field = Vec::with_capacity(out * out);229 for i in 0..out {230 for j in 0..out {231 let mut total = 0.0;232 for a in 0..samples {233 for b in 0..samples {234 let px =235 (j as f64 + (b as f64 + 0.5) / samples as f64 - out as f64 / 2.0) * scale;236 let py =237 (i as f64 + (a as f64 + 0.5) / samples as f64 - out as f64 / 2.0) * scale;238 for (k, &(cos, sin)) in turns.iter().enumerate() {239 let x = c + px * cos + py * sin;240 let y = c - px * sin + py * cos;241 values[k] = if x >= 0.0 && y >= 0.0 && x < size as f64 && y < size as f64 {242 data[y as usize * size + x as usize]243 } else {244 0.0245 };246 }247 total += blend.fold(&values);248 }249 }250 field.push(total / (samples * samples) as f32);251 }252 }253 Ok(field)254}255256/// The ring profile: the circle means at steps radii spaced evenly from the centre to the corner circle.257///258/// # Errors259///260/// Errors when the sample count is not the side squared.261pub fn profile(data: &[f32], size: usize, steps: usize) -> Result<Vec<f32>> {262 let steps = steps.max(2);263 let far = reach(size);264 (0..steps)265 .map(|k| Ok(ring(data, size, far * k as f64 / (steps - 1) as f64)? as f32))266 .collect()267}268269/// The wheel: a profile spread over a square raster of the side, the corner circle it ends on drawn as the inscribed circle, every pixel reading the profile at its own radius.270pub fn wheel(profile: &[f32], size: usize) -> Vec<f32> {271 let last = profile.len().saturating_sub(1);272 if last == 0 {273 return vec![profile.first().copied().unwrap_or(0.0); size * size];274 }275 let c = centre(size);276 let scale = 2.0 * last as f64 / size as f64;277 let mut out = Vec::with_capacity(size * size);278 for row in 0..size {279 for col in 0..size {280 let (dx, dy) = (col as f64 + 0.5 - c, row as f64 + 0.5 - c);281 let t = (dx * dx + dy * dy).sqrt() * scale;282 let i = (t.floor() as usize).min(last);283 let f = (t - i as f64) as f32;284 let value = if i == last {285 profile[last]286 } else {287 profile[i] * (1.0 - f) + profile[i + 1] * f288 };289 out.push(value);290 }291 }292 out293}294295/// The mass a profile carries, the trapezoid integral of `2 pi r F(r)` in cells of the raster it came from.296pub fn mass(profile: &[f32], size: usize) -> f64 {297 let last = profile.len().saturating_sub(1);298 if last == 0 {299 return 0.0;300 }301 let step = reach(size) / last as f64;302 let weight = |k: usize| 2.0 * PI * (k as f64 * step) * profile[k] as f64;303 let inner: f64 = (1..last).map(weight).sum();304 step * (inner + (weight(0) + weight(last)) / 2.0)305}306307/// The mass a profile carries inside the radius, the trapezoid integral of `2 pi r F(r)` from the centre out, in cells of the raster it came from.308///309/// ```310/// let solid = vec![1.0; 64];311/// let rings = mrlyrs::math::spin::profile(&solid, 8, 4000).unwrap();312/// let inner = mrlyrs::math::spin::mass_within(&rings, 8, 3.0);313/// assert!((inner / (9.0 * std::f64::consts::PI) - 1.0).abs() < 1e-3);314/// ```315pub fn mass_within(profile: &[f32], size: usize, radius: f64) -> f64 {316 let last = profile.len().saturating_sub(1);317 if last == 0 || radius <= 0.0 {318 return 0.0;319 }320 let step = reach(size) / last as f64;321 let weight = |k: usize| 2.0 * PI * (k as f64 * step) * profile[k] as f64;322 let full = (radius / step).floor().min(last as f64) as usize;323 let mut total = 0.0;324 for k in 1..=full {325 total += (weight(k - 1) + weight(k)) / 2.0 * step;326 }327 if full < last {328 let rest = radius - full as f64 * step;329 let share = rest / step;330 let edge = weight(full) + (weight(full + 1) - weight(full)) * share;331 total += (weight(full) + edge) / 2.0 * rest;332 }333 total334}335336#[cfg(test)]337mod tests {338 use super::*;339 use crate::math::atoms;340341 fn floats(grid: &crate::core::tensor::Tensor) -> Vec<f32> {342 grid.bytes().unwrap().iter().map(|&b| b as f32).collect()343 }344345 #[test]346 fn a_solid_square_leaves_through_four_arcs() {347 let side = 8usize;348 let solid = floats(&atoms::ones_2d(side));349 for r in [0.5, 2.0, 3.99] {350 assert!((ring(&solid, side, r).unwrap() - 1.0).abs() < 1e-12);351 }352 for r in [4.5, 5.0, 5.5] {353 let expect = 1.0 - 4.0 / PI * (side as f64 / 2.0 / r).acos();354 assert!((ring(&solid, side, r).unwrap() - expect).abs() < 1e-12);355 }356 assert!(ring(&solid, side, reach(side) + 0.01).unwrap().abs() < 1e-12);357 }358359 #[test]360 fn the_carpet_opens_on_a_black_disc() {361 let carpet = atoms::carpet_nd(3, 2).kron(&atoms::carpet_nd(3, 2));362 let data = floats(&carpet);363 assert!(ring(&data, 9, 1.4).unwrap().abs() < 1e-12);364 assert!(ring(&data, 9, 2.0).unwrap() > 0.0);365 assert!((ring(&data, 9, 1.0).unwrap() - 0.0).abs() < 1e-12);366 }367368 #[test]369 fn the_mass_of_the_profile_is_the_fill() {370 let carpet = atoms::carpet_nd(3, 2)371 .kron(&atoms::carpet_nd(3, 2))372 .kron(&atoms::carpet_nd(3, 2));373 let data = floats(&carpet);374 let fills = data.iter().sum::<f32>() as f64;375 assert_eq!(fills, 512.0);376 let rings = profile(&data, 27, 4000).unwrap();377 assert!((mass(&rings, 27) - fills).abs() / fills < 0.002);378 }379380 #[test]381 fn the_spin_mass_scales_by_the_fill_about_a_filled_corner() {382 let tile = atoms::carpet_nd(3, 2);383 let carpet = tile.kron(&tile).kron(&tile).kron(&tile);384 let side = 81usize;385 let wide = 2 * side;386 let mut data = vec![0.0f32; wide * wide];387 let bytes = carpet.bytes().unwrap();388 for row in 0..side {389 for col in 0..side {390 data[(row + side) * wide + col + side] = bytes[row * side + col] as f32;391 }392 }393 let rings = profile(&data, wide, 4000).unwrap();394 assert!(mass_within(&rings, wide, 1e-6).abs() < 1e-9);395 for radius in [12.0f64, 18.0, 27.0] {396 let near = mass_within(&rings, wide, radius);397 let far = mass_within(&rings, wide, 3.0 * radius);398 assert!(near > 0.0);399 assert!((far / (8.0 * near) - 1.0).abs() < 0.08, "radius {radius}");400 }401 let whole = mass_within(&rings, wide, reach(wide));402 assert!((whole - mass(&rings, wide)).abs() / whole < 1e-9);403 }404405 #[test]406 fn the_wheel_reads_the_profile_by_radius() {407 let rings = profile(&floats(&atoms::ones_2d(8)), 8, 64).unwrap();408 let spun = wheel(&rings, 16);409 assert_eq!(spun.len(), 256);410 assert!((spun[8 * 16 + 8] - 1.0).abs() < 1e-6);411 assert!((spun[8 * 16 + 12] - 1.0).abs() < 1e-6);412 assert!(spun[8 * 16 + 15] < 0.2);413 assert_eq!(spun[0], 0.0);414 assert_eq!(profile(&[], 0, 0).unwrap().len(), 2);415 }416417 fn at(field: &[f32], out: usize, size: usize, px: f64, py: f64) -> f32 {418 let scale = 2.0 * reach(size) / out as f64;419 let col = (px / scale + out as f64 / 2.0) as usize;420 let row = (py / scale + out as f64 / 2.0) as usize;421 field[row * out + col]422 }423424 #[test]425 fn two_squares_at_an_eighth_turn_make_a_star() {426 let solid = floats(&atoms::ones_2d(8));427 let stack = |blend| radial(&solid, 8, 64, 2, 0.125, blend, 1).unwrap();428 let probe = |blend, px, py| at(&stack(blend), 64, 8, px, py);429 assert_eq!(probe(Blend::Union, 0.0, 0.0), 1.0);430 assert_eq!(probe(Blend::Union, 0.0, -5.0), 1.0);431 assert_eq!(probe(Blend::Meet, 0.0, -5.0), 0.0);432 assert_eq!(probe(Blend::Mean, 0.0, -5.0), 0.5);433 assert_eq!(probe(Blend::Sum, 0.0, 0.0), 2.0);434 assert_eq!(probe(Blend::Parity, 0.0, -5.0), 1.0);435 assert_eq!(probe(Blend::Parity, 0.0, 0.0), 0.0);436 assert_eq!(probe(Blend::Difference, 0.0, -5.0), 0.0);437 assert_eq!(probe(Blend::Difference, 3.8, -3.8), 1.0);438 assert_eq!(Blend::named("soup"), None);439 }440441 #[test]442 fn the_harmonics_read_the_rotation_order() {443 let square = harmonics(&floats(&atoms::ones_2d(8)), 8, 128, 12).unwrap();444 assert!(square[0] > 0.0);445 assert!(square[4] > 1e-3 * square[0]);446 assert!(square[8] > 1e-3 * square[0]);447 for m in [1, 2, 3, 5, 6, 7, 9, 10, 11] {448 assert!(square[m] < 1e-9 * square[0], "m {m}");449 }450 assert_eq!(turns(&square), 4);451 let mut bar = vec![0.0f32; 16];452 for i in [5, 6, 9, 10, 4, 11] {453 bar[i] = 1.0;454 }455 assert_eq!(turns(&harmonics(&bar, 4, 64, 8).unwrap()), 2);456 let mut blob = vec![0.0f32; 16];457 blob[0] = 1.0;458 assert_eq!(turns(&harmonics(&blob, 4, 64, 8).unwrap()), 1);459 assert_eq!(turns(&[1.0, 0.0, 0.0]), 0);460 assert_eq!(461 (petals(6, 4), petals(8, 4), petals(5, 1), petals(3, 0)),462 (12, 8, 5, 0)463 );464 }465466 #[test]467 fn refuses_a_raster_that_is_not_the_side_squared() {468 let short = vec![1.0f32; 15];469 assert!(arcs(&short, 4, 1.0).is_err());470 assert!(ring(&short, 4, 1.0).is_err());471 assert!(harmonics(&short, 4, 8, 4).is_err());472 assert!(profile(&short, 4, 8).is_err());473 assert!(radial(&short, 4, 4, 2, 0.5, Blend::Mean, 1).is_err());474 }475}