gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! A fixture definition as the editors draw it: its photo, its outline and
//! its pixels, blown up where it would be too small to see, and the
//! footprint it is hit by.

use crate::canvas::caches::Caches;
use crate::canvas::model_textures::{
    ModelBatch, ModelKey, aspect_bucket, draw_placeholder, svg_hash,
};
use crate::canvas::rotated_rect::RotatedRect;
use crate::canvas::view::fit_into;
use egui::emath::Vec2;
use egui::emath::{Rot2, TSTransform};
use egui::epaint::Color32;
use egui::{Painter, Pos2, Rect, vec2};
use gled_model::patching::definition::{FixtureDefinition, Model};
use gled_model::patching::fixture::{LayoutPosition, UNITS_PER_METRE};

/// A fixture always stays this many screen pixels big, so a physically tiny
/// one — a 5 cm tall bar seen across a 20 m scene — is still clickable.
pub(crate) const MIN_FIXTURE_SCREEN_PX: f32 = 11.0;

/// Colour a fixture by the mode it resolves to, so mode assignment is visible
/// on the canvas without opening the inspector. A fixture with no definition
/// has no mode and is drawn in a colour that stands out as unpatched.
pub(crate) fn mode_color(mode: Option<usize>) -> Color32 {
    let Some(mode) = mode else {
        return Color32::from_rgb(120, 60, 60);
    };
    static COLORS: &[Color32] = &[
        Color32::from_rgb(175, 213, 129),
        Color32::from_rgb(177, 152, 221),
        Color32::from_rgb(140, 181, 255),
        Color32::from_rgb(217, 176, 140),
        Color32::from_rgb(217, 148, 140),
        Color32::from_rgb(113, 208, 132),
        Color32::from_rgb(213, 129, 192),
        Color32::from_rgb(163, 218, 224),
        Color32::from_rgb(222, 233, 190),
        Color32::from_rgb(255, 255, 255),
    ];
    COLORS[mode % COLORS.len()]
}

/// How much a fixture has to be blown up, per axis, to stay [`MIN_FIXTURE_SCREEN_PX`]
/// big on screen. A 5 cm deep bar seen across a 20 m scene is a two-pixel line
/// otherwise — legible neither to click nor to look at. `zoom` is screen
/// pixels per scene unit.
pub(crate) fn legibility_scale(
    definition: &FixtureDefinition,
    position: &LayoutPosition,
    zoom: f32,
    min_screen_px: f32,
) -> Vec2 {
    let size = position.scaled([definition.size_m[0], definition.size_m[1]]) * zoom;
    let floor = |extent: f32| (min_screen_px / extent.abs().max(f32::EPSILON)).max(1.0);
    vec2(floor(size.x), floor(size.y))
}

/// The footprint a fixture occupies in a layout, in scene units: its
/// definition's physical size times its scale, centred on its position, and
/// blown up to stay `min_screen_px` big when seen at `zoom` (screen pixels per
/// scene unit), so it is hit where it is drawn. The canvas wants a clickable
/// minimum; a thumbnail wants barely more than a mark, or every fixture in it
/// would swell into the same blob; 0 gives the fixture's true size.
pub(crate) fn fixture_footprint(
    definition: &FixtureDefinition,
    position: &LayoutPosition,
    zoom: f32,
    min_screen_px: f32,
) -> RotatedRect {
    let size = position.scaled([definition.size_m[0], definition.size_m[1]]);
    let legible = legibility_scale(definition, position, zoom, min_screen_px);
    RotatedRect {
        centre: position.pos,
        half: vec2(size.x.abs() * legible.x, size.y.abs() * legible.y) * 0.5,
        angle: position.angle,
    }
}

/// The aspect a model is actually drawn at, after the layout's scale.
fn aspect_of(model: &Model, position: &LayoutPosition) -> i32 {
    let size = position.scaled(model.size_m);
    aspect_bucket(size.x.abs() / size.y.abs().max(f32::EPSILON))
}

/// The texture key of model `index` of a definition drawn at `position`.
fn model_key(
    definition_id: Option<uuid::Uuid>,
    index: usize,
    model: &Model,
    position: &LayoutPosition,
) -> ModelKey {
    (
        definition_id,
        index,
        aspect_of(model, position),
        svg_hash(model),
    )
}

/// What colour the pixels of a definition are drawn in.
#[derive(Debug, Clone, Copy)]
pub(crate) enum PixelTint<'a> {
    /// All alike, as on the patch canvas where only the live preview lights
    /// them.
    Uniform(Color32),
    /// One colour per pixel, in pixel order; pixels past the end take the
    /// last colour, or the editor's dark if there is none.
    PerPixel(&'a [Color32]),
}

