gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Rasterized fixture models, for drawing definitions on the editor canvas.

use crate::canvas::images::sane_aspect;
use crate::canvas::rotated_rect::RotatedRect;
use egui::epaint::{Mesh, Vertex};
use egui::{
    Color32, ColorImage, Context, Painter, Pos2, Shape, TextureHandle, TextureOptions, Vec2, pos2,
};
use gled_model::patching::definition::Model;
use gled_svg::{ParsedSvg, RenderedSvg};
use std::collections::HashMap;

/// A rasterized SVG as an egui image.
pub(crate) fn color_image(svg: &RenderedSvg) -> ColorImage {
    ColorImage::from_rgba_premultiplied([svg.width, svg.height], &svg.rgba)
}

/// Longest edge a model is rasterized at. Models are small on the canvas, and
/// the same texture serves every instance of a definition.
const RASTER_SIZE: f32 = 96.0;
/// Shortest edge a model is rasterized at. A long thin model, a tube's outline
/// say, would otherwise come out under a pixel high and vanish.
const MIN_RASTER_EDGE: f32 = 12.0;
/// Longest edge a long thin model may grow to for its short edge.
const MAX_RASTER_SIZE: f32 = 2048.0;

/// How many models the cache holds before it forgets the ones not drawn for a
/// while.
const KEPT_MODELS: usize = 64;
/// How many frames a model may go undrawn before the cache forgets it: an
/// edited model leaves its old raster behind, and a long session would
/// otherwise collect every one of them.
const IDLE_FRAMES: u64 = 300;

/// Width and height in pixels a model drawn at `aspect` (width / height) is
/// rasterized at.
fn raster_size(aspect: f32) -> (u32, u32) {
    let aspect = sane_aspect(aspect);
    let elongation = aspect.max(1.0 / aspect);
    let long = (MIN_RASTER_EDGE * elongation).clamp(RASTER_SIZE, MAX_RASTER_SIZE);
    let short = (long / elongation).max(1.0);
    let (width, height) = if aspect >= 1.0 {
        (long, short)
    } else {
        (short, long)
    };
    (width.round() as u32, height.round() as u32)
}

/// Identifies a rasterized model without hashing or cloning its SVG source,
/// which the canvas would otherwise do once per pixel per frame. `AssetId` is
/// not `Eq` (definitions hold floats), so the asset's uuid keys the cache.
///
/// The third element buckets the aspect ratio the model is drawn at. A layout
/// may scale a fixture per axis, and rasterizing once at the model's own
/// proportions and stretching the result turns an even outline stroke into a
/// wedge, so the aspect is baked into the raster instead.
///
/// The fourth is a hash of the model's SVG, so a model edited in the fixture
/// definition editor is rasterized again instead of showing its old drawing.
pub(crate) type ModelKey = (Option<uuid::Uuid>, usize, i32, u64);

/// Hash of a model's drawing, for [`ModelKey`]. Hashed once per model and
/// fixture drawn, not per pixel.
pub(crate) fn svg_hash(model: &Model) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    model.svg.hash(&mut hasher);
    hasher.finish()
}

/// Buckets an aspect ratio into roughly 19% steps, so a fixture being dragged
/// does not rasterize a new texture every frame.
pub(crate) fn aspect_bucket(aspect: f32) -> i32 {
    if !aspect.is_finite() || aspect <= 0.0 {
        return 0;
    }
    (aspect.log2() * 4.0).round() as i32
}

fn bucket_aspect(bucket: i32) -> f32 {
    2f32.powf(bucket as f32 / 4.0)
}

/// Rasterized models, and the frame each was last drawn in.
#[derive(Default)]
pub(crate) struct ModelTextures {
    textures: HashMap<ModelKey, (Option<TextureHandle>, u64)>,
    frame: u64,
}

impl std::fmt::Debug for ModelTextures {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ModelTextures")
            .field("textures", &self.textures.len())
            .finish_non_exhaustive()
    }
}

impl ModelTextures {
    /// Counts the frame, and forgets models not drawn for a while once there
    /// are many.
    pub(crate) fn begin_frame(&mut self) {
        self.frame += 1;
        if self.textures.len() > KEPT_MODELS {
            let frame = self.frame;
            self.textures
                .retain(|_, (_, last_used)| frame - *last_used <= IDLE_FRAMES);
        }
    }

