gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Where a definition's pixels are on the editor canvas, and which of them a
//! point or a band hits. The canvas is a square scene with the fixture's
//! origin in its middle; one scene unit is a centimetre.

use crate::canvas::definition_drawing::reach_with_photo_m;
use crate::canvas::rotated_rect::RotatedRect;
use egui::{Pos2, Rect, Vec2, pos2, vec2};
use gled_model::patching::definition::{FixtureDefinition, Pixel};
use gled_model::patching::fixture::UNITS_PER_METRE;

/// Smallest side of the canvas's scene, in scene units, so a single LED does
/// not fill the whole canvas.
const MIN_SCENE: f32 = 50.0;
/// Largest side, so a mistyped size cannot make the canvas unusable.
const MAX_SCENE: f32 = 4000.0;
/// How much room the scene leaves around the fixture.
const SCENE_MARGIN: f32 = 1.5;

/// The side of the scene a definition is edited in.
pub(crate) fn scene_side(definition: &FixtureDefinition) -> f32 {
    (2.0 * SCENE_MARGIN * reach_with_photo_m(definition) * UNITS_PER_METRE)
        .clamp(MIN_SCENE, MAX_SCENE)
}

/// Whether the definition has grown past what a scene of `side` shows well.
pub(crate) fn outgrows(definition: &FixtureDefinition, side: f32) -> bool {
    2.0 * reach_with_photo_m(definition) * UNITS_PER_METRE > 0.8 * side && side < MAX_SCENE
}

/// The scene point of the fixture's origin in a scene of `side`.
pub(crate) fn origin(side: f32) -> Pos2 {
    pos2(side / 2.0, side / 2.0)
}

/// Scene position of a point in the definition's frame.
pub(crate) fn to_scene(origin: Pos2, position_m: [f32; 3]) -> Pos2 {
    origin + vec2(position_m[0], position_m[1]) * UNITS_PER_METRE
}

/// The definition-frame offset of a scene position, in metres.
pub(crate) fn to_metres(origin: Pos2, scene: Pos2) -> [f32; 2] {
    let offset = (scene - origin) / UNITS_PER_METRE;
    [offset.x, offset.y]
}

/// A pixel's drawing on the canvas, in scene units. `min_half` keeps a tiny
/// LED large enough to point at.
pub(crate) fn pixel_shape(
    definition: &FixtureDefinition,
    pixel: usize,
    origin: Pos2,
    min_half: f32,
) -> Option<RotatedRect> {
    let pixel = definition.pixels.get(pixel)?;
    let size_m = definition.model(pixel.model).map(|model| model.size_m);
    Some(pixel_shape_of(pixel, size_m, origin, min_half))
}

/// The shape of a pixel drawn with a model `size_m` big, or a point without
/// one.
pub(crate) fn pixel_shape_of(
    pixel: &Pixel,
    size_m: Option<[f32; 2]>,
    origin: Pos2,
    min_half: f32,
) -> RotatedRect {
    let size = size_m.map_or(Vec2::ZERO, |size| vec2(size[0], size[1]) * UNITS_PER_METRE);
    RotatedRect {
        centre: to_scene(origin, pixel.position_m),
        half: vec2((size.x / 2.0).max(min_half), (size.y / 2.0).max(min_half)),
        angle: pixel.rotation,
    }
}

/// The topmost pixel at `point`: the last drawn, which is the last in DMX
/// order.
pub(crate) fn pixel_under(
    definition: &FixtureDefinition,
    origin: Pos2,
    point: Pos2,
    min_half: f32,
) -> Option<usize> {
    (0..definition.pixels.len()).rev().find(|pixel| {
        pixel_shape(definition, *pixel, origin, min_half).is_some_and(|shape| shape.contains(point))
    })
}

/// The pixels whose centres lie inside `band`.
pub(crate) fn covered_pixels(
    definition: &FixtureDefinition,
    origin: Pos2,
    band: Rect,
) -> Vec<usize> {
    definition
        .pixels
        .iter()
        .enumerate()
        .filter(|(_, pixel)| band.contains(to_scene(origin, pixel.position_m)))
        .map(|(index, _)| index)
        .collect()
}