impl PixelTint<'_> {
    fn of(&self, pixel: usize) -> Color32 {
        match self {
            Self::Uniform(color) => *color,
            Self::PerPixel(colors) => colors
                .get(pixel)
                .or(colors.last())
                .copied()
                .unwrap_or(EDITOR_PIXEL),
        }
    }
}

/// Where and how big a definition is drawn.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Placement<'a> {
    /// Where it sits in the scene.
    pub(crate) position: &'a LayoutPosition,
    /// Scene units to the screen.
    pub(crate) to_global: TSTransform,
    /// The smallest it is drawn on screen, in screen pixels; 0 draws it at
    /// its true size.
    pub(crate) min_screen_px: f32,
}

impl Placement<'_> {
    /// How much it is blown up per axis to stay legible.
    fn legible(&self, definition: &FixtureDefinition) -> Vec2 {
        legibility_scale(
            definition,
            self.position,
            self.to_global.scaling,
            self.min_screen_px,
        )
    }
}

/// The colours a definition is drawn in.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Style<'a> {
    pub(crate) outline: Color32,
    pub(crate) pixels: PixelTint<'a>,
}

/// Draws a definition's photo, faint as its opacity says, so it can lie
/// under the outline and pixels. It is blown up and turned with the fixture
/// exactly as [`draw_definition`] blows up and turns the body.
pub(crate) fn draw_photo(
    painter: &Painter,
    definition: &FixtureDefinition,
    placement: &Placement<'_>,
    caches: &mut Caches,
) {
    let Some(photo) = &definition.photo else {
        return;
    };
    let Some(texture) = caches.images.texture(painter.ctx(), &photo.file) else {
        return;
    };
    let Placement {
        position,
        to_global,
        ..
    } = *placement;
    let legible = placement.legible(definition);
    let offset = position.scaled(photo.offset_m) * legible;
    let centre = position.pos + Rot2::from_angle(position.angle) * offset;
    let size = position.scaled(photo.size_m) * to_global.scaling * legible;
    let mut batch = ModelBatch::new(&texture);
    batch.push(
        to_global * centre,
        size,
        position.angle,
        Color32::WHITE.gamma_multiply(photo.opacity.clamp(0.0, 1.0)),
    );
    batch.paint(painter);
}

/// Draws a definition's body outline in `color`, and nothing inside it.
///
/// The body is blown up to the placement's minimum with the footprint, not
/// drawn inside it, so a fixture too small to read stays a fixture rather
/// than a hairline.
pub(crate) fn draw_outline(
    painter: &Painter,
    definition: &FixtureDefinition,
    definition_id: Option<uuid::Uuid>,
    placement: &Placement<'_>,
    color: Color32,
    caches: &mut Caches,
) {
    let Placement {
        position,
        to_global,
        min_screen_px,
    } = *placement;
    let legible = placement.legible(definition);
    let outline = definition
        .outline
        .and_then(|index| Some((index, definition.model(index)?)));
    match outline.and_then(|(index, model)| {
        let key = model_key(definition_id, index, model, position);
        Some((caches.models.texture(painter.ctx(), key, model)?, model))
    }) {
        Some((texture, model)) => {
            let size = position.scaled(model.size_m) * to_global.scaling * legible;
            let mut batch = ModelBatch::new(&texture);
            batch.push(to_global * position.pos, size, position.angle, color);
            batch.paint(painter);
        }
        None => draw_placeholder(
            painter,
            fixture_footprint(definition, position, to_global.scaling, min_screen_px)
                .transformed(to_global)
                .corners()
                .to_vec(),
            color,
        ),
    }
}

