gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! A test pattern rendered through the selected layout and mode.
//!
//! The patch's screen asks the output pipeline itself where each lamp
//! samples ([`PatchPositions`]): every pixel group of every member takes one
//! colour, at its centroid's place in the scene square. A grouping mode
//! therefore reads as fewer distinct colours, and a fixture the layout leaves
//! out — or the output cannot address — stays dark.
//!
//! What is drawn is always the default layout, because that is where the
//! fixtures physically are; the selected layout only decides where each of
//! them reads its colour from.

use crate::canvas::definition_drawing::reach_m;
use crate::canvas::rotated_rect::RotatedRect;
use crate::canvas::view::fit_scene_square;
use egui::emath::TSTransform;
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, Ui, vec2};
use gled_gpu::group_samples::GroupSamples;
use gled_gpu::patch_positions::PatchPositions;
use gled_gpu::render_to_output::positions::Lamp;
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::definition::FixtureDefinition;
use gled_model::patching::fixture::{FixtureId, LayoutPosition, UNITS_PER_METRE};
use gled_model::patching::layout::{LayoutSelection, scene_bounds};
use std::collections::BTreeMap;

/// Cells per side of the pattern backdrop. Fine enough to draw a line of
/// [`LINE_WIDTH`] without breaking it up.
const BACKDROP_CELLS: usize = 40;

/// A fixture the selected layout does not hold. Dark, but still part of the
/// rig the screen is showing.
const UNLIT: Color32 = Color32::from_gray(28);

/// How wide a sweeping line is, as a fraction of the scene.
const LINE_WIDTH: f32 = 0.25;

/// Seconds one sweep takes. The two differ so the lines do not stay in step
/// and their crossing travels over the whole square.
const SWEEP_ACROSS: f64 = 5.0;
const SWEEP_DOWN: f64 = 7.0;

/// The colour the test tile emits at `uv`, which spans the scene square.
///
/// Two lines sweep the square: a red one travelling right and a green one
/// travelling down, white where they cross. A fixture therefore lights up at
/// the moment a line reaches the place it reads from, which is what says where
/// in the scene it sits.
pub(crate) fn test_pattern(uv: egui::Vec2, time: f64) -> Color32 {
    let across = sweep(uv.x, time / SWEEP_ACROSS);
    let down = sweep(uv.y, time / SWEEP_DOWN);
    let channel = |value: f32| (255.0 * value.clamp(0.0, 1.0)) as u8;
    Color32::from_rgb(channel(across), channel(down), channel(across * down))
}

/// How brightly a line standing at `phase` lights the point `at`. Both wrap at
/// the edge of the square, so a line leaving one side enters the other.
fn sweep(at: f32, phase: f64) -> f32 {
    let distance = (at - phase.rem_euclid(1.0) as f32).abs();
    let distance = distance.min(1.0 - distance);
    // the line reaches half its width to either side of where it stands
    (1.0 - distance / (LINE_WIDTH / 2.0)).max(0.0)
}

/// The scene square on screen, with the pattern drawn behind it.
struct Screen {
    painter: Painter,
    time: f64,
    to_screen: TSTransform,
    /// Screen pixels per scene unit.
    scale: f32,
}

impl Screen {
    fn new(ui: &mut Ui, rect: Rect) -> Self {
        let painter = ui.painter_at(rect);
        let time = ui.input(|i| i.time);
        // the pattern moves, so the screen has to keep repainting
        ui.ctx().request_repaint();

        let to_screen = fit_scene_square(rect);
        let screen = to_screen * scene_bounds();
        let scale = to_screen.scaling;

        draw_backdrop(&painter, screen, time);
        painter.rect_stroke(
            screen,
            0.0,
            Stroke::new(1.0, Color32::from_gray(70)),
            StrokeKind::Inside,
        );
        Self {
            painter,
            time,
            to_screen,
            scale,
        }
    }

    /// Paints pixel `pixel` of `definition`, placed at `position` with its
    /// centre at `centre`, in `color`.
    fn draw_pixel(
        &self,
        definition: &FixtureDefinition,
        position: &LayoutPosition,
        pixel: usize,
        centre: Pos2,
        color: Color32,
    ) {
        let Some(found) = definition.pixels.get(pixel) else {
            return;
        };
        let Some(model) = definition.model(found.model) else {
            return;
        };
        let size = position.scaled(model.size_m) * self.scale;
        let half = vec2(size.x.abs().max(2.0), size.y.abs().max(2.0)) * 0.5;
        let shape = RotatedRect {
            centre: self.to_screen * centre,
            half,
            angle: position.angle + found.rotation,
        };
        self.painter.add(Shape::convex_polygon(
            shape.corners().to_vec(),
            color,
            Stroke::NONE,
        ));
    }
}

