Skip to main content

gled_model/patching/definition/
mod.rs

1//! The description of a physical lamp: its pixels, the shapes they are drawn
2//! with and the modes that group them. Definitions are assets.
3
4mod color_order;
5mod edit;
6pub mod generators;
7mod model_presets;
8pub mod svg_geometry;
9
10pub use color_order::{ColorOrder, UnknownColorOrder};
11pub use edit::DefinitionError;
12
13use crate::asset::AssetTrait;
14use serde::{Deserialize, Serialize};
15use std::collections::{BTreeMap, BTreeSet, HashMap};
16use std::hash::Hash;
17use std::path::PathBuf;
18use strum::{Display, EnumIter, EnumMessage};
19
20/// How strongly a newly set photo shows: faint, so the pixels stay in front.
21pub const DEFAULT_PHOTO_OPACITY: f32 = 0.1;
22
23/// A single emitter channel of a pixel. The DMX offset of a channel is its
24/// position in the fixture's flattened pixel/channel order.
25#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
26pub enum EmittedColor {
27    Red,
28    Green,
29    Blue,
30}
31
32/// A shape, defined once and referenced by the pixels that use it — GDTF's
33/// `Models`. Position and rotation live on the referencing pixel, never in the
34/// SVG, so 64 identical emitters cost one drawing.
35#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
36pub struct Model {
37    pub name: String,
38    /// Physical extent the SVG is drawn at, in metres.
39    pub size_m: [f32; 2],
40    pub svg: String,
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
44pub struct Pixel {
45    pub name: String,
46    /// Index into [`FixtureDefinition::models`].
47    pub model: usize,
48    /// Offset from the fixture's origin, in metres.
49    pub position_m: [f32; 3],
50    /// Rotation about the fixture's Z axis, in radians.
51    pub rotation: f32,
52    pub channels: Vec<EmittedColor>,
53}
54
55impl Pixel {
56    /// A pixel drawn with the first model, unturned.
57    pub fn new(name: impl Into<String>, position_m: [f32; 3], order: ColorOrder) -> Self {
58        Self {
59            name: name.into(),
60            model: 0,
61            position_m,
62            rotation: 0.0,
63            channels: order.channels().to_vec(),
64        }
65    }
66}
67
68/// Group ids for `keys`, one per key: equal keys share a group, and the
69/// groups are numbered by first appearance, so the ids run `0..n` in order.
70pub fn group_ids<K: Eq + Hash>(keys: impl IntoIterator<Item = K>) -> Vec<usize> {
71    let mut ids = HashMap::new();
72    keys.into_iter()
73        .map(|key| {
74            let next = ids.len();
75            *ids.entry(key).or_insert(next)
76        })
77        .collect()
78}
79
80/// A mode maps every pixel of a definition onto a pixel group.
81///
82/// `group_of[pixel] == group`, so the mapping is a total function and therefore
83/// a partition of the pixels. The default mode "individual" is the identity.
84/// A mode changes only how many distinct colours are sampled, never the
85/// channel layout.
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
87pub struct Mode {
88    pub name: String,
89    pub group_of: Vec<usize>,
90}
91
92impl Mode {
93    pub fn individual(pixel_count: usize) -> Self {
94        Self {
95            name: "individual".to_string(),
96            group_of: (0..pixel_count).collect(),
97        }
98    }
99
100    /// Number of distinct groups this mode maps its pixels onto.
101    pub fn group_count(&self) -> usize {
102        self.group_of.iter().copied().collect::<BTreeSet<_>>().len()
103    }
104
105    /// Pixel indices per group, in group order.
106    pub fn groups(&self) -> BTreeMap<usize, Vec<usize>> {
107        let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
108        for (pixel, group) in self.group_of.iter().enumerate() {
109            groups.entry(*group).or_default().push(pixel);
110        }
111        groups
112    }
113}
114
115/// Which distortions a layout may apply to a fixture.
116///
117/// A layout places a fixture in a room seen from one viewpoint, so the handles
118/// it offers have to match the fixture's shape: stretching a bar across its
119/// length axis means nothing, because its pixels sit on one line.
120///
121/// It reads as the editor words it; its message is the editor's hint.
122#[derive(
123    Serialize,
124    Deserialize,
125    Debug,
126    Clone,
127    Copy,
128    PartialEq,
129    Eq,
130    Default,
131    Display,
132    EnumIter,
133    EnumMessage,
134)]
135pub enum Scaling {
136    /// Size follows the fixture's own axis; its width keeps the definition's
137    /// proportions. A pixel bar.
138    #[default]
139    #[strum(
140        serialize = "along its axis",
141        message = "Its size follows its own axis and its width keeps the proportions: a bar"
142    )]
143    AlongAxis,
144    /// Each axis scales on its own, even where that could not happen in the
145    /// physical world. A pixel array, distorted for effect.
146    #[strum(
147        serialize = "freely",
148        message = "Each axis scales on its own, even beyond what a room could show: a matrix"
149    )]
150    Free,
151}
152
153/// Everything relevant about a physical lamp which may be installed. Physical
154/// dimensions are authoritative; a layout only places and scales the fixture.
155#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
156#[serde(default)]
157pub struct FixtureDefinition {
158    /// Overall physical extent, in metres.
159    pub size_m: [f32; 3],
160    pub models: Vec<Model>,
161    /// Model drawn as the fixture's outline in the patch editor.
162    pub outline: Option<usize>,
163    pub pixels: Vec<Pixel>,
164    pub modes: Vec<Mode>,
165    /// How a layout may distort the fixture.
166    pub scaling: Scaling,
167    /// A picture of the lamp drawn under its pixels, in the editors and when
168    /// picking a fixture to patch; definitions without one show only their
169    /// own drawing.
170    pub photo: Option<Photo>,
171}
172
173/// A photograph or drawing of the lamp, placed in the definition's frame so
174/// it shows which way the lamp hangs. A PNG, JPEG or SVG in the asset
175/// repository, named by its path there (see `asset::picture`).
176#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
177pub struct Photo {
178    pub file: PathBuf,
179    /// Extent, in metres.
180    pub size_m: [f32; 2],
181    /// Where its centre sits, from the fixture's origin, in metres.
182    #[serde(default)]
183    pub offset_m: [f32; 2],
184    /// From 0 (invisible) to 1.
185    #[serde(default = "default_photo_opacity")]
186    pub opacity: f32,
187}
188
189fn default_photo_opacity() -> f32 {
190    DEFAULT_PHOTO_OPACITY
191}
192
193impl Photo {
194    /// How far the photo reaches from the fixture's origin along x and y,
195    /// in metres.
196    pub fn reach_m(&self) -> [f32; 2] {
197        [0, 1].map(|axis| self.offset_m[axis].abs() + self.size_m[axis].abs() / 2.0)
198    }
199}
200
201impl Default for FixtureDefinition {
202    /// An empty definition, as a newly created asset starts out. Definitions
203    /// are assets on disk; nothing about a physical lamp is compiled in. A
204    /// fresh installation has none: draw one in the definition editor, or old
205    /// SVG lamps are converted on start.
206    fn default() -> Self {
207        Self {
208            size_m: [0.0; 3],
209            models: vec![],
210            outline: None,
211            pixels: vec![],
212            modes: vec![Mode::individual(0)],
213            scaling: Scaling::default(),
214            photo: None,
215        }
216    }
217}
218
219impl FixtureDefinition {
220    /// The DMX channels the fixture occupies. Saturates at `u16::MAX`: a
221    /// malformed definition with more channels is far wider than a universe
222    /// either way, and gets no address (see `Patch::addresses`).
223    pub fn channel_count(&self) -> u16 {
224        channel_sum(&self.pixels)
225    }
226
227    /// DMX offset of `pixel` from the fixture's address. Saturates like
228    /// [`Self::channel_count`].
229    pub fn channel_offset(&self, pixel: usize) -> u16 {
230        channel_sum(&self.pixels[..pixel.min(self.pixels.len())])
231    }
232
233    /// Index of the definition's default mode. Always the first one.
234    pub fn default_mode(&self) -> usize {
235        0
236    }
237
238    pub fn mode(&self, index: usize) -> Option<&Mode> {
239        self.modes.get(index)
240    }
241
242    pub fn mode_names(&self) -> Vec<&str> {
243        self.modes.iter().map(|mode| mode.name.as_str()).collect()
244    }
245
246    pub fn model(&self, index: usize) -> Option<&Model> {
247        self.models.get(index)
248    }
249
250    /// The first pixel that is not a permutation of red, green and blue, if
251    /// any. gled drives three-channel RGB pixels in any order, nothing else.
252    pub fn non_rgb_pixel(&self) -> Option<&Pixel> {
253        self.pixels
254            .iter()
255            .find(|pixel| pixel.color_order().is_none())
256    }
257}
258
259impl AssetTrait for FixtureDefinition {
260    const DIR_NAME: &'static str = "fixture_definitions";
261    const NAME: &'static str = "Fixture Definition";
262    const SHOW_NAME_IF_SELECTED: bool = true;
263}
264
265/// The channels of `pixels` together, saturating at `u16::MAX`.
266fn channel_sum(pixels: &[Pixel]) -> u16 {
267    pixels.iter().fold(0u16, |sum, pixel| {
268        sum.saturating_add(u16::try_from(pixel.channels.len()).unwrap_or(u16::MAX))
269    })
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn a_photo_without_offset_or_opacity_sits_on_the_origin_and_shows_faintly() {
278        let photo: Photo =
279            serde_json::from_str(r#"{"file": "lamp.png", "size_m": [1.0, 0.5]}"#).expect("a photo");
280        assert_eq!(photo.offset_m, [0.0, 0.0]);
281        assert_eq!(photo.opacity, DEFAULT_PHOTO_OPACITY);
282    }
283
284    #[test]
285    fn a_photo_reaches_as_far_as_its_farthest_edge() {
286        let photo = Photo {
287            file: PathBuf::from("lamp.png"),
288            size_m: [1.0, 0.2],
289            offset_m: [0.25, -0.1],
290            opacity: 0.1,
291        };
292        let [x, y] = photo.reach_m();
293        assert!((x - 0.75).abs() < 1e-6 && (y - 0.2).abs() < 1e-6, "{x} {y}");
294    }
295
296    fn rgb_pixel(name: &str, x_m: f32) -> Pixel {
297        Pixel::new(name, [x_m, 0.0, 0.0], ColorOrder::Rgb)
298    }
299
300    #[test]
301    fn a_new_definition_is_empty_but_has_its_default_mode() {
302        let definition = FixtureDefinition::default();
303        assert!(definition.pixels.is_empty());
304        assert_eq!(definition.channel_count(), 0);
305        assert_eq!(definition.modes.len(), 1);
306    }
307
308    #[test]
309    fn channel_offsets_follow_the_pixel_order() {
310        let definition = FixtureDefinition {
311            pixels: vec![
312                rgb_pixel("a", 0.0),
313                rgb_pixel("b", 0.1),
314                rgb_pixel("c", 0.2),
315            ],
316            ..Default::default()
317        };
318        assert_eq!(definition.channel_count(), 9);
319        assert_eq!(definition.channel_offset(0), 0);
320        assert_eq!(definition.channel_offset(1), 3);
321        assert_eq!(definition.channel_offset(2), 6);
322    }
323
324    /// 22,000 RGB pixels are 66,000 channels, more than a `u16` holds.
325    #[test]
326    fn a_definition_wider_than_u16_saturates_instead_of_overflowing() {
327        let definition = crate::test_support::rgb_bar(22_000, 1.0);
328        assert_eq!(definition.channel_count(), u16::MAX);
329        assert_eq!(definition.channel_offset(100), 300);
330        assert_eq!(definition.channel_offset(21_845), 65_535);
331        assert_eq!(definition.channel_offset(22_000), u16::MAX);
332
333        let mut wide = rgb_pixel("wide", 0.0);
334        wide.channels = vec![EmittedColor::Red; 70_000];
335        let definition = FixtureDefinition {
336            pixels: vec![rgb_pixel("a", 0.0), wide],
337            ..Default::default()
338        };
339        assert_eq!(definition.channel_count(), u16::MAX);
340        assert_eq!(definition.channel_offset(1), 3);
341    }
342
343    #[test]
344    fn group_ids_relabel_by_first_appearance() {
345        assert_eq!(group_ids([7, 7, 3, 7, 9, 3]), [0, 0, 1, 0, 2, 1]);
346        assert_eq!(group_ids(Vec::<usize>::new()), Vec::<usize>::new());
347    }
348
349    #[test]
350    fn individual_mode_is_the_identity() {
351        let mode = Mode::individual(4);
352        assert_eq!(mode.group_of, vec![0, 1, 2, 3]);
353        assert_eq!(mode.group_count(), 4);
354    }
355
356    #[test]
357    fn a_mode_groups_pixels() {
358        // "pairs" over 6 pixels: three groups of two.
359        let mode = Mode {
360            name: "pairs".to_string(),
361            group_of: vec![0, 0, 1, 1, 2, 2],
362        };
363        assert_eq!(mode.group_count(), 3);
364        assert_eq!(mode.groups()[&0], vec![0, 1]);
365        assert_eq!(mode.groups()[&2], vec![4, 5]);
366    }
367
368    #[test]
369    fn a_pixel_that_is_no_rgb_permutation_is_detected() {
370        let mut pixel = rgb_pixel("w", 0.0);
371        pixel.channels.push(EmittedColor::Red);
372        let definition = FixtureDefinition {
373            pixels: vec![rgb_pixel("a", 0.0), pixel],
374            ..Default::default()
375        };
376        assert_eq!(
377            definition.non_rgb_pixel().map(|p| p.name.as_str()),
378            Some("w")
379        );
380    }
381
382    /// GRB is a permutation of RGB, which the output reorders.
383    #[test]
384    fn a_permuted_rgb_pixel_is_drivable() {
385        let mut pixel = rgb_pixel("grb", 0.0);
386        pixel.set_color_order(ColorOrder::Grb);
387        let definition = FixtureDefinition {
388            pixels: vec![pixel],
389            ..Default::default()
390        };
391        assert!(definition.non_rgb_pixel().is_none());
392    }
393}