    /// The model's texture, rasterizing it on first use. A model that fails to
    /// parse is remembered as failed rather than retried every frame.
    pub(crate) fn texture(
        &mut self,
        ctx: &Context,
        key: ModelKey,
        model: &Model,
    ) -> Option<TextureHandle> {
        let frame = self.frame;
        let (texture, last_used) = self
            .textures
            .entry(key)
            .or_insert_with(|| (rasterize(ctx, key, model), frame));
        *last_used = self.frame;
        texture.clone()
    }
}

/// Rasterizes `model` at the aspect `key` buckets. A model that fails to parse
/// gives `None`, which is remembered rather than retried every frame.
fn rasterize(ctx: &Context, key: ModelKey, model: &Model) -> Option<TextureHandle> {
    let (width, height) = raster_size(bucket_aspect(key.2));
    let image = ParsedSvg::parse(&model.svg)
        .and_then(|svg| svg.render(width, height))
        .map_err(|error| tracing::warn!("Could not rasterize model \"{}\": {error}", model.name))
        .ok()?;
    Some(ctx.load_texture(
        format!("fixture model {}", model.name),
        color_image(&image),
        TextureOptions::LINEAR,
    ))
}

/// Collects quads sharing one texture into a single mesh, so a 64-pixel bar
/// costs one shape per frame instead of sixty-four.
pub(crate) struct ModelBatch {
    mesh: Mesh,
}

impl ModelBatch {
    pub(crate) fn new(texture: &TextureHandle) -> Self {
        Self {
            mesh: Mesh::with_texture(texture.id()),
        }
    }

    pub(crate) fn push(&mut self, centre: Pos2, size: Vec2, angle: f32, tint: Color32) {
        let corners = RotatedRect {
            centre,
            half: size * 0.5,
            angle,
        }
        .corners();
        let uv = [
            pos2(0.0, 0.0),
            pos2(1.0, 0.0),
            pos2(1.0, 1.0),
            pos2(0.0, 1.0),
        ];

        let base = self.mesh.vertices.len() as u32;
        for (pos, uv) in corners.into_iter().zip(uv) {
            self.mesh.vertices.push(Vertex {
                pos,
                uv,
                color: tint,
            });
        }
        self.mesh
            .indices
            .extend([base, base + 1, base + 2, base, base + 2, base + 3]);
    }

    pub(crate) fn paint(self, painter: &Painter) {
        if !self.mesh.is_empty() {
            painter.add(Shape::mesh(self.mesh));
        }
    }
}

/// Fallback for a fixture whose definition is missing or has no outline.
pub(crate) fn draw_placeholder(painter: &Painter, polygon: Vec<Pos2>, color: Color32) {
    painter.add(egui::epaint::PathShape::convex_polygon(
        polygon,
        color,
        egui::Stroke::NONE,
    ));
}

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

    #[test]
    fn a_square_model_is_rasterized_at_the_usual_size() {
        assert_eq!(raster_size(1.0), (96, 96));
    }

    /// Regression: NanaTube's outline is 854 × 7 mm and came out at 96 × 0.8
    /// pixels, which drew nothing.
    #[test]
    fn a_long_thin_model_keeps_a_visible_short_edge() {
        let (width, height) = raster_size(117.0);
        assert!(height >= 12, "{width} × {height}");
        assert_eq!(raster_size(1.0 / 117.0), (height, width));
    }

    /// A model the canvas has stopped drawing is forgotten once the cache is
    /// full, and one it still draws is kept.
    #[test]
    fn models_not_drawn_for_a_while_are_forgotten() {
        let ctx = Context::default();
        let model = Model {
            name: "led".to_string(),
            size_m: [0.01, 0.01],
            svg: String::new(),
        };
        let mut textures = ModelTextures::default();
        for index in 0..=KEPT_MODELS {
            textures.texture(&ctx, (None, index, 0, 0), &model);
        }
        for _ in 0..=IDLE_FRAMES {
            textures.begin_frame();
            textures.texture(&ctx, (None, 0, 0, 0), &model);
        }
        textures.begin_frame();

        assert_eq!(textures.textures.len(), 1);
        assert!(textures.textures.contains_key(&(None, 0, 0, 0)));
    }

    #[test]
    fn a_very_long_model_stops_growing() {
        let (width, height) = raster_size(10_000.0);
        assert_eq!(width, 2048);
        assert!(height >= 1);
    }
}