use std::f64::consts::{PI, TAU};
use kurbo::{BezPath, Point, Size};
pub(crate) const DEFAULT_SIZE: f64 = 40.0;
pub(crate) const WAVY_SIZE: f64 = 48.0;
pub(crate) const STROKE_WIDTH: f64 = 4.0;
pub(crate) const TRACK_STROKE_WIDTH: f64 = 4.0;
pub(crate) const GAP_SIZE: f64 = 8.0;
pub(crate) const WAVE_AMPLITUDE: f64 = 1.6;
pub(crate) const WAVELENGTH: f64 = 15.0;
pub(crate) const START_ANGLE: f64 = -PI / 2.0;
const WAVY_ARC_STEPS: usize = 120;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Geometry {
pub(crate) center: Point,
pub(crate) radius: f64,
pub(crate) gap_angle: f64,
pub(crate) wave_k: f64,
}
pub(crate) fn geometry(
origin: Point,
size: Size,
stroke: f64,
track_stroke: f64,
gap_size: f64,
amplitude: f64,
wavelength: f64,
) -> Option<Geometry> {
let max_stroke = stroke.max(track_stroke);
let shortest = size.width.min(size.height);
let radius = (shortest - max_stroke) / 2.0 - amplitude;
if !radius.is_finite() || radius <= 0.0 {
return None;
}
let cycles = if wavelength > 0.0 {
(TAU * radius / wavelength).round().max(1.0)
} else {
1.0
};
Some(Geometry {
center: Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0),
radius,
gap_angle: (gap_size + (stroke + track_stroke) / 2.0) / radius,
wave_k: cycles / radius,
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ArcSpans {
pub(crate) track: Option<(f64, f64)>,
pub(crate) active: Option<(f64, f64)>,
}
pub(crate) fn arc_spans(
progress: Option<f64>,
start: f64,
indeterminate_sweep: f64,
gap_angle: f64,
) -> ArcSpans {
let progress = progress.map(|p| if p.is_nan() { 0.0 } else { p.clamp(0.0, 1.0) });
if progress.is_some_and(|p| p >= 1.0) {
return ArcSpans {
track: None,
active: Some((start, TAU)),
};
}
let active_sweep = match progress {
Some(p) => p * TAU,
None => indeterminate_sweep.clamp(0.0, TAU),
};
let applied_gap = active_sweep.min(gap_angle);
let track_sweep = TAU - active_sweep - applied_gap * 2.0;
ArcSpans {
track: (track_sweep > 0.0).then_some((start + active_sweep + applied_gap, track_sweep)),
active: (active_sweep > 0.0).then_some((start, active_sweep)),
}
}
pub(crate) fn wavy_arc_path(
geometry: &Geometry,
start_angle: f64,
sweep_angle: f64,
amplitude: f64,
phase: f64,
) -> BezPath {
let mut path = BezPath::new();
for i in 0..=WAVY_ARC_STEPS {
let t = i as f64 / WAVY_ARC_STEPS as f64;
let angle = start_angle + sweep_angle * t;
let arc_length = geometry.radius * (angle - start_angle).abs();
let r = geometry.radius + amplitude * (phase + arc_length * geometry.wave_k).sin();
let point = Point::new(
geometry.center.x + r * angle.cos(),
geometry.center.y + r * angle.sin(),
);
if i == 0 {
path.move_to(point);
} else {
path.line_to(point);
}
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::PathEl;
fn ring(amplitude: f64) -> Geometry {
geometry(
Point::ZERO,
Size::new(DEFAULT_SIZE, DEFAULT_SIZE),
STROKE_WIDTH,
TRACK_STROKE_WIDTH,
GAP_SIZE,
amplitude,
WAVELENGTH,
)
.expect("a 40dp box fits a ring")
}
#[test]
fn the_radius_insets_by_half_the_widest_stroke() {
let g = ring(0.0);
assert_eq!(g.radius, (DEFAULT_SIZE - STROKE_WIDTH) / 2.0);
assert_eq!(g.center, Point::new(20.0, 20.0));
}
#[test]
fn a_wave_amplitude_shrinks_the_radius_so_crests_stay_inside_the_box() {
let flat = ring(0.0);
let wavy = ring(WAVE_AMPLITUDE);
assert_eq!(wavy.radius, flat.radius - WAVE_AMPLITUDE);
}
#[test]
fn the_gap_angle_is_the_gap_plus_half_of_both_strokes_over_the_radius() {
let g = ring(0.0);
let expected = (GAP_SIZE + (STROKE_WIDTH + TRACK_STROKE_WIDTH) / 2.0) / g.radius;
assert!((g.gap_angle - expected).abs() < 1e-12);
}
#[test]
fn the_wave_closes_on_a_whole_number_of_cycles_around_the_ring() {
let g = ring(WAVE_AMPLITUDE);
let cycles = g.wave_k * g.radius;
assert!(
(cycles - cycles.round()).abs() < 1e-9,
"waveK must be a whole cycle count over the radius, got {cycles}"
);
let full_turn_phase = TAU * g.radius * g.wave_k;
assert!((full_turn_phase / TAU - cycles).abs() < 1e-9);
}
#[test]
fn a_box_smaller_than_its_own_stroke_resolves_no_ring() {
assert!(
geometry(
Point::ZERO,
Size::new(3.0, 3.0),
STROKE_WIDTH,
TRACK_STROKE_WIDTH,
GAP_SIZE,
0.0,
WAVELENGTH,
)
.is_none()
);
}
#[test]
fn a_degenerate_wavelength_still_resolves_a_ring() {
let g = geometry(
Point::ZERO,
Size::new(DEFAULT_SIZE, DEFAULT_SIZE),
STROKE_WIDTH,
TRACK_STROKE_WIDTH,
GAP_SIZE,
0.0,
0.0,
)
.expect("a zero wavelength must not sink the ring");
assert!((g.wave_k * g.radius - 1.0).abs() < 1e-12, "one cycle floor");
}
#[test]
fn a_determinate_arc_sweeps_proportionally_from_twelve_oclock() {
let g = ring(0.0);
let spans = arc_spans(Some(0.25), START_ANGLE, 0.0, g.gap_angle);
let (start, sweep) = spans.active.expect("a quarter turn is painted");
assert_eq!(start, START_ANGLE);
assert!((sweep - TAU / 4.0).abs() < 1e-12);
let (track_start, track_sweep) = spans.track.expect("three quarters of track remain");
assert!((track_start - (START_ANGLE + sweep + g.gap_angle)).abs() < 1e-12);
assert!((track_sweep - (TAU - sweep - 2.0 * g.gap_angle)).abs() < 1e-12);
}
#[test]
fn a_complete_determinate_ring_has_no_track_and_no_gaps() {
let spans = arc_spans(Some(1.0), START_ANGLE, 0.0, ring(0.0).gap_angle);
assert_eq!(spans.active, Some((START_ANGLE, TAU)));
assert_eq!(spans.track, None);
}
#[test]
fn a_zero_determinate_ring_is_all_track() {
let spans = arc_spans(Some(0.0), START_ANGLE, 0.0, ring(0.0).gap_angle);
assert_eq!(spans.active, None);
let (_, track_sweep) = spans.track.expect("the whole ring is track");
assert!(
(track_sweep - TAU).abs() < 1e-12,
"with no active arc there is nothing to gap against"
);
}
#[test]
fn a_determinate_value_is_clamped_at_both_ends() {
let gap = ring(0.0).gap_angle;
assert_eq!(
arc_spans(Some(4.0), START_ANGLE, 0.0, gap).active,
Some((START_ANGLE, TAU))
);
assert_eq!(arc_spans(Some(-4.0), START_ANGLE, 0.0, gap).active, None);
assert_eq!(
arc_spans(Some(f64::NAN), START_ANGLE, 0.0, gap).active,
None
);
}
#[test]
fn an_indeterminate_arc_takes_the_clocks_sweep_and_carries_its_rotation() {
let g = ring(0.0);
let rotation = 1.0_f64;
let sweep = 0.5 * TAU;
let spans = arc_spans(None, START_ANGLE + rotation, sweep, g.gap_angle);
assert_eq!(spans.active, Some((START_ANGLE + rotation, sweep)));
let (track_start, track_sweep) = spans.track.expect("half the ring is track");
assert!((track_start - (START_ANGLE + rotation + sweep + g.gap_angle)).abs() < 1e-12);
assert!((track_sweep - (TAU - sweep - 2.0 * g.gap_angle)).abs() < 1e-12);
}
#[test]
fn the_gap_never_exceeds_the_active_sweep_it_is_carved_out_of() {
let g = ring(0.0);
let tiny = g.gap_angle / 4.0;
let spans = arc_spans(None, START_ANGLE, tiny, g.gap_angle);
let (_, track_sweep) = spans.track.expect("almost all track");
assert!((track_sweep - (TAU - tiny - 2.0 * tiny)).abs() < 1e-12);
}
#[test]
fn the_wavy_arc_walks_the_reference_step_count() {
let g = ring(WAVE_AMPLITUDE);
let path = wavy_arc_path(&g, START_ANGLE, TAU, WAVE_AMPLITUDE, 0.0);
assert_eq!(path.elements().len(), WAVY_ARC_STEPS + 1);
assert!(matches!(path.elements().first(), Some(PathEl::MoveTo(_))));
}
#[test]
fn the_wavy_arc_oscillates_between_the_radius_and_the_amplitude() {
let g = ring(WAVE_AMPLITUDE);
let path = wavy_arc_path(&g, START_ANGLE, TAU, WAVE_AMPLITUDE, 0.0);
let radii: Vec<f64> = path
.elements()
.iter()
.filter_map(|el| match el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => Some((*p - g.center).hypot()),
_ => None,
})
.collect();
let max = radii.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min = radii.iter().cloned().fold(f64::INFINITY, f64::min);
assert!(max <= g.radius + WAVE_AMPLITUDE + 1e-9);
assert!(min >= g.radius - WAVE_AMPLITUDE - 1e-9);
assert!(
max - min > WAVE_AMPLITUDE,
"a full turn must reach both a crest and a trough"
);
}
#[test]
fn a_zero_amplitude_wavy_arc_is_a_plain_circle() {
let g = ring(0.0);
let path = wavy_arc_path(&g, START_ANGLE, TAU, 0.0, 0.0);
for el in path.elements() {
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
assert!(((*p - g.center).hypot() - g.radius).abs() < 1e-9);
}
}
}
}