1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
//! Helios frame and point types.
use bitflags::bitflags;
use crate::point::LaserPoint;
/// A frame to be sent to the Helios DAC.
#[derive(Debug, Clone, PartialEq)]
pub struct Frame {
/// Rate of output in points per second
pub pps: u32,
/// Frame flags (default is empty)
pub flags: WriteFrameFlags,
/// Points in this frame
pub points: Vec<Point>,
}
impl Frame {
/// Create a new frame with the given point rate and points.
///
/// Defaults to `SINGLE_MODE` (play once, don't repeat), matching the
/// official Helios SDK's `HELIOS_FLAGS_DEFAULT`. This prevents the DAC
/// from repeating the last frame indefinitely if the host stops sending.
pub fn new(pps: u32, points: Vec<Point>) -> Self {
Frame {
pps,
points,
flags: WriteFrameFlags::SINGLE_MODE,
}
}
/// Create a new frame with specific flags.
pub fn new_with_flags(pps: u32, points: Vec<Point>, flags: WriteFrameFlags) -> Self {
Frame { pps, points, flags }
}
}
/// A single laser point.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
/// X/Y coordinate
pub coordinate: Coordinate,
/// RGB color
pub color: Color,
/// Intensity (0-255)
pub intensity: u8,
}
/// Coordinates (x, y) for Helios DAC.
///
/// 12 bit (from 0 to 0xFFF)
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Coordinate {
pub x: u16,
pub y: u16,
}
impl From<(u16, u16)> for Coordinate {
fn from((x, y): (u16, u16)) -> Self {
Coordinate { x, y }
}
}
/// RGB color for a laser point.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color {
/// Red channel (0-255)
pub r: u8,
/// Green channel (0-255)
pub g: u8,
/// Blue channel (0-255)
pub b: u8,
}
impl Color {
/// Create a new color.
pub fn new(r: u8, g: u8, b: u8) -> Self {
Color { r, g, b }
}
}
bitflags! {
/// Flags for WriteFrame operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WriteFrameFlags: u8 {
/// Bit 0 (LSB) = if 1, start output immediately, instead of waiting for current frame (if there is one) to finish playing
const START_IMMEDIATELY = 0b0000_0001;
/// Bit 1 = if 1, play frame only once, instead of repeating until another frame is written
const SINGLE_MODE = 0b0000_0010;
/// Bit 2 = if 1, don't let WriteFrame() block execution while waiting for the transfer to finish
const DONT_BLOCK = 0b0000_0100;
}
}
impl From<&LaserPoint> for Point {
/// Convert a [`LaserPoint`] to a Helios [`Point`].
///
/// [`LaserPoint`] uses f32 coordinates (-1.0 to 1.0) and u16 colors (0-65535).
/// Helios uses u16 12-bit coordinates (0-4095) with inverted axes and u8 colors.
fn from(p: &LaserPoint) -> Self {
Point {
coordinate: Coordinate {
x: LaserPoint::coord_to_u12_inverted(p.x),
y: LaserPoint::coord_to_u12_inverted(p.y),
},
color: Color::new(
LaserPoint::color_to_u8(p.r),
LaserPoint::color_to_u8(p.g),
LaserPoint::color_to_u8(p.b),
),
intensity: LaserPoint::color_to_u8(p.intensity),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ==========================================================================
// LaserPoint to Helios Point Conversion Tests
// These test the From<&LaserPoint> implementation which handles:
// - Coordinate inversion (Helios has inverted axes)
// - 12-bit conversion (f32 -1..1 to u16 0..4095)
// - Out-of-range clamping
// ==========================================================================
#[test]
fn test_helios_conversion_center() {
// Center point (0, 0) should map to (2047, 2047) due to inversion
// Colors: u16 values that downscale to expected u8 values (128, 64, 32, 200)
let laser_point = LaserPoint::new(0.0, 0.0, 128 * 257, 64 * 257, 32 * 257, 200 * 257);
let helios_point: Point = (&laser_point).into();
// (1.0 - (0.0 + 1.0) / 2.0) * 4095 = (1.0 - 0.5) * 4095 = 2047.5 -> 2048
assert_eq!(helios_point.coordinate.x, 2048);
assert_eq!(helios_point.coordinate.y, 2048);
// Colors should downscale from u16 to u8 (>> 8)
assert_eq!(helios_point.color.r, 128);
assert_eq!(helios_point.color.g, 64);
assert_eq!(helios_point.color.b, 32);
assert_eq!(helios_point.intensity, 200);
}
#[test]
fn test_helios_conversion_boundaries() {
// Min point (-1, -1) should map to (4095, 4095) due to inversion
let min = LaserPoint::new(-1.0, -1.0, 0, 0, 0, 0);
let min_helios: Point = (&min).into();
assert_eq!(min_helios.coordinate.x, 4095);
assert_eq!(min_helios.coordinate.y, 4095);
// Max point (1, 1) should map to (0, 0) due to inversion
let max = LaserPoint::new(1.0, 1.0, 0, 0, 0, 0);
let max_helios: Point = (&max).into();
assert_eq!(max_helios.coordinate.x, 0);
assert_eq!(max_helios.coordinate.y, 0);
}
#[test]
fn test_helios_conversion_asymmetric() {
// Test that x and y convert independently with different values
let laser_point = LaserPoint::new(-0.5, 0.5, 0, 0, 0, 0);
let helios_point: Point = (&laser_point).into();
// x: (1.0 - (-0.5 + 1.0) / 2.0) * 4095 = (1.0 - 0.25) * 4095 = 3071.25 -> 3071
// y: (1.0 - (0.5 + 1.0) / 2.0) * 4095 = (1.0 - 0.75) * 4095 = 1023.75 -> 1024
assert_eq!(helios_point.coordinate.x, 3071);
assert_eq!(helios_point.coordinate.y, 1024);
}
#[test]
fn test_helios_conversion_clamps_out_of_range() {
// Out of range positive values should clamp to 0 (due to inversion)
let positive = LaserPoint::new(2.0, 3.0, 0, 0, 0, 0);
let positive_helios: Point = (&positive).into();
assert_eq!(positive_helios.coordinate.x, 0);
assert_eq!(positive_helios.coordinate.y, 0);
// Out of range negative values should clamp to 4095 (due to inversion)
let negative = LaserPoint::new(-2.0, -3.0, 0, 0, 0, 0);
let negative_helios: Point = (&negative).into();
assert_eq!(negative_helios.coordinate.x, 4095);
assert_eq!(negative_helios.coordinate.y, 4095);
}
#[test]
fn test_helios_inversion_symmetry() {
// Verify that x and -x produce symmetric results around center
// This validates the inversion formula is mathematically correct
let p1 = LaserPoint::new(0.5, 0.0, 0, 0, 0, 0);
let p2 = LaserPoint::new(-0.5, 0.0, 0, 0, 0, 0);
let h1: Point = (&p1).into();
let h2: Point = (&p2).into();
// Due to 12-bit resolution, h1.x + h2.x should equal ~4095
let sum = h1.coordinate.x as i32 + h2.coordinate.x as i32;
assert!((sum - 4095).abs() <= 1, "Sum was {}, expected ~4095", sum);
}
#[test]
fn test_helios_conversion_infinity_clamps() {
let laser_point = LaserPoint::new(f32::INFINITY, f32::NEG_INFINITY, 0, 0, 0, 0);
let helios_point: Point = (&laser_point).into();
// Infinity clamps like out-of-range values
assert_eq!(helios_point.coordinate.x, 0);
assert_eq!(helios_point.coordinate.y, 4095);
}
#[test]
fn test_helios_conversion_nan_does_not_panic() {
// NaN should produce some valid output without panicking
let laser_point = LaserPoint::new(
f32::NAN,
f32::NAN,
100 * 257,
100 * 257,
100 * 257,
100 * 257,
);
let helios_point: Point = (&laser_point).into();
// Just verify it's within valid range
assert!(helios_point.coordinate.x <= 4095);
assert!(helios_point.coordinate.y <= 4095);
}
}