gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Pictures of the asset repository — layout backgrounds, photos of
//! fixtures — as textures. Their bytes come from the engine (`pictures`).
//! Decoding a photo takes long enough to stall a frame, so on the desktop it
//! happens on a thread of its own, and a picture shows up once it is ready.

use crate::canvas::{model_textures::color_image, pictures};
use egui::{ColorImage, Context, TextureFilter, TextureHandle, TextureOptions};
use gled_protocol::bytes::SharedBytes;
use kanal::{Receiver, Sender, unbounded};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Longest edge a drawing used as a photo is rasterized at. It lies under the
/// pixels on the editors' canvases, so it has to stay sharp when zoomed in.
const PHOTO_SIZE: f32 = 1024.0;
/// Pictures are drawn much smaller than they are stored, from a thumbnail in
/// a menu to a fixture across a room; mipmaps keep them from shimmering.
const IMAGE_TEXTURE: TextureOptions = TextureOptions {
    mipmap_mode: Some(TextureFilter::Linear),
    ..TextureOptions::LINEAR
};

/// A decoded picture, or why it could not be decoded.
pub(crate) type Decoded = Result<ColorImage, String>;

#[derive(Clone)]
enum Loaded {
    /// Asked for; the engine's answer is not here yet.
    Fetching,
    Decoding,
    Ready(TextureHandle),
    /// Remembered, so a file that cannot be read is not tried every frame.
    Failed,
}

/// Pictures by path. Cloning shares the textures and the decoding thread.
#[derive(Clone)]
pub(crate) struct Images {
    loaded: HashMap<PathBuf, Loaded>,
    /// Pictures for the decoding thread, which is started on first use and
    /// ends when the last clone of this sender is gone.
    #[cfg(not(target_arch = "wasm32"))]
    jobs: Option<Sender<(PathBuf, SharedBytes)>>,
    decoded: (Sender<(PathBuf, Decoded)>, Receiver<(PathBuf, Decoded)>),
}

impl Default for Images {
    fn default() -> Self {
        Self {
            loaded: HashMap::new(),
            #[cfg(not(target_arch = "wasm32"))]
            jobs: None,
            decoded: unbounded(),
        }
    }
}

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

impl Images {
    /// The picture at `path` in the asset repository, once it is decoded.
    /// The first call asks for it.
    pub(crate) fn texture(&mut self, ctx: &Context, path: &Path) -> Option<TextureHandle> {
        self.poll(ctx);
        match self.loaded.get(path) {
            Some(Loaded::Ready(texture)) => return Some(texture.clone()),
            Some(Loaded::Decoding | Loaded::Failed) => return None,
            Some(Loaded::Fetching) | None => {}
        }
        let loaded = match pictures::get(path) {
            None => Loaded::Fetching,
            Some(Ok(bytes)) => {
                self.decode(ctx, path.to_path_buf(), bytes);
                Loaded::Decoding
            }
            Some(Err(error)) => {
                tracing::warn!("Could not load {}: {error}", path.display());
                Loaded::Failed
            }
        };
        // A picture decoded on this thread is in already.
        self.loaded.entry(path.to_path_buf()).or_insert(loaded);
        None
    }

    /// Takes a picture decoded elsewhere — the file chooser's thread, say —
    /// as the one at `path`, replacing what was there. A failed decode does
    /// not replace a picture that is already shown: whatever else shows that
    /// file keeps showing it.
    pub(crate) fn insert(&mut self, ctx: &Context, path: PathBuf, image: Decoded) {
        let loaded = match image {
            Ok(image) => Loaded::Ready(ctx.load_texture(
                format!("image {}", path.display()),
                image,
                IMAGE_TEXTURE,
            )),
            Err(error) => {
                tracing::warn!("Could not load {}: {error}", path.display());
                if matches!(self.loaded.get(&path), Some(Loaded::Ready(_))) {
                    return;
                }
                Loaded::Failed
            }
        };
        self.loaded.insert(path, loaded);
    }

    /// Takes in what the decoding thread has finished.
    fn poll(&mut self, ctx: &Context) {
        while let Ok(Some((path, image))) = self.decoded.1.try_recv() {
            self.insert(ctx, path, image);
        }
    }

    #[cfg(not(target_arch = "wasm32"))]
    fn decode(&mut self, ctx: &Context, path: PathBuf, bytes: SharedBytes) {
        if self.jobs.is_none() {
            self.jobs = self.start_decoding(ctx);
        }
        let queued = self
            .jobs
            .as_ref()
            .is_some_and(|jobs| jobs.send((path.clone(), bytes.clone())).is_ok());
        if !queued {
            let image = decode_picture(&path, &bytes.0);
            self.insert(ctx, path, image);
        }
    }