/// Draws a definition's outline and pixel shapes.
pub(crate) fn draw_definition(
    painter: &Painter,
    definition: &FixtureDefinition,
    definition_id: Option<uuid::Uuid>,
    placement: &Placement<'_>,
    style: Style<'_>,
    caches: &mut Caches,
) {
    draw_outline(
        painter,
        definition,
        definition_id,
        placement,
        style.outline,
        caches,
    );

    let Placement {
        position,
        to_global,
        ..
    } = *placement;
    let legible = placement.legible(definition);
    let rotation = Rot2::from_angle(position.angle);
    // pixels have to move out with the body they sit on
    let spread = |centre: &Pos2| {
        let offset = rotation.inverse() * (*centre - position.pos);
        position.pos + rotation * vec2(offset.x * legible.x, offset.y * legible.y)
    };

    let positions = definition.pixel_scene_positions(position);
    let mut batches: Vec<(usize, ModelBatch)> = vec![];
    for (index, pixel) in definition.pixels.iter().enumerate() {
        let (Some(model), Some(centre)) = (definition.model(pixel.model), positions.get(index))
        else {
            continue;
        };
        if !batches
            .iter()
            .any(|(model_index, _)| *model_index == pixel.model)
        {
            let key = model_key(definition_id, pixel.model, model, position);
            let Some(texture) = caches.models.texture(painter.ctx(), key, model) else {
                continue;
            };
            batches.push((pixel.model, ModelBatch::new(&texture)));
        }
        let Some((_, batch)) = batches
            .iter_mut()
            .find(|(model_index, _)| *model_index == pixel.model)
        else {
            continue;
        };
        let size = position.scaled(model.size_m) * to_global.scaling * legible;
        batch.push(
            to_global * spread(centre),
            size,
            position.angle + pixel.rotation,
            style.pixels.of(index),
        );
    }
    for (_, batch) in batches {
        batch.paint(painter);
    }
}

/// Draws a definition on its own, fitted into `rect` — for choosing a fixture
/// to patch, where it has no place in a layout yet.
pub(crate) fn draw_definition_fitted(
    painter: &Painter,
    rect: Rect,
    definition: &FixtureDefinition,
    definition_id: Option<uuid::Uuid>,
    caches: &mut Caches,
) {
    let scene = vec2(
        (definition.size_m[0] * UNITS_PER_METRE).abs().max(1.0),
        (definition.size_m[1] * UNITS_PER_METRE).abs().max(1.0),
    );
    let scale = fit_into(scene, rect).width() / scene.x;
    let placement = Placement {
        position: &LayoutPosition::at(Pos2::ZERO),
        to_global: TSTransform::new(rect.center().to_vec2(), scale),
        min_screen_px: 1.0,
    };
    let style = Style {
        outline: Color32::from_gray(210),
        pixels: PixelTint::Uniform(EDITOR_PIXEL),
    };
    draw_definition(
        painter,
        definition,
        definition_id,
        &placement,
        style,
        caches,
    );
}

/// Pixels are drawn near-black in the editor; only the live preview lights them.
pub(crate) const EDITOR_PIXEL: Color32 = Color32::from_rgb(18, 18, 20);

/// How far the fixture reaches from its origin on either axis, in metres:
/// its size and every pixel.
pub(crate) fn reach_m(definition: &FixtureDefinition) -> f32 {
    let pixels = definition.pixel_bounds_m().map_or(0.0, |(min, max)| {
        [min[0], min[1], max[0], max[1]]
            .into_iter()
            .fold(0.0f32, |reach, value| reach.max(value.abs()))
    });
    pixels
        .max(definition.size_m[0] / 2.0)
        .max(definition.size_m[1] / 2.0)
}

/// [`reach_m`], and the photo too.
pub(crate) fn reach_with_photo_m(definition: &FixtureDefinition) -> f32 {
    let photo = definition.photo.as_ref().map_or(0.0, |photo| {
        let [x, y] = photo.reach_m();
        x.max(y)
    });
    reach_m(definition).max(photo)
}

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

    fn bar() -> FixtureDefinition {
        FixtureDefinition {
            size_m: [1.0, 0.05, 0.05],
            ..Default::default()
        }
    }

    /// A fixture drawn smaller than the floor is blown up so it stays visible
    /// and clickable; one already big enough is drawn at its true size.
    #[test]
    fn a_tiny_fixture_is_blown_up_to_stay_legible() {
        let definition = bar();
        let position = LayoutPosition::at(pos2(0.0, 0.0));

        // a 1 m x 5 cm bar across a 20 m scene: 45 px long, 2 px deep
        let zoomed_out = legibility_scale(&definition, &position, 0.45, MIN_FIXTURE_SCREEN_PX);
        assert_eq!(zoomed_out.x, 1.0, "the long axis is already legible");
        assert!(zoomed_out.y > 4.0, "the short axis is not: {zoomed_out:?}");

        // close up, the fixture is drawn exactly as big as it is
        let zoomed_in = legibility_scale(&definition, &position, 8.0, MIN_FIXTURE_SCREEN_PX);
        assert_eq!(zoomed_in, egui::vec2(1.0, 1.0));
    }
}