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;