    /// A browser has no threads to spare.
    #[cfg(target_arch = "wasm32")]
    fn decode(&mut self, ctx: &Context, path: PathBuf, bytes: SharedBytes) {
        let image = decode_picture(&path, &bytes.0);
        self.insert(ctx, path, image);
    }

    /// One thread decodes one picture after the other: a list of fixture
    /// definitions asks for all their photos at once, and decoding them side
    /// by side would hold every one of them in memory together.
    #[cfg(not(target_arch = "wasm32"))]
    fn start_decoding(&self, ctx: &Context) -> Option<Sender<(PathBuf, SharedBytes)>> {
        let (jobs, queue) = unbounded::<(PathBuf, SharedBytes)>();
        let decoded = self.decoded.0.clone();
        let ctx = ctx.clone();
        let spawned = std::thread::Builder::new()
            .name("gled:ui:decode_image".to_string())
            .spawn(move || {
                #[cfg(feature = "profiling")]
                profiling::register_thread!("decode_image");

                while let Ok((path, bytes)) = queue.recv() {
                    let image = decode_picture(&path, &bytes.0);
                    if decoded.send((path, image)).is_err() {
                        return;
                    }
                    ctx.request_repaint();
                }
            });
        match spawned {
            Ok(_) => Some(jobs),
            Err(error) => {
                tracing::warn!("Could not start decoding pictures on a thread: {error}");
                None
            }
        }
    }
}

/// Decodes the picture `bytes`, of the kind `path`'s extension names. A
/// photo may be a bitmap or a drawing; a definition that ships its lamp as
/// an SVG should not have to ship a PNG as well.
pub(crate) fn decode_picture(path: &Path, bytes: &[u8]) -> Decoded {
    let is_svg = path
        .extension()
        .is_some_and(|extension| extension.eq_ignore_ascii_case("svg"));
    if is_svg {
        let source = std::str::from_utf8(bytes).map_err(|error| error.to_string())?;
        let svg = gled_svg::ParsedSvg::parse(source).map_err(|error| error.to_string())?;
        let [width, height] = svg.size_mm();
        let (width, height) = photo_raster_size(width / height);
        let image = svg
            .render(width, height)
            .map_err(|error| error.to_string())?;
        return Ok(color_image(&image));
    }
    let image = image::load_from_memory(bytes).map_err(|error| error.to_string())?;
    let size = [image.width() as usize, image.height() as usize];
    let buffer = image.to_rgba8();
    Ok(ColorImage::from_rgba_unmultiplied(
        size,
        buffer.as_flat_samples().as_slice(),
    ))
}

/// `aspect` (width / height), or 1 for one that is no proportion at all.
pub(crate) fn sane_aspect(aspect: f32) -> f32 {
    if aspect.is_finite() && aspect > 0.0 {
        aspect
    } else {
        1.0
    }
}

/// Width and height in pixels a drawing of `aspect` (width / height) is
/// rasterized at: [`PHOTO_SIZE`] along its longer edge, undistorted.
fn photo_raster_size(aspect: f32) -> (u32, u32) {
    let aspect = sane_aspect(aspect);
    let long = aspect.max(1.0);
    let pixels = |edge: f32| (PHOTO_SIZE * edge / long).round().max(1.0) as u32;
    (pixels(aspect), pixels(1.0))
}

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

    #[test]
    fn a_drawing_keeps_its_proportions_as_a_photo() {
        assert_eq!(photo_raster_size(2.0), (1024, 512));
        assert_eq!(photo_raster_size(0.5), (512, 1024));
    }

    #[test]
    fn a_drawing_without_proportions_comes_out_square() {
        assert_eq!(photo_raster_size(f32::NAN), (1024, 1024));
    }

    /// Regression: a background that failed to decode replaced the texture
    /// another layout was showing from the same file.
    #[test]
    fn a_failed_decode_keeps_the_picture_already_shown() {
        let ctx = Context::default();
        let mut images = Images::default();
        let path = PathBuf::from("shared.png");
        images.insert(
            &ctx,
            path.clone(),
            Ok(ColorImage::filled([2, 2], egui::Color32::RED)),
        );

        images.insert(&ctx, path.clone(), Err("truncated".to_string()));

        assert!(images.texture(&ctx, &path).is_some());
    }

    #[test]
    fn a_broken_picture_is_an_error_not_a_panic() {
        assert!(decode_picture(Path::new("photo.png"), b"no png").is_err());
        assert!(decode_picture(Path::new("photo.svg"), &[0xFF, 0xFE]).is_err());
    }
}