linkage-blaze 0.1.10

No-std 3D turtle graphics for animated jointed figures
Documentation
//! A motion-captured ballet display example.
//!
//! The linkage asset was converted from the Biovision Hierarchy motion-capture
//! file format and is sampled at compile time with [`crate::bvh::Motion`].

use core::{
    convert::Infallible,
    fmt::{self, Write},
};

use crate::{
    Error as LinkageError, LinkageFixed, Rgb888,
    bvh::{self, Motion},
    linkage_file,
    render::Projection,
};
use device_envoy_core::{
    Error as CoreError,
    button::Button,
    cyd::{
        CydDisplay,
        display::{CydFrame, Image565Fixed, Orientation, tga},
    },
};
use embassy_time::{Duration, Instant};
use embedded_graphics::mono_font::{MonoFont, ascii::FONT_6X10};
use embedded_graphics::prelude::Point;

// ── Screen policy ─────────────────────────────────────────────────────────────

// The CYD display supports landscape, portrait, and the inverted form of each.
/// Display orientation used by the ballet renderer.
pub const ORIENTATION: Orientation = Orientation::Portrait;
/// Font used for the motion status line.
pub const TOP_FONT: MonoFont<'static> = FONT_6X10;

// ── Palette ──────────────────────────────────────────────────────────────────

// Default colors.
/// Near-black warm-charcoal background color.
pub const BACKGROUND_COLOR: Rgb888 = Rgb888::new(13, 13, 11);
/// Warm pale-gold figure color.
pub const FOREGROUND_COLOR: Rgb888 = Rgb888::new(255, 214, 123);

// The linkage (skeleton) previously converted from BVH to lb.rs format.
linkage_file! {
    pirouette {
        file: "../assets/mocap/pirouette.lb.rs",
    }
}
const STYLE: LinkageFixed<0, 0, 3> = LinkageFixed::start()
    .pen_color(FOREGROUND_COLOR)
    .pen_width(3.2);
const LINKAGE: LinkageFixed<
    { pirouette::DOF },
    { pirouette::MARKS },
    { STYLE.step_count() + pirouette::STEP_COUNT - 1 },
> = STYLE.combine(pirouette::view());

// The motion capture data, read at compile time from BVH and stored in the binary.
#[allow(long_running_const_eval)]
// This can take ~8 seconds to compile.
const MOTION: Motion<{ pirouette::DOF }, 592> = bvh::motion!("../assets/mocap/pirouette.bvh");
const MOTION_FPS: f32 = 120.0; // the mocap was captured at 120fps, so we can run it at that speed.

// A background_bitmap read at compile time and stored in the binary.
const BACKGROUND_BITMAP: Image565Fixed<240, 320, { 240 * 320 }> =
    tga!("../assets/ballet_background.tga").to_565();

// How we convert 3D points in the linkage to 2D points in a frame.
const PROJECTION: Projection = Projection::front_orthographic(
    Point::new(84, 275), // target origin
    1.4,                 // scale
);

// ── Generic entry point ────────────────────────────────────────────────────────

/// Run the ballet example forever on a [`CydDisplay`] implementation.
pub async fn run<CydDisplayDevice>(
    display: &mut CydDisplayDevice,
    button: &impl Button,
) -> Result<Infallible, Error<CydDisplayDevice::Error>>
where
    CydDisplayDevice: CydDisplay,
{
    let mut last_sample_duration: Option<Duration> = None;
    let mut boot_was_pressed = false;

    // Loop the motion control samples forever.
    loop {
        for (sample_index, params) in MOTION.samples().enumerate() {
            let boot_is_pressed = button.is_pressed();
            if boot_is_pressed && !boot_was_pressed {
                boot_was_pressed = true;
                break;
            }
            boot_was_pressed = boot_is_pressed;
            let started = Instant::now();

            // Create a frame to draw into. It uses preallocated memory.
            let mut cyd_frame = display.full_frame_mut();

            // Draw the background_bitmap into the frame via bulk copy.
            // .draw(...) works too, but is slower.
            BACKGROUND_BITMAP.copy_to(&mut cyd_frame)?;

            // Apply the mocap params to the linkage and draw everything to the frame.
            for draw_item_3d in LINKAGE.view().draw_items_3d(&params)? {
                draw_item_3d.project(&PROJECTION).draw(&mut cyd_frame);
            }

            // Create a status line and write it to the frame.
            let status = status_text(sample_index, last_sample_duration)?;

            // Send the frame to the display.
            cyd_frame
                .write_text(&status)
                .flush()
                .await
                .map_err(Error::Flush)?;

            last_sample_duration = Some(sample_duration(started));
        }
    }
}

