#![forbid(unsafe_code)]
use crate::visual_fx::FxQuality;
#[inline]
pub const fn cell_to_normalized(cell: u16, total: u16) -> f64 {
if total == 0 {
0.5
} else {
(cell as f64 + 0.5) / total as f64
}
}
#[inline]
pub fn fill_normalized_coords(total: u16, out: &mut [f64]) {
assert!(
out.len() >= total as usize,
"output slice too small: {} < {}",
out.len(),
total
);
if total == 0 {
return;
}
let inv = 1.0 / total as f64;
for i in 0..total {
out[i as usize] = (i as f64 + 0.5) * inv;
}
}
#[derive(Debug, Clone)]
pub struct CoordCache {
x_coords: Vec<f64>,
y_coords: Vec<f64>,
width: u16,
height: u16,
}
impl CoordCache {
#[inline]
pub fn new(width: u16, height: u16) -> Self {
let mut x_coords = vec![0.0; width as usize];
let mut y_coords = vec![0.0; height as usize];
fill_normalized_coords(width, &mut x_coords);
fill_normalized_coords(height, &mut y_coords);
Self {
x_coords,
y_coords,
width,
height,
}
}
#[inline]
pub fn ensure_size(&mut self, width: u16, height: u16) {
if width > self.width {
self.x_coords.resize(width as usize, 0.0);
fill_normalized_coords(width, &mut self.x_coords);
self.width = width;
}
if height > self.height {
self.y_coords.resize(height as usize, 0.0);
fill_normalized_coords(height, &mut self.y_coords);
self.height = height;
}
}
#[inline]
pub fn x(&self, cell: u16) -> f64 {
self.x_coords.get(cell as usize).copied().unwrap_or(0.5)
}
#[inline]
pub fn y(&self, cell: u16) -> f64 {
self.y_coords.get(cell as usize).copied().unwrap_or(0.5)
}
#[inline]
pub fn x_coords(&self) -> &[f64] {
&self.x_coords
}
#[inline]
pub fn y_coords(&self) -> &[f64] {
&self.y_coords
}
}
pub trait Sampler: Send + Sync {
fn sample(&self, x: f64, y: f64, time: f64, quality: FxQuality) -> f64;
fn name(&self) -> &'static str;
#[inline]
fn applies_aspect_correction(&self) -> bool {
false
}
}
pub struct FnSampler<F>
where
F: Fn(f64, f64, f64, FxQuality) -> f64 + Send + Sync,
{
func: F,
name: &'static str,
aspect_corrected: bool,
}
impl<F> FnSampler<F>
where
F: Fn(f64, f64, f64, FxQuality) -> f64 + Send + Sync,
{
pub const fn new(func: F, name: &'static str) -> Self {
Self {
func,
name,
aspect_corrected: false,
}
}
pub const fn with_aspect_correction(mut self) -> Self {
self.aspect_corrected = true;
self
}
}
impl<F> Sampler for FnSampler<F>
where
F: Fn(f64, f64, f64, FxQuality) -> f64 + Send + Sync,
{
#[inline]
fn sample(&self, x: f64, y: f64, time: f64, quality: FxQuality) -> f64 {
(self.func)(x, y, time, quality)
}
fn name(&self) -> &'static str {
self.name
}
fn applies_aspect_correction(&self) -> bool {
self.aspect_corrected
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PlasmaSampler;
impl PlasmaSampler {
#[inline]
pub fn sample_full(x: f64, y: f64, time: f64) -> f64 {
let wx = x * 6.0;
let wy = y * 6.0;
let wx3 = wx - 3.0;
let wy3 = wy - 3.0;
let v1 = (wx * 1.5 + time).sin();
let v2 = (wy * 1.8 + time * 0.8).sin();
let v3 = ((wx + wy) * 1.2 + time * 0.6).sin();
let v4 = ((wx * wx + wy * wy).sqrt() * 2.0 - time * 1.2).sin();
let v5 = ((wx3 * wx3 + wy3 * wy3).sqrt() * 1.8 + time).cos();
let v6 = ((wx * 2.0).sin() * (wy * 2.0).cos() + time * 0.5).sin();
let value = (v1 + v2 + v3 + v4 + v5 + v6) / 6.0;
(value + 1.0) / 2.0
}
#[inline]
pub fn sample_reduced(x: f64, y: f64, time: f64) -> f64 {
let wx = x * 6.0;
let wy = y * 6.0;
let v1 = (wx * 1.5 + time).sin();
let v2 = (wy * 1.8 + time * 0.8).sin();
let v3 = ((wx + wy) * 1.2 + time * 0.6).sin();
let v4 = ((wx * wx + wy * wy).sqrt() * 2.0 - time * 1.2).sin();
let value = (v1 + v2 + v3 + v4) / 4.0;
(value + 1.0) / 2.0
}
#[inline]
pub fn sample_minimal(x: f64, y: f64, time: f64) -> f64 {
let wx = x * 6.0;
let wy = y * 6.0;
let v1 = (wx * 1.5 + time).sin();
let v2 = (wy * 1.8 + time * 0.8).sin();
let v3 = ((wx + wy) * 1.2 + time * 0.6).sin();
let value = (v1 + v2 + v3) / 3.0;
(value + 1.0) / 2.0
}
}
impl Sampler for PlasmaSampler {
#[inline]
fn sample(&self, x: f64, y: f64, time: f64, quality: FxQuality) -> f64 {
match quality {
FxQuality::Off => 0.0,
FxQuality::Minimal => Self::sample_minimal(x, y, time),
FxQuality::Reduced => Self::sample_reduced(x, y, time),
FxQuality::Full => Self::sample_full(x, y, time),
}
}
fn name(&self) -> &'static str {
"plasma"
}
}
#[derive(Debug, Clone, Copy)]
pub struct BallState {
pub x: f64,
pub y: f64,
pub r2: f64,
pub hue: f64,
}
#[derive(Debug, Clone)]
pub struct MetaballFieldSampler {
balls: Vec<BallState>,
}
impl MetaballFieldSampler {
pub fn new(balls: Vec<BallState>) -> Self {
Self { balls }
}
#[inline]
pub fn sample_field_from_slice(
balls: &[BallState],
x: f64,
y: f64,
quality: FxQuality,
) -> (f64, f64) {
if quality == FxQuality::Off || balls.is_empty() {
return (0.0, 0.0);
}
let step = match quality {
FxQuality::Full => 1,
FxQuality::Reduced => {
if balls.len() > 4 {
4
} else {
1
}
}
FxQuality::Minimal => {
if balls.len() > 2 {
2
} else {
1
}
}
FxQuality::Off => return (0.0, 0.0),
};
const EPS: f64 = 1e-8;
let mut sum = 0.0;
let mut weighted_hue = 0.0;
if step == 1 {
for ball in balls {
let dx = x - ball.x;
let dy = y - ball.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq > EPS {
let contrib = ball.r2 / dist_sq;
sum += contrib;
weighted_hue += ball.hue * contrib;
} else {
sum += 100.0;
weighted_hue += ball.hue * 100.0;
}
}
} else {
for i in (0..balls.len()).step_by(step) {
let ball = &balls[i];
let dx = x - ball.x;
let dy = y - ball.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq > EPS {
let contrib = ball.r2 / dist_sq;
sum += contrib;
weighted_hue += ball.hue * contrib;
} else {
sum += 100.0;
weighted_hue += ball.hue * 100.0;
}
}
}
let avg_hue = if sum > EPS { weighted_hue / sum } else { 0.0 };
(sum, avg_hue)
}
pub fn set_balls(&mut self, balls: Vec<BallState>) {
self.balls = balls;
}
pub fn balls(&self) -> &[BallState] {
&self.balls
}
#[inline]
pub fn sample_field(&self, x: f64, y: f64, quality: FxQuality) -> (f64, f64) {
Self::sample_field_from_slice(&self.balls, x, y, quality)
}
}
impl Sampler for MetaballFieldSampler {
#[inline]
fn sample(&self, x: f64, y: f64, _time: f64, quality: FxQuality) -> f64 {
self.sample_field(x, y, quality).0
}
fn name(&self) -> &'static str {
"metaballs"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cell_to_normalized_basic() {
let nx = cell_to_normalized(0, 10);
assert!((nx - 0.05).abs() < 1e-10, "cell 0 should be at 0.05");
let nx = cell_to_normalized(4, 10);
assert!((nx - 0.45).abs() < 1e-10, "cell 4 should be at 0.45");
let nx = cell_to_normalized(9, 10);
assert!((nx - 0.95).abs() < 1e-10, "cell 9 should be at 0.95");
}
#[test]
fn test_cell_to_normalized_zero_total() {
let nx = cell_to_normalized(0, 0);
assert!((nx - 0.5).abs() < 1e-10, "zero total should return 0.5");
}
#[test]
fn test_cell_to_normalized_single_cell() {
let nx = cell_to_normalized(0, 1);
assert!((nx - 0.5).abs() < 1e-10, "single cell should be at 0.5");
}
#[test]
fn test_fill_normalized_coords() {
let mut coords = vec![0.0; 5];
fill_normalized_coords(5, &mut coords);
assert!((coords[0] - 0.1).abs() < 1e-10);
assert!((coords[2] - 0.5).abs() < 1e-10);
assert!((coords[4] - 0.9).abs() < 1e-10);
}
#[test]
fn test_coord_cache() {
let cache = CoordCache::new(10, 5);
assert!((cache.x(0) - 0.05).abs() < 1e-10);
assert!((cache.y(0) - 0.1).abs() < 1e-10);
assert!((cache.x(9) - 0.95).abs() < 1e-10);
assert!((cache.y(4) - 0.9).abs() < 1e-10);
}
#[test]
fn test_coord_cache_grow_only() {
let mut cache = CoordCache::new(5, 5);
cache.ensure_size(10, 10);
assert!(cache.x_coords().len() >= 10);
assert!(cache.y_coords().len() >= 10);
assert!((cache.x(9) - 0.95).abs() < 1e-10);
}
#[test]
fn test_coord_cache_out_of_range_defaults() {
let cache = CoordCache::new(4, 3);
assert!((cache.x(99) - 0.5).abs() < 1e-10);
assert!((cache.y(99) - 0.5).abs() < 1e-10);
}
#[test]
fn test_coord_cache_does_not_shrink() {
let mut cache = CoordCache::new(8, 6);
cache.ensure_size(4, 3);
assert!(cache.x_coords().len() >= 8);
assert!(cache.y_coords().len() >= 6);
}
#[test]
fn test_plasma_sampler_bounded() {
let sampler = PlasmaSampler;
for x in [0.0, 0.25, 0.5, 0.75, 1.0] {
for y in [0.0, 0.25, 0.5, 0.75, 1.0] {
for t in [0.0, 1.0, 10.0] {
let v = sampler.sample(x, y, t, FxQuality::Full);
assert!(
(0.0..=1.0).contains(&v),
"plasma value {v} out of bounds at ({x}, {y}, {t})"
);
}
}
}
}
#[test]
fn test_plasma_sampler_quality_tiers() {
let sampler = PlasmaSampler;
let v_off = sampler.sample(0.5, 0.5, 1.0, FxQuality::Off);
assert!((v_off - 0.0).abs() < 1e-10);
let v_min = sampler.sample(0.5, 0.5, 1.0, FxQuality::Minimal);
let v_red = sampler.sample(0.5, 0.5, 1.0, FxQuality::Reduced);
let v_full = sampler.sample(0.5, 0.5, 1.0, FxQuality::Full);
assert!((0.0..=1.0).contains(&v_min));
assert!((0.0..=1.0).contains(&v_red));
assert!((0.0..=1.0).contains(&v_full));
}
#[test]
fn test_plasma_sampler_deterministic() {
let sampler = PlasmaSampler;
let v1 = sampler.sample(0.3, 0.7, 2.5, FxQuality::Full);
let v2 = sampler.sample(0.3, 0.7, 2.5, FxQuality::Full);
assert!((v1 - v2).abs() < 1e-15, "plasma should be deterministic");
}
#[test]
fn test_metaball_field_basic() {
let sampler = MetaballFieldSampler::new(vec![BallState {
x: 0.5,
y: 0.5,
r2: 0.01,
hue: 0.0,
}]);
let (field_center, _) = sampler.sample_field(0.5, 0.5, FxQuality::Full);
let (field_edge, _) = sampler.sample_field(0.0, 0.0, FxQuality::Full);
assert!(
field_center > field_edge,
"field should be higher at ball center"
);
}
#[test]
fn test_metaball_field_off() {
let sampler = MetaballFieldSampler::new(vec![BallState {
x: 0.5,
y: 0.5,
r2: 0.01,
hue: 0.0,
}]);
let (field, hue) = sampler.sample_field(0.5, 0.5, FxQuality::Off);
assert!((field - 0.0).abs() < 1e-10);
assert!((hue - 0.0).abs() < 1e-10);
}
#[test]
fn test_metaball_field_deterministic() {
let sampler = MetaballFieldSampler::new(vec![
BallState {
x: 0.3,
y: 0.3,
r2: 0.02,
hue: 0.2,
},
BallState {
x: 0.7,
y: 0.7,
r2: 0.02,
hue: 0.8,
},
]);
let (f1, h1) = sampler.sample_field(0.4, 0.5, FxQuality::Full);
let (f2, h2) = sampler.sample_field(0.4, 0.5, FxQuality::Full);
assert!((f1 - f2).abs() < 1e-15, "field should be deterministic");
assert!((h1 - h2).abs() < 1e-15, "hue should be deterministic");
}
#[test]
fn test_fn_sampler() {
let sampler = FnSampler::new(|x, y, _t, _q| x + y, "test");
assert_eq!(sampler.name(), "test");
assert!((sampler.sample(0.3, 0.2, 0.0, FxQuality::Full) - 0.5).abs() < 1e-10);
}
#[test]
fn test_fn_sampler_aspect_correction_flag() {
let sampler = FnSampler::new(|x, y, _t, _q| x + y, "aspect").with_aspect_correction();
assert!(sampler.applies_aspect_correction());
}
#[test]
fn test_metaball_field_zero_distance() {
let sampler = MetaballFieldSampler::new(vec![BallState {
x: 0.5,
y: 0.5,
r2: 0.01,
hue: 0.75,
}]);
let (field, hue) = sampler.sample_field(0.5, 0.5, FxQuality::Full);
assert!(field > 1.0, "field should be boosted at zero distance");
assert!((hue - 0.75).abs() < 1e-6, "hue should track the ball hue");
}
#[test]
fn test_metaball_field_quality_step_reduces_contribs() {
let balls = vec![
BallState {
x: 0.2,
y: 0.2,
r2: 1.0,
hue: 0.1,
},
BallState {
x: 0.4,
y: 0.4,
r2: 100.0,
hue: 0.2,
},
BallState {
x: 0.6,
y: 0.6,
r2: 100.0,
hue: 0.3,
},
BallState {
x: 0.8,
y: 0.8,
r2: 100.0,
hue: 0.4,
},
BallState {
x: 0.9,
y: 0.1,
r2: 1.0,
hue: 0.5,
},
];
let full =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.1, 0.9, FxQuality::Full).0;
let reduced =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.1, 0.9, FxQuality::Reduced).0;
assert!(reduced < full, "reduced quality should drop contributions");
}
#[test]
fn test_metaball_set_and_balls_roundtrip() {
let mut sampler = MetaballFieldSampler::new(Vec::new());
let balls = vec![BallState {
x: 0.1,
y: 0.2,
r2: 0.03,
hue: 0.9,
}];
sampler.set_balls(balls);
assert_eq!(sampler.balls().len(), 1);
}
#[test]
fn test_plasma_regression_golden() {
let cases = [
(0.0, 0.0, 0.0, 0.5), (0.5, 0.5, 0.0, 0.5), (1.0, 1.0, 0.0, 0.5), (0.25, 0.75, 1.0, 0.65), ];
let sampler = PlasmaSampler;
for (x, y, t, expected_approx) in cases {
let actual = sampler.sample(x, y, t, FxQuality::Full);
assert!((0.0..=1.0).contains(&actual), "value should be bounded");
assert!(
(actual - expected_approx).abs() < 0.5,
"value {actual} at ({x},{y},{t}) seems off"
);
}
}
#[test]
fn test_fill_normalized_coords_zero_total() {
let mut coords = vec![42.0; 3];
fill_normalized_coords(0, &mut coords);
assert!((coords[0] - 42.0).abs() < 1e-10);
}
#[test]
fn test_fill_normalized_coords_single() {
let mut coords = vec![0.0; 1];
fill_normalized_coords(1, &mut coords);
assert!((coords[0] - 0.5).abs() < 1e-10);
}
#[test]
fn test_fill_normalized_coords_oversized_slice() {
let mut coords = vec![99.0; 10];
fill_normalized_coords(3, &mut coords);
assert!((coords[0] - 1.0 / 6.0).abs() < 1e-10);
assert!((coords[1] - 3.0 / 6.0).abs() < 1e-10);
assert!((coords[2] - 5.0 / 6.0).abs() < 1e-10);
assert!((coords[3] - 99.0).abs() < 1e-10);
}
#[test]
fn test_fill_normalized_coords_values_monotonic() {
let mut coords = vec![0.0; 20];
fill_normalized_coords(20, &mut coords);
for w in coords.windows(2) {
assert!(w[1] > w[0], "coordinates should be strictly increasing");
}
}
#[test]
fn test_fill_normalized_coords_all_within_unit() {
let mut coords = vec![0.0; 100];
fill_normalized_coords(100, &mut coords);
for (i, &c) in coords.iter().enumerate() {
assert!(
(0.0..=1.0).contains(&c),
"coord[{i}] = {c} is out of [0, 1]"
);
}
}
#[test]
fn test_coord_cache_ensure_size_noop() {
let mut cache = CoordCache::new(10, 10);
let x5_before = cache.x(5);
cache.ensure_size(5, 5); let x5_after = cache.x(5);
assert!((x5_before - x5_after).abs() < 1e-15);
}
#[test]
fn test_coord_cache_ensure_size_one_dimension() {
let mut cache = CoordCache::new(5, 5);
cache.ensure_size(10, 3);
assert!(cache.x_coords().len() >= 10);
assert!(cache.y_coords().len() >= 5);
assert!((cache.x(9) - 0.95).abs() < 1e-10);
}
#[test]
fn test_coord_cache_zero_dimensions() {
let cache = CoordCache::new(0, 0);
assert!((cache.x(0) - 0.5).abs() < 1e-10);
assert!((cache.y(0) - 0.5).abs() < 1e-10);
}
#[test]
fn test_plasma_sample_full_bounded() {
for x in [0.0, 0.1, 0.5, 0.9, 1.0] {
for y in [0.0, 0.1, 0.5, 0.9, 1.0] {
let v = PlasmaSampler::sample_full(x, y, 0.0);
assert!((0.0..=1.0).contains(&v), "sample_full({x}, {y}, 0.0) = {v}");
}
}
}
#[test]
fn test_plasma_sample_reduced_bounded() {
for x in [0.0, 0.5, 1.0] {
for y in [0.0, 0.5, 1.0] {
for t in [0.0, 3.0, 10.0] {
let v = PlasmaSampler::sample_reduced(x, y, t);
assert!(
(0.0..=1.0).contains(&v),
"sample_reduced({x}, {y}, {t}) = {v}"
);
}
}
}
}
#[test]
fn test_plasma_sample_minimal_bounded() {
for x in [0.0, 0.5, 1.0] {
for y in [0.0, 0.5, 1.0] {
for t in [0.0, 3.0, 10.0] {
let v = PlasmaSampler::sample_minimal(x, y, t);
assert!(
(0.0..=1.0).contains(&v),
"sample_minimal({x}, {y}, {t}) = {v}"
);
}
}
}
}
#[test]
fn test_plasma_quality_tiers_differ() {
let x = 0.3;
let y = 0.7;
let t = 2.0;
let full = PlasmaSampler::sample_full(x, y, t);
let reduced = PlasmaSampler::sample_reduced(x, y, t);
let minimal = PlasmaSampler::sample_minimal(x, y, t);
let all_same = (full - reduced).abs() < 1e-12 && (reduced - minimal).abs() < 1e-12;
assert!(
!all_same,
"quality tiers should generally produce different values"
);
}
#[test]
fn test_plasma_sampler_name() {
let sampler = PlasmaSampler;
assert_eq!(sampler.name(), "plasma");
}
#[test]
fn test_plasma_v5_mul_form_matches_powi_form_bit_exact() {
let xs: [f64; 6] = [0.0, 0.1, 0.33, 0.5, 0.9, 1.0];
let ys: [f64; 5] = [0.0, 0.2, 0.5, 0.77, 1.0];
let ts: [f64; 5] = [0.0, 0.5, 1.0, std::f64::consts::PI, 10.0];
for x in xs {
for y in ys {
for t in ts {
let wx = x * 6.0;
let wy = y * 6.0;
let powi_form =
(((wx - 3.0).powi(2) + (wy - 3.0).powi(2)).sqrt() * 1.8 + t).cos();
let wx3 = wx - 3.0;
let wy3 = wy - 3.0;
let mul_form = ((wx3 * wx3 + wy3 * wy3).sqrt() * 1.8 + t).cos();
assert_eq!(
powi_form.to_bits(),
mul_form.to_bits(),
"x={x}, y={y}, t={t}"
);
}
}
}
}
#[test]
fn test_plasma_sampler_no_aspect_correction() {
let sampler = PlasmaSampler;
assert!(!sampler.applies_aspect_correction());
}
#[test]
fn test_metaball_empty_balls() {
let sampler = MetaballFieldSampler::new(Vec::new());
let (field, hue) = sampler.sample_field(0.5, 0.5, FxQuality::Full);
assert!((field - 0.0).abs() < 1e-10);
assert!((hue - 0.0).abs() < 1e-10);
}
#[test]
fn test_metaball_sampler_name() {
let sampler = MetaballFieldSampler::new(Vec::new());
assert_eq!(sampler.name(), "metaballs");
}
#[test]
fn test_metaball_sampler_no_aspect_correction() {
let sampler = MetaballFieldSampler::new(Vec::new());
assert!(!sampler.applies_aspect_correction());
}
#[test]
fn test_metaball_field_decreases_with_distance() {
let balls = vec![BallState {
x: 0.5,
y: 0.5,
r2: 0.1,
hue: 0.0,
}];
let (f_near, _) =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.51, 0.5, FxQuality::Full);
let (f_mid, _) =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.6, 0.5, FxQuality::Full);
let (f_far, _) =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.9, 0.5, FxQuality::Full);
assert!(f_near > f_mid, "field should decrease with distance");
assert!(f_mid > f_far, "field should decrease with distance");
}
#[test]
fn test_metaball_field_additive() {
let one_ball = vec![BallState {
x: 0.3,
y: 0.5,
r2: 0.05,
hue: 0.0,
}];
let two_balls = vec![
BallState {
x: 0.3,
y: 0.5,
r2: 0.05,
hue: 0.0,
},
BallState {
x: 0.7,
y: 0.5,
r2: 0.05,
hue: 0.5,
},
];
let (f1, _) =
MetaballFieldSampler::sample_field_from_slice(&one_ball, 0.5, 0.5, FxQuality::Full);
let (f2, _) =
MetaballFieldSampler::sample_field_from_slice(&two_balls, 0.5, 0.5, FxQuality::Full);
assert!(f2 > f1, "two balls should produce stronger field");
}
#[test]
fn test_metaball_weighted_hue() {
let balls = vec![
BallState {
x: 0.5,
y: 0.5,
r2: 0.01,
hue: 0.0,
},
BallState {
x: 0.6,
y: 0.5,
r2: 0.01,
hue: 1.0,
},
];
let (_, hue) =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.55, 0.5, FxQuality::Full);
assert!(
(0.0..=1.0).contains(&hue),
"weighted hue should be in [0, 1]"
);
}
#[test]
fn test_metaball_minimal_quality_step() {
let balls: Vec<BallState> = (0..6)
.map(|i| BallState {
x: (i as f64 + 0.5) / 6.0,
y: 0.5,
r2: 0.01,
hue: i as f64 / 5.0,
})
.collect();
let (f_full, _) =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.5, 0.5, FxQuality::Full);
let (f_minimal, _) =
MetaballFieldSampler::sample_field_from_slice(&balls, 0.5, 0.5, FxQuality::Minimal);
assert!(
f_minimal < f_full,
"minimal quality should use fewer balls (full={f_full}, min={f_minimal})"
);
}
#[test]
fn test_metaball_sampler_trait_returns_field() {
let sampler = MetaballFieldSampler::new(vec![BallState {
x: 0.5,
y: 0.5,
r2: 0.01,
hue: 0.5,
}]);
let field_via_trait = sampler.sample(0.5, 0.5, 0.0, FxQuality::Full);
let (field_direct, _) = sampler.sample_field(0.5, 0.5, FxQuality::Full);
assert!(
(field_via_trait - field_direct).abs() < 1e-15,
"Sampler trait should return field strength"
);
}
#[test]
fn test_fn_sampler_no_aspect_correction_by_default() {
let sampler = FnSampler::new(|_, _, _, _| 0.0, "test");
assert!(!sampler.applies_aspect_correction());
}
#[test]
fn test_fn_sampler_quality_passed_through() {
let sampler = FnSampler::new(
|_, _, _, q| match q {
FxQuality::Full => 1.0,
FxQuality::Reduced => 0.75,
FxQuality::Minimal => 0.5,
FxQuality::Off => 0.0,
},
"quality_test",
);
assert!((sampler.sample(0.0, 0.0, 0.0, FxQuality::Full) - 1.0).abs() < 1e-10);
assert!((sampler.sample(0.0, 0.0, 0.0, FxQuality::Reduced) - 0.75).abs() < 1e-10);
assert!((sampler.sample(0.0, 0.0, 0.0, FxQuality::Minimal) - 0.5).abs() < 1e-10);
assert!((sampler.sample(0.0, 0.0, 0.0, FxQuality::Off) - 0.0).abs() < 1e-10);
}
#[test]
fn test_cell_to_normalized_monotonic() {
for total in [2, 5, 10, 50, 100] {
for cell in 1..total {
let prev = cell_to_normalized(cell - 1, total);
let curr = cell_to_normalized(cell, total);
assert!(
curr > prev,
"cell_to_normalized should be strictly increasing: cell={cell}, total={total}"
);
}
}
}
#[test]
fn test_cell_to_normalized_within_unit() {
for total in [1, 2, 5, 10, 100, 1000] {
for cell in 0..total {
let v = cell_to_normalized(cell, total);
assert!(
(0.0..=1.0).contains(&v),
"cell_to_normalized({cell}, {total}) = {v}"
);
}
}
}
#[test]
fn test_cell_to_normalized_symmetric() {
let total = 10;
for cell in 0..total / 2 {
let left = cell_to_normalized(cell, total);
let right = cell_to_normalized(total - 1 - cell, total);
assert!(
(left + right - 1.0).abs() < 1e-10,
"cells {cell} and {} should sum to 1.0",
total - 1 - cell
);
}
}
}