Skip to main content

linkage_blaze/examples/
ballet.rs

1//! A motion-captured ballet display example.
2//!
3//! The linkage asset was converted from the Biovision Hierarchy motion-capture
4//! file format and is sampled at compile time with [`crate::bvh::Motion`].
5
6use core::{
7    convert::Infallible,
8    fmt::{self, Write},
9};
10
11use crate::{
12    Error as LinkageError, LinkageFixed, Rgb888,
13    bvh::{self, Motion},
14    linkage_file,
15    render::Projection,
16};
17use device_envoy_core::{
18    Error as CoreError,
19    button::Button,
20    cyd::{
21        CydDisplay,
22        display::{CydFrame, Image565Fixed, Orientation, tga},
23    },
24};
25use embassy_time::{Duration, Instant};
26use embedded_graphics::mono_font::{MonoFont, ascii::FONT_6X10};
27use embedded_graphics::prelude::Point;
28
29// ── Screen policy ─────────────────────────────────────────────────────────────
30
31// The CYD display supports landscape, portrait, and the inverted form of each.
32/// Display orientation used by the ballet renderer.
33pub const ORIENTATION: Orientation = Orientation::Portrait;
34/// Font used for the motion status line.
35pub const TOP_FONT: MonoFont<'static> = FONT_6X10;
36
37// ── Palette ──────────────────────────────────────────────────────────────────
38
39// Default colors.
40/// Near-black warm-charcoal background color.
41pub const BACKGROUND_COLOR: Rgb888 = Rgb888::new(13, 13, 11);
42/// Warm pale-gold figure color.
43pub const FOREGROUND_COLOR: Rgb888 = Rgb888::new(255, 214, 123);
44
45// The linkage (skeleton) previously converted from BVH to lb.rs format.
46linkage_file! {
47    pirouette {
48        file: "../assets/mocap/pirouette.lb.rs",
49    }
50}
51const STYLE: LinkageFixed<0, 0, 3> = LinkageFixed::start()
52    .pen_color(FOREGROUND_COLOR)
53    .pen_width(3.2);
54const LINKAGE: LinkageFixed<
55    { pirouette::DOF },
56    { pirouette::MARKS },
57    { STYLE.step_count() + pirouette::STEP_COUNT - 1 },
58> = STYLE.combine(pirouette::view());
59
60// The motion capture data, read at compile time from BVH and stored in the binary.
61#[allow(long_running_const_eval)]
62// This can take ~8 seconds to compile.
63const MOTION: Motion<{ pirouette::DOF }, 592> = bvh::motion!("../assets/mocap/pirouette.bvh");
64const MOTION_FPS: f32 = 120.0; // the mocap was captured at 120fps, so we can run it at that speed.
65
66// A background_bitmap read at compile time and stored in the binary.
67const BACKGROUND_BITMAP: Image565Fixed<240, 320, { 240 * 320 }> =
68    tga!("../assets/ballet_background.tga").to_565();
69
70// How we convert 3D points in the linkage to 2D points in a frame.
71const PROJECTION: Projection = Projection::front_orthographic(
72    Point::new(84, 275), // target origin
73    1.4,                 // scale
74);
75
76// ── Generic entry point ────────────────────────────────────────────────────────
77
78/// Run the ballet example forever on a [`CydDisplay`] implementation.
79pub async fn run<CydDisplayDevice>(
80    display: &mut CydDisplayDevice,
81    button: &impl Button,
82) -> Result<Infallible, Error<CydDisplayDevice::Error>>
83where
84    CydDisplayDevice: CydDisplay,
85{
86    let mut last_sample_duration: Option<Duration> = None;
87    let mut boot_was_pressed = false;
88
89    // Loop the motion control samples forever.
90    loop {
91        for (sample_index, params) in MOTION.samples().enumerate() {
92            let boot_is_pressed = button.is_pressed();
93            if boot_is_pressed && !boot_was_pressed {
94                boot_was_pressed = true;
95                break;
96            }
97            boot_was_pressed = boot_is_pressed;
98            let started = Instant::now();
99
100            // Create a frame to draw into. It uses preallocated memory.
101            let mut cyd_frame = display.full_frame_mut();
102
103            // Draw the background_bitmap into the frame via bulk copy.
104            // .draw(...) works too, but is slower.
105            BACKGROUND_BITMAP.copy_to(&mut cyd_frame)?;
106
107            // Apply the mocap params to the linkage and draw everything to the frame.
108            for draw_item_3d in LINKAGE.view().draw_items_3d(&params)? {
109                draw_item_3d.project(&PROJECTION).draw(&mut cyd_frame);
110            }
111
112            // Create a status line and write it to the frame.
113            let status = status_text(sample_index, last_sample_duration)?;
114
115            // Send the frame to the display.
116            cyd_frame
117                .write_text(&status)
118                .flush()
119                .await
120                .map_err(Error::Flush)?;
121
122            last_sample_duration = Some(sample_duration(started));
123        }
124    }
125}
126
127/// Errors from the generic ballet loop.
128#[derive(Debug, derive_more::From)]
129pub enum Error<FlushError> {
130    /// A runtime linkage parameter was invalid.
131    Linkage(LinkageError),
132    /// Formatting the status line failed.
133    StatusText(fmt::Error),
134    /// A device-envoy-core operation failed (for example, the background_bitmap's
135    /// dimensions didn't match the frame's).
136    Core(CoreError),
137    /// Flushing a frame to the display failed.
138    #[from(ignore)]
139    Flush(FlushError),
140}
141
142#[cfg(not(test))]
143fn sample_duration(started: Instant) -> Duration {
144    Instant::now() - started
145}
146
147#[cfg(test)]
148fn sample_duration(_started: Instant) -> Duration {
149    Duration::from_millis(1)
150}
151
152fn status_text(
153    sample_index: usize,
154    last_sample_duration: Option<Duration>,
155) -> Result<heapless::String<64>, fmt::Error> {
156    let mut status_text = heapless::String::<64>::new();
157
158    let Some(last_sample_duration) = last_sample_duration else {
159        // return the empty string
160        return Ok(status_text);
161    };
162
163    let elapsed_secs = last_sample_duration.as_micros() as f32 * 1e-6_f32;
164    let fps = elapsed_secs.recip();
165    let slomo = MOTION_FPS / fps;
166
167    write!(
168        &mut status_text,
169        " #{:03}/{:03}  |  {:>4.1} fps  |  slomo {:>4.1}x",
170        sample_index + 1,
171        MOTION.sample_count(),
172        fps,
173        slomo,
174    )?;
175    Ok(status_text)
176}
177
178#[cfg(test)]
179mod tests {
180    use device_envoy_core::{
181        button::Button,
182        memory::{CydMemory, assert_framebuffer_matches_expected_png},
183    };
184    use embedded_graphics::{geometry::Point, image::GetPixel};
185    use futures_executor::block_on;
186
187    use super::{BACKGROUND_COLOR, FOREGROUND_COLOR, ORIENTATION, TOP_FONT, run};
188
189    fn render_ballet(memory_cyd: &mut CydMemory, button: &impl Button) {
190        let mut display = memory_cyd.display();
191        block_on(run(&mut display, button))
192            .expect_err("the free-running loop should stop at the frame budget");
193    }
194
195    #[test]
196    fn boot_restarts_the_motion_sequence_at_the_initial_frame() {
197        let mut baseline = CydMemory::new(
198            ORIENTATION.size(),
199            BACKGROUND_COLOR,
200            FOREGROUND_COLOR,
201            &TOP_FONT,
202        );
203        baseline.set_frame_budget(2);
204        let baseline_button = baseline.button_memory();
205        render_ballet(&mut baseline, &baseline_button);
206
207        let mut restarted = CydMemory::new(
208            ORIENTATION.size(),
209            BACKGROUND_COLOR,
210            FOREGROUND_COLOR,
211            &TOP_FONT,
212        );
213        restarted.set_frame_budget(4);
214        let mut restarted_button = restarted.button_memory();
215        restarted_button.set_pressed_for_frame(2, true);
216        render_ballet(&mut restarted, &restarted_button);
217
218        for position_y in 0..ORIENTATION.height() as usize {
219            for position_x in 0..ORIENTATION.width() as usize {
220                assert_eq!(
221                    restarted.pixel(Point::new(position_x as i32, position_y as i32)),
222                    baseline.pixel(Point::new(position_x as i32, position_y as i32)),
223                    "restarted frame differs at ({position_x}, {position_y})",
224                );
225            }
226        }
227    }
228
229    #[test]
230    fn ballet_renders_expected_frame() {
231        const GOLDEN_TEST_FRAME_BUDGET: usize = 225;
232
233        let mut memory_cyd = CydMemory::new(
234            ORIENTATION.size(),
235            BACKGROUND_COLOR,
236            FOREGROUND_COLOR,
237            &TOP_FONT,
238        );
239        memory_cyd.set_frame_budget(GOLDEN_TEST_FRAME_BUDGET);
240        let memory_button = memory_cyd.button_memory();
241
242        let ballet_error = {
243            let mut display = memory_cyd.display();
244            block_on(run(&mut display, &memory_button))
245        }
246        .expect_err("the free-running loop should stop at the frame budget");
247        drop(ballet_error);
248
249        assert_framebuffer_matches_expected_png(
250            &memory_cyd,
251            env!("CARGO_MANIFEST_DIR"),
252            "ballet.png",
253        )
254        .expect("rendered frame should match the golden image");
255    }
256}