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