/// Errors from the generic ballet loop.
#[derive(Debug, derive_more::From)]
pub enum Error<FlushError> {
    /// A runtime linkage parameter was invalid.
    Linkage(LinkageError),
    /// Formatting the status line failed.
    StatusText(fmt::Error),
    /// A device-envoy-core operation failed (for example, the background_bitmap's
    /// dimensions didn't match the frame's).
    Core(CoreError),
    /// Flushing a frame to the display failed.
    #[from(ignore)]
    Flush(FlushError),
}

#[cfg(not(test))]
fn sample_duration(started: Instant) -> Duration {
    Instant::now() - started
}

#[cfg(test)]
fn sample_duration(_started: Instant) -> Duration {
    Duration::from_millis(1)
}

fn status_text(
    sample_index: usize,
    last_sample_duration: Option<Duration>,
) -> Result<heapless::String<64>, fmt::Error> {
    let mut status_text = heapless::String::<64>::new();

    let Some(last_sample_duration) = last_sample_duration else {
        // return the empty string
        return Ok(status_text);
    };

    let elapsed_secs = last_sample_duration.as_micros() as f32 * 1e-6_f32;
    let fps = elapsed_secs.recip();
    let slomo = MOTION_FPS / fps;

    write!(
        &mut status_text,
        " #{:03}/{:03}  |  {:>4.1} fps  |  slomo {:>4.1}x",
        sample_index + 1,
        MOTION.sample_count(),
        fps,
        slomo,
    )?;
    Ok(status_text)
}

#[cfg(test)]
mod tests {
    use device_envoy_core::{
        button::Button,
        memory::{CydMemory, assert_framebuffer_matches_expected_png},
    };
    use embedded_graphics::{geometry::Point, image::GetPixel};
    use futures_executor::block_on;

    use super::{BACKGROUND_COLOR, FOREGROUND_COLOR, ORIENTATION, TOP_FONT, run};

    fn render_ballet(memory_cyd: &mut CydMemory, button: &impl Button) {
        let mut display = memory_cyd.display();
        block_on(run(&mut display, button))
            .expect_err("the free-running loop should stop at the frame budget");
    }

    #[test]
    fn boot_restarts_the_motion_sequence_at_the_initial_frame() {
        let mut baseline = CydMemory::new(
            ORIENTATION.size(),
            BACKGROUND_COLOR,
            FOREGROUND_COLOR,
            &TOP_FONT,
        );
        baseline.set_frame_budget(2);
        let baseline_button = baseline.button_memory();
        render_ballet(&mut baseline, &baseline_button);

        let mut restarted = CydMemory::new(
            ORIENTATION.size(),
            BACKGROUND_COLOR,
            FOREGROUND_COLOR,
            &TOP_FONT,
        );
        restarted.set_frame_budget(4);
        let mut restarted_button = restarted.button_memory();
        restarted_button.set_pressed_for_frame(2, true);
        render_ballet(&mut restarted, &restarted_button);

        for position_y in 0..ORIENTATION.height() as usize {
            for position_x in 0..ORIENTATION.width() as usize {
                assert_eq!(
                    restarted.pixel(Point::new(position_x as i32, position_y as i32)),
                    baseline.pixel(Point::new(position_x as i32, position_y as i32)),
                    "restarted frame differs at ({position_x}, {position_y})",
                );
            }
        }
    }

    #[test]
    fn ballet_renders_expected_frame() {
        const GOLDEN_TEST_FRAME_BUDGET: usize = 225;

        let mut memory_cyd = CydMemory::new(
            ORIENTATION.size(),
            BACKGROUND_COLOR,
            FOREGROUND_COLOR,
            &TOP_FONT,
        );
        memory_cyd.set_frame_budget(GOLDEN_TEST_FRAME_BUDGET);
        let memory_button = memory_cyd.button_memory();

        let ballet_error = {
            let mut display = memory_cyd.display();
            block_on(run(&mut display, &memory_button))
        }
        .expect_err("the free-running loop should stop at the frame budget");
        drop(ballet_error);

        assert_framebuffer_matches_expected_png(
            &memory_cyd,
            env!("CARGO_MANIFEST_DIR"),
            "ballet.png",
        )
        .expect("rendered frame should match the golden image");
    }
}