From cb95dadc2a2462e11f1229fbb70e58db0cba3c88 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 30 Apr 2026 11:26:40 -0600 Subject: [PATCH] feat: accelerating spin-down spiral + 10/5-min mini-spiral warnings --- CHANGELOG.md | 13 +++++-- src/main.rs | 102 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6a0fb..456f571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 display." - **Running** (`ShowingRunning`): 3 quick clockwise circles (radius 40 px, ~600 ms) — universal "spinner / in progress" cue. - - **Pre-shutdown** (`Ending → ShutdownAnim`): inward spiral 50 → 5 px - over 3 turns (~2 s), 30 s before lifetime end — cursor visibly winds - down. + - **Pre-shutdown** (`Ending → ShutdownAnim`): eased inward spiral + 80 → 2 px over 5 turns (~5 s), starting 30 s before lifetime end. A + shared phase `u(t) = t^2.5` drives both radius and angle, so the + rotation accelerates as the radius collapses — the visual signature + of a coin/Euler-disk spinning down. + - **Warnings** (`Active → Warning10 / Warning5`): mini versions of + the same spiral fire 10 min and 5 min before shutdown — 30 → 2 px + over 2 turns (~1.5 s) and 50 → 2 px over 3 turns (~2.5 s) — so the + user gets escalating kinetic foreshadowing of the death gesture + that's coming. - 2 s `Settling` state between wake-up and running animations so the display has time to come out of sleep before the spinner draws. - All animations carry sub-pixel residue forward across `i8` HID delta diff --git a/src/main.rs b/src/main.rs index 79fe547..c6a52ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,7 @@ use embassy_usb::{ class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State}, }; use hsmc::{Duration, statechart}; -use libm::{cosf, roundf, sinf}; +use libm::{cosf, powf, roundf, sinf}; use panic_reset as _; use smart_leds::RGB8; use static_cell::StaticCell; @@ -45,7 +45,7 @@ type Writer = HidWriter<'static, UsbDriver, 5>; const RUN_DURATION: Duration = Duration::from_hours(3).saturating_add(Duration::from_mins(50)); const SHUTDOWN_LEAD: Duration = Duration::from_secs(30); const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD); -const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(2); +const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5); const QUIET_AFTER_ANIM: Duration = SHUTDOWN_LEAD.saturating_sub(SHUTDOWN_ANIM_BUDGET); const JIGGLE_PERIOD: Duration = Duration::from_secs(270); const FLASH_DURATION: Duration = Duration::from_millis(100); @@ -93,10 +93,33 @@ const RUN_RADIUS: f32 = 40.0; const RUN_FRAMES_PER_CIRCLE: u32 = 25; const RUN_CIRCLES: u32 = 3; -const SHUTDOWN_RADIUS_START: f32 = 50.0; -const SHUTDOWN_RADIUS_END: f32 = 5.0; -const SHUTDOWN_TURNS: u32 = 3; -const SHUTDOWN_FRAMES: u32 = 120; +// Shared "spin-down" spiral. Both radius and angle are driven by an eased phase +// u(t) = t^EASE_POW with EASE_POW > 1 — slow at the start, ~EASE_POW× the +// average rate at the finish. Because radius and angle share u, the inward +// spiral and the rotation accelerate together: a coin/Euler-disk feel. +const EASE_POW: f32 = 2.5; +const SPIRAL_RADIUS_END: f32 = 2.0; + +// Final shutdown — the dramatic full version, 30 s before USB goes silent. +const SHUTDOWN_RADIUS_START: f32 = 80.0; +const SHUTDOWN_TURNS: f32 = 5.0; +const SHUTDOWN_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame + +// 5-min warning — medium escalation. +const WARN5_RADIUS_START: f32 = 50.0; +const WARN5_TURNS: f32 = 3.0; +const WARN5_FRAMES: u32 = 312; // ~2.5 s + +// 10-min warning — small foreshadow. +const WARN10_RADIUS_START: f32 = 30.0; +const WARN10_TURNS: f32 = 2.0; +const WARN10_FRAMES: u32 = 187; // ~1.5 s + +// Offsets from Active-entry. The hsmc parent timer rule: timers in a parent +// state start on parent entry and survive sibling-substate transitions, so +// these three `after`s race concurrently against the same epoch. +const WARN_10_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(10)); +const WARN_5_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(5)); // ── Watchdog (independent task) ──────────────────────────────────── const WATCHDOG_TIMEOUT: EDuration = EDuration::from_secs(8); @@ -126,6 +149,7 @@ pub enum Ev { WakeDone, RunDone, Jiggled, + WarnDone, ShutdownAnimDone, } @@ -159,6 +183,8 @@ Jiggly { state Active { entry: capture_active_start; on(every JIGGLE_PERIOD) => jiggle_pair; + on(after WARN_10_AT) => Warning10; + on(after WARN_5_AT) => Warning5; on(after RUN_BEFORE_SHUTDOWN) => Ending; default(Breathing); @@ -171,6 +197,16 @@ Jiggly { entry: paint_white; on(after FLASH_DURATION) => Breathing; } + + state Warning10 { + during: animate_warning_10(writer); + on(WarnDone) => Breathing; + } + + state Warning5 { + during: animate_warning_5(writer); + on(WarnDone) => Breathing; + } } state Ending { @@ -290,17 +326,24 @@ async fn animate_running(writer: &mut Writer) -> Ev { Ev::RunDone } -async fn animate_shutdown(writer: &mut Writer) -> Ev { +async fn animate_spiral( + writer: &mut Writer, + radius_start: f32, + radius_end: f32, + turns: f32, + frames: u32, +) { let mut prev_x: f32 = 0.0; let mut prev_y: f32 = 0.0; let mut acc_x: f32 = 0.0; let mut acc_y: f32 = 0.0; - for f in 0..SHUTDOWN_FRAMES { - let t = (f as f32) / (SHUTDOWN_FRAMES as f32); - let angle = t * 2.0 * PI * (SHUTDOWN_TURNS as f32); - let radius = SHUTDOWN_RADIUS_START + (SHUTDOWN_RADIUS_END - SHUTDOWN_RADIUS_START) * t; + for f in 0..frames { + let t = (f as f32) / (frames as f32); + let u = powf(t, EASE_POW); + let angle = u * 2.0 * PI * turns; + let radius = radius_start + (radius_end - radius_start) * u; // Subtract starting offset so the spiral begins at the cursor's entry point. - let next_x = radius * cosf(angle) - SHUTDOWN_RADIUS_START; + let next_x = radius * cosf(angle) - radius_start; let next_y = radius * sinf(angle); let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y); acc_x = used_x; @@ -310,9 +353,44 @@ async fn animate_shutdown(writer: &mut Writer) -> Ev { prev_y = next_y; Timer::after(ANIM_FRAME).await; } +} + +async fn animate_shutdown(writer: &mut Writer) -> Ev { + animate_spiral( + writer, + SHUTDOWN_RADIUS_START, + SPIRAL_RADIUS_END, + SHUTDOWN_TURNS, + SHUTDOWN_FRAMES, + ) + .await; Ev::ShutdownAnimDone } +async fn animate_warning_5(writer: &mut Writer) -> Ev { + animate_spiral( + writer, + WARN5_RADIUS_START, + SPIRAL_RADIUS_END, + WARN5_TURNS, + WARN5_FRAMES, + ) + .await; + Ev::WarnDone +} + +async fn animate_warning_10(writer: &mut Writer) -> Ev { + animate_spiral( + writer, + WARN10_RADIUS_START, + SPIRAL_RADIUS_END, + WARN10_TURNS, + WARN10_FRAMES, + ) + .await; + Ev::WarnDone +} + async fn breathe_color(neo: &mut Neo, active_start: &mut Option) -> Ev { loop { let elapsed = active_start.map(|s| s.elapsed()).unwrap_or_default();