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};
pub const DEFAULT_PHOTO_OPACITY: f32 = 0.1;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmittedColor {
Red,
Green,
Blue,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Model {
pub name: String,
pub size_m: [f32; 2],
pub svg: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Pixel {
pub name: String,
pub model: usize,
pub position_m: [f32; 3],
pub rotation: f32,
pub channels: Vec<EmittedColor>,
}
impl Pixel {
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(),
}
}
}
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()
}
#[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(),
}
}
pub fn group_count(&self) -> usize {
self.group_of.iter().copied().collect::<BTreeSet<_>>().len()
}
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
}
}
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
Display,
EnumIter,
EnumMessage,
)]
pub enum Scaling {
#[default]
#[strum(
serialize = "along its axis",
message = "Its size follows its own axis and its width keeps the proportions: a bar"
)]
AlongAxis,
#[strum(
serialize = "freely",
message = "Each axis scales on its own, even beyond what a room could show: a matrix"
)]
Free,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct FixtureDefinition {
pub size_m: [f32; 3],
pub models: Vec<Model>,
pub outline: Option<usize>,
pub pixels: Vec<Pixel>,
pub modes: Vec<Mode>,
pub scaling: Scaling,
pub photo: Option<Photo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Photo {
pub file: PathBuf,
pub size_m: [f32; 2],
#[serde(default)]
pub offset_m: [f32; 2],
#[serde(default = "default_photo_opacity")]
pub opacity: f32,
}
fn default_photo_opacity() -> f32 {
DEFAULT_PHOTO_OPACITY
}
impl Photo {
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 {
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 {
pub fn channel_count(&self) -> u16 {
channel_sum(&self.pixels)
}
pub fn channel_offset(&self, pixel: usize) -> u16 {
channel_sum(&self.pixels[..pixel.min(self.pixels.len())])
}
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)
}
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;
}
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);
}
#[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() {
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")
);
}
#[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());
}
}