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;