Skip to main content

linkage_blaze/examples/armatron/
main.rs

1//! A reusable Armatron display example.
2//!
3//! The device-agnostic loop renders an articulated linkage and interactive
4//! controls through Device Envoy's CYD (Cheap Yellow Display) traits.
5//!
6//! The generic loop redraws every frame, updates immediate-mode controls, and
7//! flushes frames through [`CydDisplay`].
8
9mod controls;
10mod reverse_kinematics;
11
12use core::convert::Infallible;
13
14use crate::{
15    Error as LinkageError, LinkageFixed, LinkageView, Rgb888, Step, linkage_file,
16    render::Projection,
17};
18use device_envoy_core::{
19    button::Button,
20    cyd::{
21        Cyd, CydDisplay, CydTouch,
22        display::{CydFrame, Orientation},
23    },
24};
25use embassy_time::Instant;
26use embedded_graphics::{geometry::Point, pixelcolor::WebColors};
27use nanorand::{Rng, WyRand};
28
29use crate::examples::ui::{Error as UiError, UiFrame, UiState};
30use controls::{
31    CALIBRATE_BUTTON, DISTANCE_LABEL, DOLLY_SLIDER, FPS_LABEL, NEXT_TARGET_BUTTON,
32    PARAM_SLIDER_COUNT, PARAM_SLIDERS, PREVIOUS_TARGET_BUTTON, RK_STEP_BUTTON, TARGET_LABEL,
33    TILT_SLIDER, VERSION_LABEL, VERSION_TEXT, XY_VIEW_SLIDER,
34};
35use reverse_kinematics::ReverseKinematics;
36
37// ── Palette ──────────────────────────────────────────────────────────────────
38
39/// Background color for the Armatron display.
40pub const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_BLACK;
41/// Foreground color for the Armatron display.
42pub const FOREGROUND_COLOR: Rgb888 = Rgb888::CSS_WHITE;
43
44// ---- linkages ----
45//
46// Build the displayed scene in layers:
47// - `CAMERA_CONTROL` provides the view-control params shared by the scene and
48//   the arm-tip distance helper linkage.
49// - `SCENE_WITH_ARM` adds the static floor grid and articulated arm plus joint spheres.
50//       The arm linkage ends with an invisible tip in the center of the hand.
51// - `LINKAGE` appends a red ghost arm that shows the current target pose.
52linkage_file! {
53    camera_control {
54        file: "../../assets/examples/armatron/camera_control.lb.rs",
55    }
56}
57linkage_file! {
58    grid9x9 {
59        file: "../../assets/examples/armatron/grid_9x9.lb.rs",
60    }
61}
62linkage_file! {
63    armatron1 {
64        file: "../../assets/examples/armatron/armatron1.lb.rs",
65    }
66}
67const CAMERA_AND_GRID: LinkageFixed<
68    { camera_control::DOF + grid9x9::DOF },
69    { camera_control::MARKS + grid9x9::MARKS },
70    { camera_control::STEP_COUNT + grid9x9::STEP_COUNT - 1 },
71> = camera_control::fixed().combine(grid9x9::view());
72const ARMATRON_WITH_JOINTS: LinkageFixed<
73    { armatron1::DOF },
74    { armatron1::MARKS },
75    { joint_sphere_step_count(&armatron1::fixed()) },
76> = with_joint_spheres(armatron1::fixed(), 0.15);
77const SCENE_WITH_ARM: LinkageFixed<
78    { CAMERA_AND_GRID.dof() + ARMATRON_WITH_JOINTS.dof() }, // Combined parameter count (DOF).
79    { CAMERA_AND_GRID.mark_count() + ARMATRON_WITH_JOINTS.mark_count() }, // Combined mark-slot count.
80    // `combine` omits the right-hand `Start`, leaving one spare slot for the later `restore`.
81    { CAMERA_AND_GRID.step_count() + ARMATRON_WITH_JOINTS.step_count() },
82> = CAMERA_AND_GRID.combine(ARMATRON_WITH_JOINTS.view());
83// `pen_color` and `sphere_param` each append one step after the ghost arm.
84const TARGET_SUFFIX_STEP_COUNT: usize = 2;
85const LINKAGE_FIXED: LinkageFixed<
86    { SCENE_WITH_ARM.dof() + armatron1::DOF },
87    { SCENE_WITH_ARM.mark_count() + armatron1::MARKS },
88    { SCENE_WITH_ARM.step_count() + armatron1::STEP_COUNT + TARGET_SUFFIX_STEP_COUNT },
89> = SCENE_WITH_ARM
90    .restore("scene origin")
91    .combine(armatron1::view())
92    .pen_color(Rgb888::CSS_RED)
93    .sphere_param("close hand", 0.5, 0.0);
94const LINKAGE: LinkageView<{ LINKAGE_FIXED.dof() }, { LINKAGE_FIXED.mark_count() }> =
95    LINKAGE_FIXED.view();
96// Minimal linkage used only to measure arm-tip distance to the target.
97const ARM_TIP_LINKAGE_FIXED: LinkageFixed<
98    { camera_control::DOF + armatron1::DOF },
99    { camera_control::MARKS + armatron1::MARKS },
100    { camera_control::STEP_COUNT + armatron1::STEP_COUNT - 1 },
101> = camera_control::fixed().combine(armatron1::view());
102const ARM_TIP_LINKAGE: LinkageView<
103    { ARM_TIP_LINKAGE_FIXED.dof() },
104    { ARM_TIP_LINKAGE_FIXED.mark_count() },
105> = ARM_TIP_LINKAGE_FIXED.view();
106
107// The ghost arm's params begin immediately after the displayed scene's params.
108const TARGET_PARAM_START: usize = SCENE_WITH_ARM.dof();
109const ORIENTATION: Orientation = Orientation::Landscape;
110
111const XY_VIEW_PARAM_INDEX: usize = LINKAGE.param_index(XY_VIEW_SLIDER.label(), 0);
112const TILT_PARAM_INDEX: usize = LINKAGE.param_index(TILT_SLIDER.label(), 0);
113const DOLLY_PARAM_INDEX: usize = LINKAGE.param_index(DOLLY_SLIDER.label(), 0);
114// Resolve arm sliders to linkage indexes at compile time for controls and search.
115const ARM_PARAM_INDEXES: [usize; PARAM_SLIDER_COUNT] = {
116    let mut indexes = [0; PARAM_SLIDER_COUNT];
117    let mut slider_index = 0;
118    while slider_index < PARAM_SLIDER_COUNT {
119        indexes[slider_index] = LINKAGE.param_index(PARAM_SLIDERS[slider_index].label(), 0);
120        slider_index += 1;
121    }
122    indexes
123};
124/// Number of normalized linkage parameters used by the Armatron scene.
125pub const DOF: usize = LINKAGE.dof();
126
127const PROJECTION: Projection = Projection::front_perspective(
128    Point::new(
129        ORIENTATION.width() as i32 / 2,
130        ORIENTATION.height() as i32 / 2,
131    ),
132    ORIENTATION.width() as f32 / 16.0, // 16 world units span the screen width
133    30.0,
134);
135
136// ── Generic armatron loop ─────────────────────────────────────────────────────
137
138/// Run the Armatron example until physical or on-screen input requests calibration.
139pub async fn run<CydDevice, ButtonDevice>(
140    cyd: &mut CydDevice,
141    button: &mut ButtonDevice,
142) -> Result<Exit, Error<CydDevice::Error>>
143where
144    CydDevice: Cyd,
145    ButtonDevice: Button,
146{
147    // Set the initial params including a random target.
148    let mut params = LINKAGE.param_defaults();
149    let mut target_seed: u8 = 0;
150    randomize_target_from_seed(target_seed, &mut params);
151
152    // Set up state.
153    let mut ui_state = UiState::new();
154    let mut reverse_kinematics = ReverseKinematics::new();
155    let mut previous_tick = None;
156
157    loop {
158        if button.is_pressed() {
159            return Ok(Exit::CalibrationRequested);
160        }
161
162        let (display, touch) = cyd.parts();
163        let touch_event = touch.try_read().map_err(Error::Cyd)?;
164        let mut frame = display.full_frame_mut();
165        let current_tick = Instant::now();
166        frame.clear();
167
168        // Draw the scene before widgets so the UI appears on top.
169        for draw_item_3d in LINKAGE.draw_items_3d(&params)? {
170            draw_item_3d.project(&PROJECTION).draw(&mut frame);
171        }
172
173        let mut ui_frame = UiFrame::new(&mut ui_state, touch_event, &mut frame);
174
175        ui_frame.slider(&TILT_SLIDER, &mut params[TILT_PARAM_INDEX])?;
176        ui_frame.slider(&DOLLY_SLIDER, &mut params[DOLLY_PARAM_INDEX])?;
177        ui_frame.slider(&XY_VIEW_SLIDER, &mut params[XY_VIEW_PARAM_INDEX])?;
178        for (param_slider, param_index) in PARAM_SLIDERS.iter().zip(ARM_PARAM_INDEXES) {
179            if ui_frame.slider(param_slider, &mut params[param_index])? {
180                reverse_kinematics.clear();
181            }
182        }
183
184        if ui_frame.button(&PREVIOUS_TARGET_BUTTON)? {
185            reverse_kinematics.clear();
186            target_seed = target_seed.wrapping_sub(1);
187            randomize_target_from_seed(target_seed, &mut params);
188        }
189        if ui_frame.button(&NEXT_TARGET_BUTTON)? {
190            reverse_kinematics.clear();
191            target_seed = target_seed.wrapping_add(1);
192            randomize_target_from_seed(target_seed, &mut params);
193        }
194        if ui_frame.icon_button(reverse_kinematics.run_button())? {
195            reverse_kinematics.toggle(&params)?;
196        }
197        let hold_button_state = ui_frame.hold_button(&RK_STEP_BUTTON)?;
198
199        if ui_frame.button(&CALIBRATE_BUTTON)? {
200            return Ok(Exit::CalibrationRequested);
201        }
202
203        // Explicit per-frame solver schedule slot.
204        let dt_seconds = previous_tick.map_or(0.0, |previous_tick| {
205            current_tick
206                .saturating_duration_since(previous_tick)
207                .as_micros() as f32
208                / 1_000_000.0
209        });
210        reverse_kinematics.hold_step(&mut params, hold_button_state, dt_seconds)?;
211        reverse_kinematics.tick(&mut params, dt_seconds)?;
212
213        ui_frame.label(&TARGET_LABEL, format_args!("target #{target_seed}"))?;
214        let distance_hundredths = target_distance_hundredths(&params)?;
215        ui_frame.label(
216            &DISTANCE_LABEL,
217            format_args!(
218                "distance {:02}.{:02}",
219                distance_hundredths / 100,
220                distance_hundredths % 100
221            ),
222        )?;
223        if let Some((fps_whole, fps_fraction)) =
224            previous_tick.and_then(|previous_tick| display_fps_since(previous_tick, current_tick))
225        {
226            ui_frame.label(
227                &FPS_LABEL,
228                format_args!("{fps_whole:>2}.{fps_fraction} fps"),
229            )?;
230        }
231        if !cfg!(test) {
232            ui_frame.label(&VERSION_LABEL, format_args!("{VERSION_TEXT}"))?;
233        }
234
235        ui_frame.draw_touch_cursor()?;
236
237        frame.flush().await.map_err(Error::Cyd)?;
238        previous_tick = Some(current_tick);
239    }
240}
241
242/// Error from the generic Armatron loop, generic over the display-device error `CydError`.
243///
244/// Local UI errors get a derived `From`, so they propagate with a plain `?`.
245/// The display-device error `CydError` is the one exception: it
246/// is converted explicitly with `.map_err(Error::Cyd)` at the call site,
247/// because a blanket `From<CydError>` would overlap with those concrete `From`s under
248/// coherence.
249#[derive(Debug, derive_more::From)]
250pub enum Error<CydError> {
251    /// A runtime linkage parameter was invalid.
252    Linkage(LinkageError),
253    /// A UI widget failed (text formatting; draw is infallible here).
254    Ui(UiError<Infallible>),
255    /// Reading touch events or flushing a frame failed.
256    #[from(ignore)]
257    Cyd(CydError),
258}
259
260/// Successful reason for leaving the Armatron application loop.
261#[derive(Debug)]
262pub enum Exit {
263    /// The user requested touch-screen calibration.
264    CalibrationRequested,
265}
266
267#[cfg(test)]
268mod tests {
269    use device_envoy_core::cyd::touch::TouchEvent;
270    use device_envoy_core::memory::{
271        CydMemory, Error as CydMemoryError, assert_framebuffer_matches_expected_png,
272    };
273    use embedded_graphics::mono_font::ascii::FONT_9X15_BOLD;
274    use futures_executor::block_on;
275
276    use super::controls::CALIBRATE_BUTTON;
277    use super::{Error, Exit, run};
278
279    fn test_memory_cyd() -> CydMemory {
280        CydMemory::new(
281            embedded_graphics::geometry::Size::new(320, 240),
282            super::BACKGROUND_COLOR,
283            super::FOREGROUND_COLOR,
284            &FONT_9X15_BOLD,
285        )
286    }
287
288    #[test]
289    fn tapping_the_calibrate_button_requests_calibration() -> Result<(), Error<CydMemoryError>> {
290        let mut memory_cyd = test_memory_cyd();
291        let touch_rectangle = CALIBRATE_BUTTON.touch_rectangle;
292        let touch_center = touch_rectangle.top_left
293            + embedded_graphics::geometry::Point::new(
294                touch_rectangle.size.width as i32 / 2,
295                touch_rectangle.size.height as i32 / 2,
296            );
297        memory_cyd.push_touch_event(TouchEvent::Down {
298            point: touch_center,
299        });
300        let mut memory_button = memory_cyd.button_memory();
301
302        let armatron_exit = block_on(run(&mut memory_cyd, &mut memory_button))?;
303
304        assert!(matches!(armatron_exit, Exit::CalibrationRequested));
305        assert_eq!(
306            memory_cyd.flush_count(),
307            0,
308            "the calibrate-button exit happens before the frame is flushed"
309        );
310        Ok(())
311    }
312
313    #[test]
314    fn boot_requests_calibration() -> Result<(), Error<CydMemoryError>> {
315        let mut memory_cyd = test_memory_cyd();
316        memory_cyd.set_frame_budget(1);
317        let mut memory_button = memory_cyd.button_memory();
318        memory_button.set_pressed_for_frame(0, true);
319
320        let armatron_exit = block_on(run(&mut memory_cyd, &mut memory_button))?;
321
322        assert!(matches!(armatron_exit, Exit::CalibrationRequested));
323        assert_eq!(memory_cyd.flush_count(), 0);
324        Ok(())
325    }
326
327    #[test]
328    fn armatron_renders_expected_frame() {
329        let mut memory_cyd = test_memory_cyd();
330        memory_cyd.set_frame_budget(1);
331        let mut memory_button = memory_cyd.button_memory();
332
333        let armatron_error = block_on(run(&mut memory_cyd, &mut memory_button))
334            .expect_err("the free-running loop should stop at the frame budget");
335        assert!(matches!(
336            armatron_error,
337            Error::Cyd(CydMemoryError::OutOfFrames)
338        ));
339
340        assert_framebuffer_matches_expected_png(
341            &memory_cyd,
342            env!("CARGO_MANIFEST_DIR"),
343            "armatron.png",
344        )
345        .expect("rendered frame should match the golden image");
346    }
347}
348
349// ── Private helper functions ───────────────────────────────────────────────────
350
351const fn joint_sphere_step_count<const DOF: usize, const MARKS: usize, const N: usize>(
352    linkage: &LinkageFixed<DOF, MARKS, N>,
353) -> usize {
354    let mut count = linkage.len;
355    let mut step_index = 0;
356    while step_index < linkage.len {
357        if matches!(
358            linkage.steps[step_index],
359            Step::Forward(_) | Step::Left(_) | Step::Up(_)
360        ) {
361            count += 2;
362        }
363        step_index += 1;
364    }
365    count
366}
367
368const fn with_joint_spheres<
369    const DOF: usize,
370    const MARKS: usize,
371    const N: usize,
372    const N_OUT: usize,
373>(
374    linkage: LinkageFixed<DOF, MARKS, N>,
375    joint_radius: f32,
376) -> LinkageFixed<DOF, MARKS, N_OUT> {
377    let mut output = LinkageFixed {
378        steps: [const { Step::Start }; N_OUT],
379        len: 0,
380        params: linkage.params,
381        param_len: linkage.param_len,
382        mark_names: linkage.mark_names,
383        mark_len: linkage.mark_len,
384    };
385    let mut step_index = 0;
386    while step_index < linkage.len {
387        let step = linkage.steps[step_index];
388        let is_translation = matches!(step, Step::Forward(_) | Step::Left(_) | Step::Up(_));
389        if is_translation {
390            assert!(output.len < N_OUT, "joint-sphere output capacity too small");
391            output.steps[output.len] = Step::Sphere(joint_radius);
392            output.len += 1;
393        }
394        assert!(output.len < N_OUT, "joint-sphere output capacity too small");
395        output.steps[output.len] = step;
396        output.len += 1;
397        if is_translation {
398            assert!(output.len < N_OUT, "joint-sphere output capacity too small");
399            output.steps[output.len] = Step::Sphere(joint_radius);
400            output.len += 1;
401        }
402        step_index += 1;
403    }
404    output
405}
406
407fn randomize_target_from_seed(target_seed: u8, params: &mut [f32; DOF]) {
408    let mut rng = WyRand::new_seed(u64::from(target_seed));
409    for param in params[TARGET_PARAM_START..].iter_mut() {
410        *param = rng.generate::<u32>() as f32 / (u32::MAX as f32 + 1.0);
411    }
412}
413
414fn target_distance_hundredths(params: &[f32; DOF]) -> Result<u32, LinkageError> {
415    // Display bound: the label format only has room for "distance 99.99".
416    Ok(libm::roundf(target_distance(params)?.clamp(0.0, 99.99) * 100.0) as u32)
417}
418
419fn display_fps_since(previous_tick: Instant, current_tick: Instant) -> Option<(u32, u32)> {
420    let elapsed_micros = current_tick
421        .saturating_duration_since(previous_tick)
422        .as_micros();
423
424    (elapsed_micros != 0).then(|| {
425        // Convert microseconds/frame to tenths of frames/second, rounded.
426        let fps_tenths = 10_000_000_u64.saturating_add(elapsed_micros / 2) / elapsed_micros;
427        let fps_tenths = fps_tenths.min(999) as u32;
428        (fps_tenths / 10, fps_tenths % 10)
429    })
430}
431
432fn target_distance(params: &[f32; DOF]) -> Result<f32, LinkageError> {
433    let mut arm_params = [0.0f32; TARGET_PARAM_START];
434    arm_params.copy_from_slice(&params[..TARGET_PARAM_START]);
435    let arm_tip = ARM_TIP_LINKAGE.final_pose(&arm_params)?.position();
436    let target_center = LINKAGE.final_pose(params)?.position();
437    Ok(arm_tip.distance_to(target_center))
438}