/// The box around the drawings of the selected pixels, in scene units. Its
/// handles then sit beside the pixels rather than on them, so a pixel at the
/// end of a selected row can still be clicked.
pub(crate) fn selection_bbox(
    definition: &FixtureDefinition,
    origin: Pos2,
    selection: impl IntoIterator<Item = usize>,
) -> Option<Rect> {
    let corners: Vec<Pos2> = selection
        .into_iter()
        .filter_map(|pixel| pixel_shape(definition, pixel, origin, 0.0))
        .flat_map(|shape| shape.corners())
        .collect();
    (!corners.is_empty()).then(|| Rect::from_points(&corners))
}

/// The size a DMX number is written in on a pixel `size_px` screen pixels
/// small, or `None` when it would not be legible.
pub(crate) fn label_font_px(size_px: f32) -> Option<f32> {
    (size_px >= 14.0).then(|| (size_px * 0.45).clamp(9.0, 16.0))
}

#[cfg(test)]
mod tests {
    use super::*;
    use gled_model::patching::definition::{EmittedColor, Model, Photo};

    fn definition() -> FixtureDefinition {
        let pixel = |x_m: f32| Pixel {
            name: "p".to_string(),
            model: 0,
            position_m: [x_m, 0.0, 0.0],
            rotation: 0.0,
            channels: vec![EmittedColor::Red, EmittedColor::Green, EmittedColor::Blue],
        };
        FixtureDefinition {
            size_m: [0.3, 0.02, 0.02],
            models: vec![Model::circle("led", 0.02)],
            pixels: vec![pixel(-0.1), pixel(0.0), pixel(0.005)],
            ..Default::default()
        }
    }

    #[test]
    fn metres_and_scene_units_round_trip() {
        let origin = origin(100.0);
        let scene = to_scene(origin, [0.12, -0.05, 0.0]);
        assert_eq!(scene, pos2(62.0, 45.0));
        let [x, y] = to_metres(origin, scene);
        assert!((x - 0.12).abs() < 1e-5 && (y + 0.05).abs() < 1e-5);
    }

    #[test]
    fn the_last_drawn_of_two_overlapping_pixels_is_hit() {
        let origin = origin(100.0);
        // pixels 1 and 2 overlap half a centimetre apart
        assert_eq!(pixel_under(&definition(), origin, origin, 0.0), Some(2));
        assert_eq!(
            pixel_under(&definition(), origin, pos2(40.0, 50.0), 0.0),
            Some(0)
        );
        assert_eq!(
            pixel_under(&definition(), origin, pos2(90.0, 50.0), 0.0),
            None
        );
    }

    #[test]
    fn a_band_covers_the_pixels_whose_centres_it_holds() {
        let origin = origin(100.0);
        let band = Rect::from_min_max(pos2(35.0, 40.0), pos2(45.0, 60.0));
        assert_eq!(covered_pixels(&definition(), origin, band), vec![0]);
    }

    #[test]
    fn the_selection_box_spans_the_selected_drawings() {
        let origin = origin(100.0);
        let bbox = selection_bbox(&definition(), origin, [0, 1]).expect("two pixels");
        // two 2 cm LEDs, 10 cm apart
        let expected = Rect::from_min_max(pos2(39.0, 49.0), pos2(51.0, 51.0));
        assert!((bbox.min - expected.min).length() < 1e-4, "{bbox:?}");
        assert!((bbox.max - expected.max).length() < 1e-4, "{bbox:?}");
    }

    #[test]
    fn the_scene_leaves_room_around_the_fixture() {
        // the bar reaches 15 cm from its origin: 45 cm, raised to the minimum
        assert!((scene_side(&definition()) - MIN_SCENE).abs() < 1e-3);
        assert!(!outgrows(&definition(), MIN_SCENE));
        assert!(outgrows(&definition(), 30.0));
    }

    #[test]
    fn the_scene_makes_room_for_the_photo() {
        let mut definition = definition();
        definition.photo = Some(Photo {
            file: "lamp.png".into(),
            size_m: [1.0, 1.0],
            offset_m: [0.5, 0.0],
            opacity: 0.1,
        });
        // the photo reaches a metre from the origin: 3 m with the margin
        assert!((scene_side(&definition) - 300.0).abs() < 1e-3);
    }

    #[test]
    fn tiny_pixels_get_no_number() {
        assert_eq!(label_font_px(10.0), None);
        assert_eq!(label_font_px(20.0), Some(9.0));
    }
}