Skip to main content

laser_dac/presentation/
mod.rs

1//! Frame-first presentation types and engine.
2//!
3//! This module provides:
4//! - [`Frame`]: immutable frame type for submission
5//! - [`TransitionFn`] / [`default_transition`]: blanking between frames
6//! - `PresentationEngine`: core frame lifecycle manager (internal)
7//! - [`FrameSession`] / [`FrameSessionConfig`]: public frame-mode API
8
9pub(crate) mod content_source;
10pub(crate) mod driver;
11mod engine;
12mod output_model;
13mod session;
14mod slice_pipeline;
15
16pub use session::FrameSessionMetrics;
17pub use session::{FrameSession, FrameSessionConfig};
18
19// Re-export internal types for tests (they live in sub-modules but tests use `super::*`)
20#[cfg(test)]
21pub(crate) use engine::ColorDelayLine;
22#[cfg(all(test, not(feature = "testutils")))]
23pub(crate) use engine::PresentationEngine;
24
25// Re-export PresentationEngine for benchmarks behind testutils feature
26#[cfg(feature = "testutils")]
27pub use engine::PresentationEngine;
28
29use crate::point::LaserPoint;
30use std::sync::Arc;
31
32// =============================================================================
33// OutputFilter
34// =============================================================================
35
36/// Why an [`OutputFilter`] was reset.
37///
38/// Resets happen at output continuity boundaries where downstream processing
39/// should discard history and treat the next presented slice as a fresh stream.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum OutputResetReason {
42    /// The session scheduler started.
43    SessionStart,
44    /// The backend reconnected and presentation state was replayed.
45    Reconnect,
46    /// Output was armed and startup blanking is about to resume visible content.
47    Arm,
48    /// Output was disarmed and presented output becomes forced-blanked.
49    Disarm,
50}
51
52/// The delivery mode of the presented slice passed to an [`OutputFilter`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PresentedSliceKind {
55    /// A FIFO chunk materialized by the frame session.
56    FifoChunk,
57    /// A complete frame-swap hardware frame.
58    FrameSwapFrame,
59}
60
61/// Metadata describing the final presented slice seen by an [`OutputFilter`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct OutputFilterContext {
64    /// Output rate for the presented slice.
65    pub pps: u32,
66    /// Whether the slice is a FIFO chunk or a frame-swap frame.
67    pub kind: PresentedSliceKind,
68    /// Whether the slice should be interpreted as cyclic.
69    pub is_cyclic: bool,
70}
71
72/// Hook for advanced output-space processing in frame mode.
73///
74/// The filter runs on the final point sequence immediately before backend
75/// write, after transition composition, blanking, and color delay.
76///
77/// `WouldBlock` retries reuse the already-filtered buffer verbatim. The filter
78/// is only called again when a new presented slice is materialized.
79pub trait OutputFilter: Send + 'static {
80    /// Reset internal continuity state after a stream break.
81    fn reset(&mut self, _reason: OutputResetReason) {}
82
83    /// Transform the final presented output in place.
84    fn filter(&mut self, points: &mut [LaserPoint], ctx: &OutputFilterContext);
85}
86
87// =============================================================================
88// Frame
89// =============================================================================
90
91/// A complete frame of laser points authored by the application.
92///
93/// This is the unit of submission for frame-mode output. Frames are immutable
94/// once created and cheaply cloneable via `Arc`.
95///
96/// # Example
97///
98/// ```
99/// use laser_dac::presentation::Frame;
100/// use laser_dac::LaserPoint;
101///
102/// let frame = Frame::new(vec![
103///     LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
104///     LaserPoint::new(1.0, 0.0, 0, 65535, 0, 65535),
105/// ]);
106/// assert_eq!(frame.len(), 2);
107/// ```
108#[derive(Clone, Debug)]
109pub struct Frame {
110    points: Arc<Vec<LaserPoint>>,
111}
112
113impl Frame {
114    /// Create a new frame from a vector of points.
115    pub fn new(points: Vec<LaserPoint>) -> Self {
116        Self {
117            points: Arc::new(points),
118        }
119    }
120
121    /// Returns a reference to the frame's points.
122    pub fn points(&self) -> &[LaserPoint] {
123        &self.points
124    }
125
126    /// Returns the first point, or `None` if the frame is empty.
127    pub fn first_point(&self) -> Option<&LaserPoint> {
128        self.points.first()
129    }
130
131    /// Returns the last point, or `None` if the frame is empty.
132    pub fn last_point(&self) -> Option<&LaserPoint> {
133        self.points.last()
134    }
135
136    /// Returns the number of points in the frame.
137    pub fn len(&self) -> usize {
138        self.points.len()
139    }
140
141    /// Returns true if the frame contains no points.
142    pub fn is_empty(&self) -> bool {
143        self.points.is_empty()
144    }
145}
146
147impl From<Vec<LaserPoint>> for Frame {
148    fn from(points: Vec<LaserPoint>) -> Self {
149        Self::new(points)
150    }
151}
152
153// =============================================================================
154// TransitionFn
155// =============================================================================
156
157/// Describes how to handle the seam between two adjacent frame endpoints.
158///
159/// Returned by [`TransitionFn`] to tell the engine what to do at each seam —
160/// including self-loops (A→A) and frame changes (A→B).
161#[derive(Clone, Debug)]
162pub enum TransitionPlan {
163    /// Keep both seam endpoints and insert these points between them.
164    /// An empty vec keeps both endpoints with nothing in between.
165    Transition(Vec<LaserPoint>),
166    /// The two seam endpoints are the same logical point — coalesce them
167    /// so only one copy appears in the output.
168    Coalesce,
169}
170
171/// Callback that generates a transition plan between frames.
172///
173/// Called with the last point of the outgoing frame and the first point of
174/// the incoming frame. Returns a [`TransitionPlan`] describing how to handle
175/// the seam.
176///
177/// Self-loops (A→A) also run through this callback, so transition planning
178/// is consistent regardless of whether the frame changed.
179pub type TransitionFn = Box<dyn Fn(&LaserPoint, &LaserPoint) -> TransitionPlan + Send>;
180
181/// Default blanking transition settings (microseconds).
182///
183/// Converted to point counts via `round(µs × pps / 1_000_000)`.
184const END_DWELL_US: f64 = 100.0;
185const START_DWELL_US: f64 = 400.0;
186
187/// Create the default transition function for the given PPS.
188///
189/// Produces a 3-phase blanking sequence between frames:
190///
191/// 1. **End dwell** — repeat `from` with laser OFF. Lets the galvo settle
192///    at the endpoint before moving.
193/// 2. **Transit** — quintic ease-in-out interpolation from→to with laser OFF.
194///    Point count scales with L∞ distance (0–64 points).
195/// 3. **Start dwell** — repeat `to` with laser OFF. Lets the galvo settle
196///    at the new position before the next frame lights up.
197///
198/// All points are blanked. The on-beam dwell phases (post-on, pre-on) from
199/// the full 5-phase sequence are omitted — those are the frame's responsibility.
200pub fn default_transition(pps: u32) -> TransitionFn {
201    let end_dwell = (END_DWELL_US * pps as f64 / 1_000_000.0).round() as usize;
202    let start_dwell = (START_DWELL_US * pps as f64 / 1_000_000.0).round() as usize;
203
204    Box::new(move |from: &LaserPoint, to: &LaserPoint| {
205        let dx = to.x - from.x;
206        let dy = to.y - from.y;
207
208        // L-infinity distance (correct for independent galvo axes)
209        let d_inf = dx.abs().max(dy.abs());
210        let transit = (32.0 * d_inf).ceil().clamp(0.0, 64.0) as usize;
211
212        let total = end_dwell + transit + start_dwell;
213        let mut points = Vec::with_capacity(total);
214
215        // Phase 1: end dwell — blanked at source
216        for _ in 0..end_dwell {
217            points.push(LaserPoint::blanked(from.x, from.y));
218        }
219
220        // Phase 2: transit — quintic ease-in-out from→to, blanked
221        for i in 0..transit {
222            let t = (i as f32 + 1.0) / (transit as f32 + 1.0);
223            let t = quintic_ease_in_out(t);
224            points.push(LaserPoint::blanked(from.x + dx * t, from.y + dy * t));
225        }
226
227        // Phase 3: start dwell — blanked at destination
228        for _ in 0..start_dwell {
229            points.push(LaserPoint::blanked(to.x, to.y));
230        }
231
232        TransitionPlan::Transition(points)
233    })
234}
235
236/// Quintic ease-in-out: smooth acceleration/deceleration for galvo transit.
237///
238/// `t` in [0, 1] → output in [0, 1].
239/// First half:  `16t⁵`
240/// Second half: `0.5(2t−2)⁵ + 1`
241fn quintic_ease_in_out(t: f32) -> f32 {
242    if t < 0.5 {
243        16.0 * t * t * t * t * t
244    } else {
245        let u = 2.0 * t - 2.0;
246        0.5 * u * u * u * u * u + 1.0
247    }
248}
249
250#[cfg(test)]
251mod tests;