/// What every pixel of every fixture shows on a test tile driving
/// `selection`, in pixel order: the colour the output samples for the
/// pixel's lamp, or [`UNLIT`] where the output samples nothing.
fn pixel_colors(
    patch: &Patch,
    selection: LayoutSelection,
    collections: &Collections,
    time: f64,
) -> BTreeMap<FixtureId, Vec<Color32>> {
    // a patch the output refuses lights nothing there either
    let positions = patch.positions(selection, collections).unwrap_or_default();
    let addressing = patch.addressing(collections);
    patch
        .topology
        .default_layout
        .members
        .keys()
        .filter_map(|id| {
            let definition = patch.definition(*id, collections)?;
            let definition = &definition.data;
            let slot = addressing.slot(*id);
            let colors = (0..definition.pixels.len())
                .map(|pixel| {
                    let lamp = slot.and_then(|(slot, address)| {
                        let index = address.lamp_index(definition.channel_offset(pixel));
                        positions.lamp(slot, index)
                    });
                    match lamp {
                        // the output's y points up, the scene's down
                        Some(Lamp::Position { x, y }) => test_pattern(vec2(x, 1.0 - y), time),
                        _ => UNLIT,
                    }
                })
                .collect();
            Some((*id, colors))
        })
        .collect()
}

/// Draws the test screen into `rect`.
pub(crate) fn show(
    ui: &mut Ui,
    rect: Rect,
    patch: &Patch,
    selection: LayoutSelection,
    collections: &Collections,
) {
    let screen = Screen::new(ui, rect);
    let colors = pixel_colors(patch, selection, collections, screen.time);

    // The rig as it stands, not as the layout arranges it.
    for (id, position) in &patch.topology.default_layout.members {
        let (Some(definition), Some(colors)) = (patch.definition(*id, collections), colors.get(id))
        else {
            continue;
        };
        let definition = &definition.data;
        let centres = definition.pixel_scene_positions(position);
        for (pixel, (centre, color)) in centres.iter().zip(colors).enumerate() {
            screen.draw_pixel(definition, position, pixel, *centre, *color);
        }
    }
}

/// Share of the scene square a definition fills on its own test screen.
const DEFINITION_FILL: f32 = 0.8;

/// Draws one definition, filling most of the screen, lit through `mode`: one
/// colour per pixel group, as the output samples it. For seeing what a mode
/// does while the definition is still being drawn.
pub(crate) fn show_definition(
    ui: &mut Ui,
    rect: Rect,
    definition: &FixtureDefinition,
    mode: usize,
) {
    let screen = Screen::new(ui, rect);
    let position = definition_position(definition);
    let samples = GroupSamples::new(definition, &position, mode);

    for (pixel, color) in definition_pixel_colors(&samples, screen.time) {
        if let Some(centre) = samples.pixel_positions.get(pixel) {
            screen.draw_pixel(definition, &position, pixel, *centre, color);
        }
    }
}

/// Where a definition stands on its own test screen: in the middle, scaled to
/// fill most of it.
fn definition_position(definition: &FixtureDefinition) -> LayoutPosition {
    let bounds = scene_bounds();
    let longest = (2.0 * reach_m(definition)).max(0.01);
    let scale = DEFINITION_FILL * bounds.width() / (longest * UNITS_PER_METRE);
    LayoutPosition::new(bounds.center(), 0.0, [scale, scale])
}

/// What each pixel of a definition shows on its own test screen, by pixel
/// index: every pixel of a group the colour at the group's centre. A pixel the
/// mode groups nowhere is not listed and not drawn.
fn definition_pixel_colors(samples: &GroupSamples, time: f64) -> Vec<(usize, Color32)> {
    let bounds = scene_bounds();
    samples
        .groups
        .iter()
        .flat_map(|group| {
            let uv = vec2(
                group.centre.x / bounds.width(),
                group.centre.y / bounds.height(),
            );
            let color = test_pattern(uv, time);
            group.pixels.iter().map(move |pixel| (*pixel, color))
        })
        .collect()
}

/// The pattern itself, dimmed, so the lit pixels can be read against what the
/// tile is emitting.
fn draw_backdrop(painter: &Painter, screen: Rect, time: f64) {
    let cell = screen.size() / BACKDROP_CELLS as f32;
    let mut mesh = egui::epaint::Mesh::default();
    for y in 0..BACKDROP_CELLS {
        for x in 0..BACKDROP_CELLS {
            let uv = vec2(
                (x as f32 + 0.5) / BACKDROP_CELLS as f32,
                (y as f32 + 0.5) / BACKDROP_CELLS as f32,
            );
            let color = test_pattern(uv, time).gamma_multiply(0.22);
            let rect = Rect::from_min_size(
                screen.min + vec2(x as f32 * cell.x, y as f32 * cell.y),
                cell,
            );
            mesh.add_colored_rect(rect, color);
        }
    }
    painter.add(Shape::mesh(mesh));
}

#[cfg(test)]
mod tests {
    use super::*;
    use gled_model::patching::layout::LayoutRef;

