gled-model 2.31.1

The data model of gled: projects, scenes, effects, animations, curves and assets
Documentation
//! The description of a physical lamp: its pixels, the shapes they are drawn
//! with and the modes that group them. Definitions are assets.

mod color_order;
mod edit;
pub mod generators;
mod model_presets;
pub mod svg_geometry;

pub use color_order::{ColorOrder, UnknownColorOrder};
pub use edit::DefinitionError;

use crate::asset::AssetTrait;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::hash::Hash;
use std::path::PathBuf;
use strum::{Display, EnumIter, EnumMessage};

/// How strongly a newly set photo shows: faint, so the pixels stay in front.
pub const DEFAULT_PHOTO_OPACITY: f32 = 0.1;

/// A single emitter channel of a pixel. The DMX offset of a channel is its
/// position in the fixture's flattened pixel/channel order.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmittedColor {
    Red,
    Green,
    Blue,
}

/// A shape, defined once and referenced by the pixels that use it — GDTF's
/// `Models`. Position and rotation live on the referencing pixel, never in the
/// SVG, so 64 identical emitters cost one drawing.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Model {
    pub name: String,
    /// Physical extent the SVG is drawn at, in metres.
    pub size_m: [f32; 2],
    pub svg: String,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Pixel {
    pub name: String,
    /// Index into [`FixtureDefinition::models`].
    pub model: usize,
    /// Offset from the fixture's origin, in metres.
    pub position_m: [f32; 3],
    /// Rotation about the fixture's Z axis, in radians.
    pub rotation: f32,
    pub channels: Vec<EmittedColor>,
}

impl Pixel {
    /// A pixel drawn with the first model, unturned.
    pub fn new(name: impl Into<String>, position_m: [f32; 3], order: ColorOrder) -> Self {
        Self {
            name: name.into(),
            model: 0,
            position_m,
            rotation: 0.0,
            channels: order.channels().to_vec(),
        }
    }
}

/// Group ids for `keys`, one per key: equal keys share a group, and the
/// groups are numbered by first appearance, so the ids run `0..n` in order.
pub fn group_ids<K: Eq + Hash>(keys: impl IntoIterator<Item = K>) -> Vec<usize> {
    let mut ids = HashMap::new();
    keys.into_iter()
        .map(|key| {
            let next = ids.len();
            *ids.entry(key).or_insert(next)
        })
        .collect()
}

/// A mode maps every pixel of a definition onto a pixel group.
///
/// `group_of[pixel] == group`, so the mapping is a total function and therefore
/// a partition of the pixels. The default mode "individual" is the identity.
/// A mode changes only how many distinct colours are sampled, never the
/// channel layout.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Mode {
    pub name: String,
    pub group_of: Vec<usize>,
}

impl Mode {
    pub fn individual(pixel_count: usize) -> Self {
        Self {
            name: "individual".to_string(),
            group_of: (0..pixel_count).collect(),
        }
    }

    /// Number of distinct groups this mode maps its pixels onto.
    pub fn group_count(&self) -> usize {
        self.group_of.iter().copied().collect::<BTreeSet<_>>().len()
    }

    /// Pixel indices per group, in group order.
    pub fn groups(&self) -> BTreeMap<usize, Vec<usize>> {
        let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
        for (pixel, group) in self.group_of.iter().enumerate() {
            groups.entry(*group).or_default().push(pixel);
        }
        groups
    }
}

/// Which distortions a layout may apply to a fixture.
///
/// A layout places a fixture in a room seen from one viewpoint, so the handles
/// it offers have to match the fixture's shape: stretching a bar across its
/// length axis means nothing, because its pixels sit on one line.
///
/// It reads as the editor words it; its message is the editor's hint.
#[derive(
    Serialize,
    Deserialize,
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    Default,
    Display,
    EnumIter,
    EnumMessage,
)]
pub enum Scaling {
    /// Size follows the fixture's own axis; its width keeps the definition's
    /// proportions. A pixel bar.
    #[default]
    #[strum(
        serialize = "along its axis",
        message = "Its size follows its own axis and its width keeps the proportions: a bar"
    )]
    AlongAxis,
    /// Each axis scales on its own, even where that could not happen in the
    /// physical world. A pixel array, distorted for effect.
    #[strum(
        serialize = "freely",
        message = "Each axis scales on its own, even beyond what a room could show: a matrix"
    )]
    Free,
}