    /// Each line lights its own axis and nothing else, so what a fixture does
    /// says where it sits.
    #[test]
    fn the_two_lines_cross_where_they_meet() {
        // both lines start in the corner
        assert_eq!(test_pattern(vec2(0.0, 0.0), 0.0), Color32::WHITE);

        let across = test_pattern(vec2(0.0, 0.5), 0.0);
        assert!(across.r() > 200 && across.g() == 0, "{across:?}");

        let down = test_pattern(vec2(0.5, 0.0), 0.0);
        assert!(down.g() > 200 && down.r() == 0, "{down:?}");

        assert_eq!(
            test_pattern(vec2(0.5, 0.5), 0.0),
            Color32::BLACK,
            "between the lines the square is dark"
        );
    }

    /// The lines travel, so every point is reached in turn.
    #[test]
    fn the_lines_sweep_with_time() {
        let quarter = SWEEP_ACROSS * 0.25;

        let lit = test_pattern(vec2(0.25, 0.5), quarter);
        assert!(lit.r() > 200, "a quarter of the way across: {lit:?}");
        assert_eq!(
            test_pattern(vec2(0.0, 0.5), quarter).r(),
            0,
            "and gone from where it started"
        );
    }

    /// The line is a quarter of the square wide, half of it to either side of
    /// where it stands.
    #[test]
    fn a_line_covers_a_quarter_of_the_square() {
        let lit = |x: f32| test_pattern(vec2(x, 0.5), 0.0).r() > 0;

        assert!(lit(LINE_WIDTH / 2.0 - 0.01), "just inside its edge");
        assert!(!lit(LINE_WIDTH / 2.0 + 0.01), "just outside it");
    }

    /// A 4-pixel bar whose second mode lights it in halves.
    fn half_bar() -> FixtureDefinition {
        let mut bar = gled_model::test_support::rgb_bar(4, 1.0);
        bar.modes.push(gled_model::patching::definition::Mode {
            name: "half".to_string(),
            group_of: vec![0, 0, 1, 1],
        });
        bar
    }

    /// A [`half_bar`] in the middle of the scene, and a second one wired
    /// after it.
    fn rig() -> (Patch, Collections, FixtureId, FixtureId) {
        use gled_model::patching::fixture::Fixture;
        use gled_model::test_support::collections_with;

        let (collections, ids) = collections_with(vec![half_bar()]);
        let mut patch = Patch::default();
        let mut add = |name| {
            let mut fixture = Fixture::new(name);
            fixture.definition = Some(ids[0]);
            patch.add_fixture(fixture, LayoutPosition::at(scene_bounds().center()))
        };
        let (first, second) = (add("first"), add("second"));
        patch.topology.make_successor(first, second);
        (patch, collections, first, second)
    }

    /// Whatever the output does not sample stays dark: here the second bar,
    /// which the layout leaves out.
    #[test]
    fn a_fixture_the_layout_leaves_out_stays_dark() {
        let (mut patch, collections, first, second) = rig();
        let layout = LayoutRef::Layout(patch.add_layout());
        patch.add_member(layout, first);
        let selection = LayoutSelection::new(layout, 0);

        let colors = pixel_colors(&patch, selection, &collections, 0.0);

        assert!(colors[&first].iter().all(|color| *color != UNLIT));
        assert!(colors[&second].iter().all(|color| *color == UNLIT));
    }

    /// A grouping mode reads as one colour per group, as the output samples
    /// it.
    #[test]
    fn a_group_is_lit_in_one_colour() {
        let (mut patch, collections, first, _) = rig();
        patch.set_fixture_mode(LayoutRef::Topology, 0, first, 1);
        let selection = LayoutSelection::new(LayoutRef::Topology, 0);

        let colors = pixel_colors(&patch, selection, &collections, 0.3);

        let first = &colors[&first];
        assert_eq!(first[0], first[1]);
        assert_eq!(first[2], first[3]);
        assert!(first.iter().all(|color| *color != UNLIT));
    }

    /// The definition's own screen lights each group of the mode in one
    /// colour, as the output samples it.
    #[test]
    fn a_definition_group_is_lit_in_one_colour() {
        let bar = half_bar();
        let samples = GroupSamples::new(&bar, &definition_position(&bar), 1);

        let colors: BTreeMap<usize, Color32> =
            definition_pixel_colors(&samples, 0.3).into_iter().collect();

        assert_eq!(colors.len(), 4, "every pixel is drawn: {colors:?}");
        assert_eq!(colors[&0], colors[&1]);
        assert_eq!(colors[&2], colors[&3]);
    }

    /// A line leaving one side comes back in at the other.
    #[test]
    fn a_line_wraps_at_the_edge() {
        let almost_round = SWEEP_ACROSS * 0.99;
        let lit = test_pattern(vec2(0.0, 0.5), almost_round);
        assert!(lit.r() > 0, "{lit:?}");
    }
}