/// Everything relevant about a physical lamp which may be installed. Physical
/// dimensions are authoritative; a layout only places and scales the fixture.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct FixtureDefinition {
    /// Overall physical extent, in metres.
    pub size_m: [f32; 3],
    pub models: Vec<Model>,
    /// Model drawn as the fixture's outline in the patch editor.
    pub outline: Option<usize>,
    pub pixels: Vec<Pixel>,
    pub modes: Vec<Mode>,
    /// How a layout may distort the fixture.
    pub scaling: Scaling,
    /// A picture of the lamp drawn under its pixels, in the editors and when
    /// picking a fixture to patch; definitions without one show only their
    /// own drawing.
    pub photo: Option<Photo>,
}

/// A photograph or drawing of the lamp, placed in the definition's frame so
/// it shows which way the lamp hangs. An image file beside the definition,
/// PNG or SVG.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Photo {
    pub file: PathBuf,
    /// Extent, in metres.
    pub size_m: [f32; 2],
    /// Where its centre sits, from the fixture's origin, in metres.
    #[serde(default)]
    pub offset_m: [f32; 2],
    /// From 0 (invisible) to 1.
    #[serde(default = "default_photo_opacity")]
    pub opacity: f32,
}

fn default_photo_opacity() -> f32 {
    DEFAULT_PHOTO_OPACITY
}

impl Photo {
    /// How far the photo reaches from the fixture's origin along x and y,
    /// in metres.
    pub fn reach_m(&self) -> [f32; 2] {
        [0, 1].map(|axis| self.offset_m[axis].abs() + self.size_m[axis].abs() / 2.0)
    }
}

impl Default for FixtureDefinition {
    /// An empty definition, as a newly created asset starts out. Definitions
    /// are assets on disk; nothing about a physical lamp is compiled in. A
    /// fresh installation has none: draw one in the definition editor, or old
    /// SVG lamps are converted on start.
    fn default() -> Self {
        Self {
            size_m: [0.0; 3],
            models: vec![],
            outline: None,
            pixels: vec![],
            modes: vec![Mode::individual(0)],
            scaling: Scaling::default(),
            photo: None,
        }
    }
}

impl FixtureDefinition {
    /// The DMX channels the fixture occupies. Saturates at `u16::MAX`: a
    /// malformed definition with more channels is far wider than a universe
    /// either way, and gets no address (see `Patch::addresses`).
    pub fn channel_count(&self) -> u16 {
        channel_sum(&self.pixels)
    }

    /// DMX offset of `pixel` from the fixture's address. Saturates like
    /// [`Self::channel_count`].
    pub fn channel_offset(&self, pixel: usize) -> u16 {
        channel_sum(&self.pixels[..pixel.min(self.pixels.len())])
    }

    /// Index of the definition's default mode. Always the first one.
    pub fn default_mode(&self) -> usize {
        0
    }

    pub fn mode(&self, index: usize) -> Option<&Mode> {
        self.modes.get(index)
    }

    pub fn mode_names(&self) -> Vec<&str> {
        self.modes.iter().map(|mode| mode.name.as_str()).collect()
    }

    pub fn model(&self, index: usize) -> Option<&Model> {
        self.models.get(index)
    }

    /// The first pixel that is not a permutation of red, green and blue, if
    /// any. gled drives three-channel RGB pixels in any order, nothing else.
    pub fn non_rgb_pixel(&self) -> Option<&Pixel> {
        self.pixels
            .iter()
            .find(|pixel| pixel.color_order().is_none())
    }
}

impl AssetTrait for FixtureDefinition {
    const DIR_NAME: &'static str = "fixture_definitions";
    const NAME: &'static str = "Fixture Definition";
    const SHOW_NAME_IF_SELECTED: bool = true;
}

/// The channels of `pixels` together, saturating at `u16::MAX`.
fn channel_sum(pixels: &[Pixel]) -> u16 {
    pixels.iter().fold(0u16, |sum, pixel| {
        sum.saturating_add(u16::try_from(pixel.channels.len()).unwrap_or(u16::MAX))
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_photo_without_offset_or_opacity_sits_on_the_origin_and_shows_faintly() {
        let photo: Photo =
            serde_json::from_str(r#"{"file": "lamp.png", "size_m": [1.0, 0.5]}"#).expect("a photo");
        assert_eq!(photo.offset_m, [0.0, 0.0]);
        assert_eq!(photo.opacity, DEFAULT_PHOTO_OPACITY);
    }

    #[test]
    fn a_photo_reaches_as_far_as_its_farthest_edge() {
        let photo = Photo {
            file: PathBuf::from("lamp.png"),
            size_m: [1.0, 0.2],
            offset_m: [0.25, -0.1],
            opacity: 0.1,
        };
        let [x, y] = photo.reach_m();
        assert!((x - 0.75).abs() < 1e-6 && (y - 0.2).abs() < 1e-6, "{x} {y}");
    }

    fn rgb_pixel(name: &str, x_m: f32) -> Pixel {
        Pixel::new(name, [x_m, 0.0, 0.0], ColorOrder::Rgb)
    }

    #[test]
    fn a_new_definition_is_empty_but_has_its_default_mode() {
        let definition = FixtureDefinition::default();
        assert!(definition.pixels.is_empty());
        assert_eq!(definition.channel_count(), 0);
        assert_eq!(definition.modes.len(), 1);
    }

    #[test]
    fn channel_offsets_follow_the_pixel_order() {
        let definition = FixtureDefinition {
            pixels: vec![
                rgb_pixel("a", 0.0),
                rgb_pixel("b", 0.1),
                rgb_pixel("c", 0.2),
            ],
            ..Default::default()
        };
        assert_eq!(definition.channel_count(), 9);
        assert_eq!(definition.channel_offset(0), 0);
        assert_eq!(definition.channel_offset(1), 3);
        assert_eq!(definition.channel_offset(2), 6);
    }

    /// 22,000 RGB pixels are 66,000 channels, more than a `u16` holds.
    #[test]
    fn a_definition_wider_than_u16_saturates_instead_of_overflowing() {
        let definition = crate::test_support::rgb_bar(22_000, 1.0);
        assert_eq!(definition.channel_count(), u16::MAX);
        assert_eq!(definition.channel_offset(100), 300);
        assert_eq!(definition.channel_offset(21_845), 65_535);
        assert_eq!(definition.channel_offset(22_000), u16::MAX);

        let mut wide = rgb_pixel("wide", 0.0);
        wide.channels = vec![EmittedColor::Red; 70_000];
        let definition = FixtureDefinition {
            pixels: vec![rgb_pixel("a", 0.0), wide],
            ..Default::default()
        };
        assert_eq!(definition.channel_count(), u16::MAX);
        assert_eq!(definition.channel_offset(1), 3);
    }

    #[test]
    fn group_ids_relabel_by_first_appearance() {
        assert_eq!(group_ids([7, 7, 3, 7, 9, 3]), [0, 0, 1, 0, 2, 1]);
        assert_eq!(group_ids(Vec::<usize>::new()), Vec::<usize>::new());
    }

    #[test]
    fn individual_mode_is_the_identity() {
        let mode = Mode::individual(4);
        assert_eq!(mode.group_of, vec![0, 1, 2, 3]);
        assert_eq!(mode.group_count(), 4);
    }

    #[test]
    fn a_mode_groups_pixels() {
        // "pairs" over 6 pixels: three groups of two.
        let mode = Mode {
            name: "pairs".to_string(),
            group_of: vec![0, 0, 1, 1, 2, 2],
        };
        assert_eq!(mode.group_count(), 3);
        assert_eq!(mode.groups()[&0], vec![0, 1]);
        assert_eq!(mode.groups()[&2], vec![4, 5]);
    }

    #[test]
    fn a_pixel_that_is_no_rgb_permutation_is_detected() {
        let mut pixel = rgb_pixel("w", 0.0);
        pixel.channels.push(EmittedColor::Red);
        let definition = FixtureDefinition {
            pixels: vec![rgb_pixel("a", 0.0), pixel],
            ..Default::default()
        };
        assert_eq!(
            definition.non_rgb_pixel().map(|p| p.name.as_str()),
            Some("w")
        );
    }

    /// GRB is a permutation of RGB, which the output reorders.
    #[test]
    fn a_permuted_rgb_pixel_is_drivable() {
        let mut pixel = rgb_pixel("grb", 0.0);
        pixel.set_color_order(ColorOrder::Grb);
        let definition = FixtureDefinition {
            pixels: vec![pixel],
            ..Default::default()
        };
        assert!(definition.non_rgb_pixel().is_none());
    }
}