From 9d4b3bb091e2bcd83acb84c4757f43e67509d7f7 Mon Sep 17 00:00:00 2001 From: wovadi <350371346+wovadi@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:05:02 +0100 Subject: [PATCH 1/6] Raster images are drawn on screen and in PNG output (#1756) An IMAGE whose file exists was drawn as a frame with two diagonals. Now its pixels are loaded when a drawing opens and drawn on screen and in PNG output, with rotation, clip boundary, fade and object transparency. A file that can't be read keeps the crossed-out frame and reports a message. Ported from the raster commit of #1029 onto the split render and canvas modules. New here: large images are drawn from a pyramid of levels cut into tiles uploaded as they come into view, instead of one texture with pixels skipped, so a large scan stays sharp zoomed in and doesn't shimmer zoomed out. Limits are listed in docs/gaps.md (FF10): PDF and SVG output write only the frame, and on screen images lie under all other objects. Co-Authored-By: Chanthawat Kiriyadee Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 2 +- apps/cadcraft-cli/src/lib.rs | 1 + apps/cadcraft-web/src/main.rs | 1 + apps/cadcraft/src/main.rs | 1 + crates/doc/src/entity/layout.rs | 5 +- crates/doc/src/lib.rs | 5 + crates/doc/src/raster.rs | 93 ++++ crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/blocks/xref.rs | 6 + crates/engine/src/cmd/exchange.rs | 8 +- crates/engine/src/cmd/file.rs | 16 + crates/engine/src/cmd/image.rs | 349 +++++++++++++ crates/engine/tests/image_attach.rs | 376 ++++++++++++++ crates/engine/tests/image_decoder_panic.rs | 55 ++ crates/io/Cargo.toml | 1 + crates/io/src/lib.rs | 2 + crates/io/src/pdf.rs | 2 + crates/io/src/raster.rs | 79 +++ crates/io/src/svg.rs | 2 + crates/io/tests/raster_decode.rs | 172 +++++++ crates/io/tests/raster_foreign_files.rs | 222 +++++++++ crates/render/src/display.rs | 52 ++ crates/render/src/draw.rs | 12 +- crates/render/src/lib.rs | 58 ++- crates/render/src/raster.rs | 98 +++- crates/render/src/viewports.rs | 26 +- crates/render/tests/raster_image.rs | 338 +++++++++++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/canvas/draw.rs | 5 + crates/ui-egui/src/canvas/images.rs | 554 +++++++++++++++++++++ crates/ui-egui/src/canvas/viewport.rs | 3 + crates/ui-egui/src/gpu.rs | 9 + crates/ui-egui/src/i18n/uk.tsv | 2 + docs/file-format-parity.md | 7 +- docs/gaps.md | 5 +- 36 files changed, 2554 insertions(+), 17 deletions(-) create mode 100644 crates/doc/src/raster.rs create mode 100644 crates/engine/src/cmd/image.rs create mode 100644 crates/engine/tests/image_attach.rs create mode 100644 crates/engine/tests/image_decoder_panic.rs create mode 100644 crates/io/src/raster.rs create mode 100644 crates/io/tests/raster_decode.rs create mode 100644 crates/io/tests/raster_foreign_files.rs create mode 100644 crates/render/tests/raster_image.rs create mode 100644 crates/ui-egui/src/canvas/images.rs diff --git a/Cargo.lock b/Cargo.lock index 51693203d..edcfbec50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,6 +752,7 @@ dependencies = [ "cadcraft-geom", "cadcraft-render", "craft-pdf", + "image", "miniz_oxide 0.9.1", "quick-xml 0.42.0", "serde", diff --git a/Cargo.toml b/Cargo.toml index da6bca3b4..cab8c4d2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ skrifa = "0.48" # so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text. harfrust = "0.14" unicode-bidi = "0.3.18" -image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } encoding_rs = "0.8.35" # XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones) quick-xml = "0.42" diff --git a/apps/cadcraft-cli/src/lib.rs b/apps/cadcraft-cli/src/lib.rs index c858417e2..1db550695 100644 --- a/apps/cadcraft-cli/src/lib.rs +++ b/apps/cadcraft-cli/src/lib.rs @@ -43,6 +43,7 @@ fn install_io() { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); + cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) }); diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index a25782d6e..30d9c55f5 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -119,6 +119,7 @@ mod web { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); + cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) }); diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 4d7bdc39b..b9efa2e00 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -145,6 +145,7 @@ pub fn install_io() { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); + cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) }); diff --git a/crates/doc/src/entity/layout.rs b/crates/doc/src/entity/layout.rs index be1b9584d..4568c24ac 100644 --- a/crates/doc/src/entity/layout.rs +++ b/crates/doc/src/entity/layout.rs @@ -59,8 +59,9 @@ pub struct Image { /// The image definition's name (its key in the drawing's image dictionary). #[serde(default, skip_serializing_if = "String::is_empty")] pub name: String, - /// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down): - /// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image. + /// Clip boundary in pixel coordinates (y down, counted from pixel centres: the centre of the + /// top-left pixel is the origin, so the whole image is -0.5 to size - 0.5): two opposite + /// corners of a rectangle, or a polygon's vertices. Empty: the whole image. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub clip: Vec, /// The clip boundary is applied. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d970a6a56..4c6febbe4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ mod header; pub mod library; mod plotstyle; mod plotter; +mod raster; mod refedit; mod store; mod tables; @@ -33,6 +34,7 @@ pub use gdt::*; pub use header::{HVal, Header}; pub use plotstyle::*; pub use plotter::*; +pub use raster::{Raster, Rasters}; pub use refedit::{RefEdit, WorkItem}; pub use store::EntityStore; pub use tables::*; @@ -132,6 +134,8 @@ pub struct Drawing { /// Records of the file this drawing was read from that the reader could not place (a damaged /// file), counted by the section they came from. Never written to a file. pub skipped_records: BTreeMap, + /// The pixels of the raster images that are loaded (IMAGE). Never written to a file. + pub rasters: Rasters, } impl Default for Drawing { @@ -191,6 +195,7 @@ impl Drawing { read_notes: Vec::new(), refedit: None, skipped_records: BTreeMap::new(), + rasters: Rasters::default(), } } diff --git a/crates/doc/src/raster.rs b/crates/doc/src/raster.rs new file mode 100644 index 000000000..1eb721997 --- /dev/null +++ b/crates/doc/src/raster.rs @@ -0,0 +1,93 @@ +//! Decoded raster image pixels (IMAGE entities). The drawing keeps them for the session only: +//! they are loaded from the image files after a drawing opens and are never written to a file. + +use std::collections::BTreeMap; +use std::sync::Arc; + +/// The pixels of a raster image: 8-bit RGBA, not premultiplied, rows from the top. +#[derive(Clone, PartialEq)] +pub struct Raster { + width: u32, + height: u32, + rgba: Vec, + opaque: bool, +} + +impl Raster { + /// `None` unless both sides are at least one pixel and `rgba` holds exactly four bytes per pixel. + pub fn new(width: u32, height: u32, rgba: Vec) -> Option { + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + if pixels == 0 || rgba.len() != pixels.checked_mul(4)? { + return None; + } + let opaque = rgba.as_chunks::<4>().0.iter().all(|[_, _, _, alpha]| *alpha == u8::MAX); + Some(Raster { width, height, rgba, opaque }) + } + pub fn width(&self) -> u32 { + self.width + } + pub fn height(&self) -> u32 { + self.height + } + /// Four bytes per pixel (red, green, blue, alpha), `width * height` pixels. + pub fn rgba(&self) -> &[u8] { + &self.rgba + } + /// No pixel is transparent. + pub fn opaque(&self) -> bool { + self.opaque + } + /// The pixel in column `x` of row `y` (from the top): red, green, blue, alpha. + pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> { + if x >= self.width || y >= self.height { + return None; + } + let (x, y, width) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?, usize::try_from(self.width).ok()?); + let pixels = self.rgba.as_chunks::<4>().0; + pixels.get(y.checked_mul(width)?.checked_add(x)?).copied() + } +} + +impl std::fmt::Debug for Raster { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Raster({}x{})", self.width, self.height) + } +} + +/// The loaded pixels of a drawing's raster images, by image file path ([`crate::Image::path`]). +/// Session data: two drawings are equal whatever images they have loaded, and copying a drawing +/// (undo) shares the pixels. +#[derive(Clone, Default)] +pub struct Rasters(Arc>>); + +impl Rasters { + pub fn get(&self, path: &str) -> Option<&Arc> { + self.0.get(path) + } + pub fn insert(&mut self, path: &str, raster: Arc) { + Arc::make_mut(&mut self.0).insert(path.to_string(), raster); + } + pub fn remove(&mut self, path: &str) { + if self.0.contains_key(path) { + Arc::make_mut(&mut self.0).remove(path); + } + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl PartialEq for Rasters { + fn eq(&self, _: &Rasters) -> bool { + true + } +} + +impl std::fmt::Debug for Rasters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Rasters({})", self.0.len()) + } +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 172e416d1..f3df20305 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -18,6 +18,7 @@ mod gripcmds; mod griphot; pub(crate) mod groups; pub(crate) mod hatch; +mod image; mod inquiry; mod layer; mod layout; @@ -198,6 +199,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layer::specs()); v.extend(layout::specs()); v.extend(exchange::specs()); + v.extend(image::specs()); v.extend(props::specs()); v.extend(inquiry::specs()); v.extend(settings::specs()); diff --git a/crates/engine/src/cmd/blocks/xref.rs b/crates/engine/src/cmd/blocks/xref.rs index b87e73795..df0e3513a 100644 --- a/crates/engine/src/cmd/blocks/xref.rs +++ b/crates/engine/src/cmd/blocks/xref.rs @@ -67,6 +67,12 @@ pub(crate) fn after_open(s: &mut Session, i: usize) { echo_all(s, &out); } +/// The file a reference saved as `path` in a drawing at `host` is read from, if there is one +/// (xrefs and raster images look in the same places). +pub(crate) fn find_file(path: &str, host: Option<&str>) -> Option { + load::find(path, host.map(std::path::Path::new), false).ok() +} + /// The open drawing's path (xref paths are relative to its folder). fn host(s: &Session) -> Option { s.state().ok().and_then(|st| st.path.clone()) diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index e28de89fc..ac29b1aba 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -55,11 +55,11 @@ pub fn specs() -> Vec { .alias(&["printpdf", "pdfb"]) .params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match") .noundo(), - CommandSpec::new("attach", "Attach", |_, _| { - Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into())) + CommandSpec::new("attach", "Attach", |s, p| match str_param(p, "path").map(extension) { + Some(e) if super::image::is_image_ext(&e) => super::image::run_imageattach(s, p), + _ => Err(EngineError::Other("ATTACH is not available yet for this file: only raster images (PNG, JPEG, BMP, TIFF; see IMAGEATTACH) can be attached, PDF/DWF/DGN underlays can't be shown yet".into())), }) - .params("not available yet") - .noundo(), + .params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"), CommandSpec::new("share", "Share", |_, _| { Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into())) }) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7c26a71f9..26aea531d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -70,6 +70,19 @@ pub fn entity_codec() -> Option<&'static EntityCodec> { /// COMPARE (drawing comparison). pub mod compare; +/// Decode a raster image file (PNG, JPEG, BMP, TIFF) to pixels: installed by the app with the +/// image reader, a lower layer. +pub type ImageHook = fn(&[u8]) -> std::result::Result; + +static IMAGE: OnceLock = OnceLock::new(); + +pub fn set_image_decoder(h: ImageHook) { + let _ = IMAGE.set(h); +} +pub fn image_decoder() -> Option { + IMAGE.get().copied() +} + /// Hand finished file bytes to the user where there is no file system (the browser: a download). /// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started. pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>; @@ -301,6 +314,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result { let ReadFile { drawing, binary_dxf, name, path } = f; let i = add_read(s, drawing, binary_dxf, &name, path); super::blocks::xref::after_open(s, i); + super::image::after_open(s, i); Ok(opened(s, i, true)) } @@ -353,6 +367,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result { crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?; let i = open_bytes(s, &bytes, path, Some(path.to_string()))?; super::blocks::xref::after_open(s, i); + super::image::after_open(s, i); return Ok(opened(s, i, true)); } #[cfg(target_arch = "wasm32")] @@ -363,6 +378,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").unwrap_or("Drawing.dxf"); let i = open_bytes(s, &bytes, name, None)?; super::blocks::xref::after_open(s, i); + super::image::after_open(s, i); Ok(opened(s, i, false)) } diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs new file mode 100644 index 000000000..240664891 --- /dev/null +++ b/crates/engine/src/cmd/image.rs @@ -0,0 +1,349 @@ +//! Raster images (IMAGEATTACH, IMAGE). An IMAGE entity names its file; the pixels are decoded +//! when the drawing opens or the image is attached and kept with the open drawing +//! ([`Drawing::rasters`]), never in the file. An image whose file can't be found or read stays in +//! the drawing as a crossed-out frame. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +use cadcraft_doc::{Drawing, EntityKind, Image, Raster}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::file::base64_decode; +use super::{CommandSpec, bad, f64_or, point_param, str_param}; +use crate::{Result, Session}; + +/// Most image files loaded per drawing. +const MAX_IMAGE_FILES: usize = 1000; +/// Largest image file read (bytes). +#[cfg(not(target_arch = "wasm32"))] +const MAX_IMAGE_BYTES: u64 = 256 << 20; +/// Most decoded pixel bytes kept per drawing; images past it stay unloaded. +const MAX_LOADED_BYTES: u64 = 2 << 30; +/// Most image files decoded at the same time when a drawing opens. +const MAX_LOADERS: usize = 4; +/// Longest image path taken from a caller. +const MAX_PATH: usize = 4096; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("imageattach", "Raster Image Reference...", run_imageattach).alias(&["iat"]).params( + "{path: PNG/JPEG/BMP/TIFF file (saved as given; a relative path is relative to this drawing's folder), data?: base64 file contents \ + (instead of reading `path`), at?: [x,y] lower-left corner (default 0,0), scale?: number (default 1: the image is one drawing unit \ + wide), width?: width in drawing units (instead of `scale`), rotation? (degrees), name? (default: the file name)} → {name, handle, \ + path, pixels: [w, h], size: [w, h]}", + ), + CommandSpec::new("image", "Raster Images", run_image) + .alias(&["im"]) + .params("{} → {images: [{name, path, foundAt, status: Loaded|Not Found|Not loaded, references, pixels?: [w, h]}]}") + .noundo(), + ] +} + +/// Whether `ext` (lower case, no dot) is an image file type IMAGEATTACH reads. +pub(crate) fn is_image_ext(ext: &str) -> bool { + matches!(ext, "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff") +} + +/// Every IMAGE entity of `d`: in model space, on the layouts and in the blocks. +fn images(d: &Drawing) -> impl Iterator { + let model = std::iter::once(&d.model); + let layouts = d.layouts.iter().map(|l| &l.entities); + let blocks = d.blocks.values().map(|b| &b.entities); + model.chain(layouts).chain(blocks).flat_map(|store| store.iter()).filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i), + _ => None, + }) +} + +/// The image files `d` names, each once, in the order they are first used. +fn paths(d: &Drawing) -> Vec { + let mut seen = BTreeSet::new(); + let mut paths = Vec::new(); + for i in images(d) { + if !i.path.trim().is_empty() && seen.insert(i.path.as_str()) { + paths.push(i.path.clone()); + } + } + paths +} + +/// The contents of the image file saved as `path` in a drawing at `host`. +fn read_file(path: &str, host: Option<&str>) -> std::result::Result, String> { + #[cfg(not(target_arch = "wasm32"))] + { + let file = super::blocks::xref::find_file(path, host).ok_or("file not found")?; + let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len(); + if len > MAX_IMAGE_BYTES { + return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)); + } + std::fs::read(&file).map_err(|e| e.to_string()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (path, host); + Err("files are not available on the web".into()) + } +} + +/// Decode an image file. A decoder that panics on a damaged file fails this image only: the +/// drawing it belongs to must still open. +fn decode(bytes: &[u8]) -> std::result::Result { + let hook = super::file::image_decoder().ok_or("image files can't be read in this build")?; + match std::panic::catch_unwind(|| hook(bytes)) { + Ok(r) => r, + Err(_) => Err("the file is damaged and can't be read".into()), + } +} + +fn bytes_of(r: &Raster) -> u64 { + u64::try_from(r.rgba().len()).unwrap_or(u64::MAX) +} + +/// Load the images of drawing `i`, just opened. Those that can't be loaded are reported and +/// stay crossed-out frames; opening never fails over an image. +pub(crate) fn after_open(s: &mut Session, i: usize) { + let Some(st) = s.docs.get_mut(i) else { return }; + let paths = paths(&st.doc); + if paths.is_empty() { + return; + } + let mut rasters = st.doc.rasters.clone(); + let notes = load_missing(&paths, st.path.as_deref(), &mut rasters); + if !rasters.is_empty() { + // Loading pixels is not a change to the drawing. A drawing counts as unchanged while + // it is the very one that was saved, so the saved one follows. + let unchanged = Arc::ptr_eq(&st.doc, &st.saved); + Arc::make_mut(&mut st.doc).rasters = rasters; + if unchanged { + st.saved = st.doc.clone(); + } + } + for n in notes { + s.echo(n); + } +} + +/// Read and decode into `rasters` the files of `paths` that are not in it yet, for a drawing at +/// `host`. Returns one message per file that could not be loaded. +fn load_missing(paths: &[String], host: Option<&str>, rasters: &mut cadcraft_doc::Rasters) -> Vec { + let mut notes = Vec::new(); + let mut missing: Vec<&str> = paths.iter().map(String::as_str).filter(|p| rasters.get(p).is_none()).collect(); + if missing.len() > MAX_IMAGE_FILES { + missing.truncate(MAX_IMAGE_FILES); + notes.push(format!("Images: only the first {MAX_IMAGE_FILES} image files are loaded")); + } + let decoded = decode_files(&missing, host); + // Kept in the order the drawing uses them, up to the memory limit. + let mut kept_bytes = 0u64; + for (path, result) in missing.iter().zip(decoded) { + match result { + Ok(r) => { + kept_bytes = kept_bytes.saturating_add(bytes_of(&r)); + if kept_bytes > MAX_LOADED_BYTES { + notes.push(over_limit(path)); + break; + } + rasters.insert(path, Arc::new(r)); + } + Err(e) => notes.push(format!("Image {path}: {e}")), + } + } + notes +} + +fn over_limit(path: &str) -> String { + format!("Image {path}: not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20) +} + +/// Read and decode the image files `paths` of a drawing at `host`, a few at a time (decoding is +/// what opening a drawing with large images waits for). The results are in the order of `paths`. +fn decode_files(paths: &[&str], host: Option<&str>) -> Vec> { + // Decoded bytes so far, shared by the loaders: past the limit no further file is started. + let decoded_bytes = AtomicU64::new(0); + let load = |path: &str| -> std::result::Result { + if decoded_bytes.load(Ordering::Relaxed) > MAX_LOADED_BYTES { + return Err(format!("not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20)); + } + let r = read_file(path, host).and_then(|bytes| decode(&bytes))?; + decoded_bytes.fetch_add(bytes_of(&r), Ordering::Relaxed); + Ok(r) + }; + let mut results: Vec>> = paths.iter().map(|_| None).collect(); + let loaders = loader_count(paths.len()); + if loaders > 1 { + // Each loader takes the next file nobody has started. + let next = AtomicUsize::new(0); + let work = || { + let mut done = Vec::new(); + loop { + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(path) = paths.get(index) else { break }; + done.push((index, load(path))); + } + done + }; + let finished: Vec<_> = std::thread::scope(|scope| { + let started: Vec<_> = (0..loaders).filter_map(|_| std::thread::Builder::new().spawn_scoped(scope, work).ok()).collect(); + started.into_iter().filter_map(|loader| loader.join().ok()).flatten().collect() + }); + for (index, result) in finished { + if let Some(slot) = results.get_mut(index) { + *slot = Some(result); + } + } + } + // One loader, or files a loader that could not start or finish left behind. + results.into_iter().zip(paths).map(|(result, path)| result.unwrap_or_else(|| load(path))).collect() +} + +/// How many files are decoded at once: one per processor, at most [`MAX_LOADERS`] (each large +/// image needs several hundred megabytes while it is decoded) and no more than there are files. +fn loader_count(files: usize) -> usize { + #[cfg(not(target_arch = "wasm32"))] + let processors = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(1); + #[cfg(target_arch = "wasm32")] + let processors = 1; + processors.min(MAX_LOADERS).min(files) +} + +/// The file name without folders and extension (either separator). +fn stem(path: &str) -> String { + let file = path.rsplit(['/', '\\']).next().unwrap_or(path); + match file.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem.to_string(), + _ => file.to_string(), + } +} + +/// The definition name for a new image of `path`: the name that file already has in `d`, else +/// `wanted`, numbered (`wanted_2`, `wanted_3`, …) when another file has taken it. +fn def_name(d: &Drawing, path: &str, wanted: &str) -> String { + if let Some(i) = images(d).find(|i| i.path == path && !i.name.is_empty()) { + return i.name.clone(); + } + // The names other files have, compared without regard to case. + let taken: BTreeSet = images(d).filter(|i| i.path != path).map(|i| i.name.to_ascii_lowercase()).collect(); + let free = |name: &str| !taken.contains(&name.to_ascii_lowercase()); + if free(wanted) { + return wanted.to_string(); + } + // One of these is free: there are more candidates than names taken. + let candidates = 2..taken.len().saturating_add(3); + candidates.map(|n| format!("{wanted}_{n}")).find(|name| free(name)).unwrap_or_else(|| wanted.to_string()) +} + +const ID: &str = "imageattach"; + +pub(crate) fn run_imageattach(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(ID, "`path` is required"))?.to_string(); + if path.len() > MAX_PATH { + return Err(bad(ID, "`path` is too long")); + } + let place = placement(p)?; + let raster = pixels(s, p, &path)?; + let (w, h) = (f64::from(raster.width()), f64::from(raster.height())); + // One pixel in drawing units, along the image's bottom edge (`u`) and its left edge (`v`). + let pixel = place.width / w; + let u = Vec2::from_angle(place.rotation) * pixel; + let v = u.perp(); + if !(pixel.is_normal() && (pixel * h).is_normal() && u.x.is_finite() && u.y.is_finite()) { + return Err(bad(ID, "the image would be too small or too large to draw")); + } + let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| stem(&path)); + let name = def_name(s.doc()?, &path, &wanted); + let image = Image { + insert: place.at.to3(0.0), + u: u.to3(0.0), + v: v.to3(0.0), + size: Vec2::new(w, h), + path: path.clone(), + name: name.clone(), + ..Image::default() + }; + s.doc_mut()?.rasters.insert(&path, raster); + let handle = s.add_entity(EntityKind::Image(image))?; + Ok(json!({ "name": name, "handle": handle.hex(), "path": path, "pixels": [w, h], "size": [place.width, pixel * h] })) +} + +/// Where and how large IMAGEATTACH puts the image. +struct Placement { + /// The lower-left corner. + at: Vec2, + /// Radians, counter-clockwise. + rotation: f64, + /// The image's width in drawing units. + width: f64, +} + +/// `at`, `rotation` and the width (`width`, else `scale`: an image is one unit wide at scale 1). +fn placement(p: &Value) -> Result { + let at = point_param(p, "at").unwrap_or(Vec2::ZERO); + if !(at.x.is_finite() && at.y.is_finite()) { + return Err(bad(ID, "`at` must be a point")); + } + let width = match p.get("width") { + Some(v) => v.as_f64().filter(|w| w.is_finite()).ok_or_else(|| bad(ID, "`width` must be a number"))?, + None => f64_or(p, "scale", 1.0), + }; + if !(width.is_finite() && width > 0.0) { + return Err(bad(ID, "`scale` and `width` must be greater than zero")); + } + Ok(Placement { at, rotation: f64_or(p, "rotation", 0.0).to_radians(), width }) +} + +/// The pixels of the image at `path`: decoded from `data` when the caller hands the file over, +/// else the ones already loaded for that path, else read from the file. +fn pixels(s: &Session, p: &Value, path: &str) -> Result> { + let bytes = match p.get("data") { + Some(data) => { + let text = data.as_str().ok_or_else(|| bad(ID, "`data` must be base64 text"))?; + base64_decode(text).ok_or_else(|| bad(ID, "`data` is not valid base64"))? + } + None => { + if let Some(loaded) = s.doc()?.rasters.get(path) { + return Ok(loaded.clone()); + } + let host = s.state()?.path.clone(); + read_file(path, host.as_deref()).map_err(|e| bad(ID, format!("{path}: {e}")))? + } + }; + Ok(Arc::new(decode(&bytes).map_err(|e| bad(ID, format!("{path}: {e}")))?)) +} + +fn run_image(s: &mut Session, _p: &Value) -> Result { + let host = s.state()?.path.clone(); + let d = s.doc()?; + // Per file: its definition name and how many images show it. + let mut uses: BTreeMap<&str, (&str, usize)> = BTreeMap::new(); + for i in images(d) { + uses.entry(i.path.as_str()).or_insert((i.name.as_str(), 0)).1 += 1; + } + let rows: Vec = paths(d) + .iter() + .map(|path| { + let (name, references) = uses.get(path.as_str()).copied().unwrap_or_default(); + let mut row = describe(d, path, host.as_deref()); + row["name"] = json!(name); + row["references"] = json!(references); + row + }) + .collect(); + Ok(json!({ "images": rows })) +} + +/// The state of image file `path` of drawing `d` (saved at `host`): where it is and whether its +/// pixels are loaded. +fn describe(d: &Drawing, path: &str, host: Option<&str>) -> Value { + let loaded = d.rasters.get(path); + let found_at = super::blocks::xref::find_file(path, host).map(|f| f.to_string_lossy().into_owned()); + let status = match (loaded, &found_at) { + (Some(_), _) => "Loaded", + (None, None) => "Not Found", + // The file is there but could not be read as an image. + (None, Some(_)) => "Not loaded", + }; + json!({ "path": path, "foundAt": found_at, "status": status, "pixels": loaded.map(|r| json!([r.width(), r.height()])) }) +} diff --git a/crates/engine/tests/image_attach.rs b/crates/engine/tests/image_attach.rs new file mode 100644 index 000000000..864d968a5 --- /dev/null +++ b/crates/engine/tests/image_attach.rs @@ -0,0 +1,376 @@ +//! Raster images (#956): IMAGEATTACH from a file and from handed-over bytes, the pixels loaded +//! again when the drawing is reopened, a missing file left as a frame, and bad input refused. + +use std::path::{Path, PathBuf}; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io}; +use cadcraft_engine::doc::{Drawing, EntityKind, Image, Raster}; +use serde_json::{Value, json}; + +fn hooks() { + set_io(IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: None, + }); + set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); +} + +/// A fresh folder for one test. +fn folder(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cadcraft-image-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A PNG file of 4 x 2 pixels. +fn png() -> Vec { + let px: Vec = (0..8u8).flat_map(|i| [i * 30, 255 - i * 30, 7, 255]).collect(); + cadcraft_io::raster::encode_png(&Raster::new(4, 2, px).unwrap()).unwrap() +} + +fn images(s: &Session) -> Vec { + let d = s.doc().unwrap(); + d.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i.clone()) } else { None }).collect() +} + +fn open(s: &mut Session, path: &Path) { + s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap(); +} + +fn listed(s: &mut Session) -> Value { + s.execute("image", &json!({})).unwrap()["images"].clone() +} + +#[test] +fn attach_from_bytes_places_the_image() { + hooks(); + let mut s = Session::new(); + let r = s.execute("imageattach", &json!({ "path": "maps/site.png", "data": base64_encode(&png()), "at": [10, 20], "width": 100 })).unwrap(); + assert_eq!((r["name"].as_str(), r["pixels"].clone(), r["size"].clone()), (Some("site"), json!([4.0, 2.0]), json!([100.0, 50.0]))); + let i = images(&s).pop().unwrap(); + assert_eq!((i.insert.x, i.insert.y, i.u.x, i.u.y, i.v.x, i.v.y), (10.0, 20.0, 25.0, 0.0, 0.0, 25.0)); + assert_eq!((i.size.x, i.size.y, i.path.as_str(), i.name.as_str()), (4.0, 2.0, "maps/site.png", "site")); + assert_eq!(s.doc().unwrap().rasters.get("maps/site.png").map(|r| (r.width(), r.height())), Some((4, 2))); + let l = listed(&mut s); + assert_eq!((l[0]["status"].as_str(), l[0]["references"].as_u64(), l[0]["pixels"].clone()), (Some("Loaded"), Some(1), json!([4, 2]))); + + // A second reference to the same file shares the definition and the pixels; another file + // with the same file name gets its own name. + s.execute("imageattach", &json!({ "path": "maps/site.png", "at": [200, 0], "rotation": 90, "scale": 8 })).unwrap(); + let r = s.execute("attach", &json!({ "path": "other/site.png", "data": base64_encode(&png()) })).unwrap(); + assert_eq!(r["name"], "site_2"); + let all = images(&s); + assert_eq!(all.len(), 3); + assert_eq!(all[1].name, "site"); + assert!((all[1].u.x).abs() < 1e-12 && (all[1].u.y - 2.0).abs() < 1e-12 && (all[1].v.x + 2.0).abs() < 1e-12, "{:?}", all[1]); + assert_eq!(listed(&mut s)[0]["references"], 2); + + // Undo takes the reference away; the drawing draws with its pixels. + s.execute("undo", &json!({})).unwrap(); + assert_eq!(images(&s).len(), 2); + let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()); + assert_eq!(list.images.len(), 2); +} + +#[test] +fn a_file_is_found_next_to_the_drawing_and_loaded_again_on_open() { + hooks(); + let dir = folder("file"); + std::fs::write(dir.join("map.png"), png()).unwrap(); + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&Drawing::new_metric(), "host.dxf").unwrap()).unwrap(); + let mut s = Session::new(); + open(&mut s, &host); + let r = s.execute("imageattach", &json!({ "path": "map.png" })).unwrap(); + assert_eq!(r["size"], json!([1.0, 0.5]), "one drawing unit wide by default"); + s.execute("qsave", &json!({})).unwrap(); + + // Reopened: the pixels come back from the file. + let mut s = Session::new(); + open(&mut s, &host); + assert_eq!(images(&s).len(), 1); + assert!(s.doc().unwrap().rasters.get("map.png").is_some()); + assert!(!s.state().unwrap().is_dirty(), "loading images is not a change"); + let l = listed(&mut s); + assert_eq!(l[0]["status"], "Loaded"); + assert!(l[0]["foundAt"].as_str().is_some_and(|f| f.ends_with("map.png"))); + + // The file gone: the drawing still opens, says so, and keeps the reference. + std::fs::remove_file(dir.join("map.png")).unwrap(); + let mut s = Session::new(); + open(&mut s, &host); + assert_eq!(images(&s).len(), 1); + assert!(s.doc().unwrap().rasters.get("map.png").is_none()); + assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("file not found")), "{:?}", s.log); + assert_eq!(listed(&mut s)[0]["status"], "Not Found"); + + // A file that is not an image is reported the same way. + std::fs::write(dir.join("map.png"), b"not an image").unwrap(); + let mut s = Session::new(); + open(&mut s, &host); + assert!(s.doc().unwrap().rasters.get("map.png").is_none()); + assert!(s.log.iter().any(|l| l.contains("map.png")), "{:?}", s.log); + assert_eq!(listed(&mut s)[0]["status"], "Not loaded"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_input_is_refused_and_changes_nothing() { + hooks(); + let mut s = Session::new(); + let data = base64_encode(&png()); + for p in [ + json!({}), + json!({ "path": " " }), + json!({ "path": "nowhere/missing.png" }), + json!({ "path": "a.png", "data": "***" }), + json!({ "path": "a.png", "data": 5 }), + json!({ "path": "a.png", "data": base64_encode(b"plain text") }), + json!({ "path": "a.png", "data": data, "scale": 0 }), + json!({ "path": "a.png", "data": data, "width": -3 }), + json!({ "path": "a.png", "data": data, "width": "wide" }), + json!({ "path": "a.png", "data": data, "width": 1e-320 }), + json!({ "path": "x".repeat(5000), "data": data }), + ] { + assert!(s.execute("imageattach", &p).is_err(), "{p}"); + } + assert!(images(&s).is_empty()); + assert!(s.execute("attach", &json!({ "path": "plan.pdf" })).unwrap_err().to_string().contains("not available yet")); + assert_eq!(listed(&mut s), json!([])); +} + +#[test] +fn an_image_among_many_objects_keeps_its_pixels() { + // More objects than one build task takes: the display list is built in parts and joined, + // and the image must still point at its own pixels. + hooks(); + let mut s = Session::new(); + for i in 0..700 { + s.execute("line", &json!({ "points": [[i, 0], [i, 1]] })).unwrap(); + } + s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [0, 5], "width": 4 })).unwrap(); + for i in 0..700 { + s.execute("line", &json!({ "points": [[i, 2], [i, 3]] })).unwrap(); + } + s.execute("imageattach", &json!({ "path": "b.png", "data": base64_encode(&png()), "at": [10, 5], "width": 8 })).unwrap(); + let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()); + let widths: Vec = list + .prims + .iter() + .filter_map(|p| match p.kind { + cadcraft_engine::render::Kind::Image { index } => list.images.get(index as usize).map(|i| i.right.x * 4.0), + _ => None, + }) + .collect(); + assert_eq!(widths, [4.0, 8.0]); +} + +fn drawn_images(s: &Session) -> usize { + cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()).images.len() +} + +#[test] +fn editing_commands_work_on_images_and_undo_keeps_the_pixels() { + hooks(); + let mut s = Session::new(); + let r = s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [10, 0], "width": 4 })).unwrap(); + let h = r["handle"].as_str().unwrap().to_string(); + + // Move, then a quarter turn about the origin: the corner and both axes follow. + s.execute("move", &json!({ "handles": [h], "delta": [0, 5] })).unwrap(); + s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 90 })).unwrap(); + let i = images(&s).pop().unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!(close(i.insert.x, -5.0) && close(i.insert.y, 10.0), "{i:?}"); + assert!(close(i.u.x, 0.0) && close(i.u.y, 1.0) && close(i.v.x, -1.0) && close(i.v.y, 0.0), "{i:?}"); + assert_eq!(drawn_images(&s), 1); + + // A copy is a second reference to the same file. + s.execute("copy", &json!({ "handles": [h], "delta": [20, 0] })).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2)); + assert_eq!(listed(&mut s)[0]["references"], 2); + assert_eq!(listed(&mut s).as_array().map(Vec::len), Some(1)); + + // Scaled about its corner: twice the size, still the same pixels. + s.execute("scale", &json!({ "handles": [h], "base": [-5, 10], "factor": 2 })).unwrap(); + let i = images(&s).into_iter().find(|i| close(i.insert.x, -5.0)).unwrap(); + assert!(close(i.u.y, 2.0) && close(i.size.x, 4.0), "{i:?}"); + + // Erased, then brought back by undo, with its picture; redo erases again. + s.execute("erase", &json!({ "handles": [h] })).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (1, 1)); + s.execute("undo", &json!({})).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2)); + s.execute("redo", &json!({})).unwrap(); + assert_eq!(images(&s).len(), 1); + + // Everything undone: no image, and nothing left to draw. + for _ in 0..10 { + let _ = s.execute("undo", &json!({})); + } + assert!(images(&s).is_empty()); + assert_eq!(drawn_images(&s), 0); + assert_eq!(listed(&mut s), json!([])); +} + +#[test] +fn the_alias_and_the_command_line_reach_the_command() { + hooks(); + assert_eq!(cadcraft_engine::cmd::resolve_alias("IAT"), "imageattach"); + assert_eq!(cadcraft_engine::cmd::resolve_alias("im"), "image"); + let spec = |id: &str| cadcraft_engine::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + assert!(!spec("imageattach").params.is_empty() && !spec("image").params.is_empty()); + // Typed without a file it has nothing to attach: an error, not a crash or a stuck prompt. + let mut s = Session::new(); + let _ = s.cmdline("imageattach"); + let _ = s.cmdline(""); + s.cancel(); + assert!(s.running.is_none() && images(&s).is_empty()); +} + +#[test] +fn images_in_blocks_and_layouts_are_loaded_on_open() { + use cadcraft_engine::doc::geom::{Vec2, Vec3}; + use cadcraft_engine::doc::{Block, Common, Entity, Space}; + hooks(); + let dir = folder("spaces"); + std::fs::create_dir_all(dir.join("maps")).unwrap(); + std::fs::write(dir.join("maps").join("a.png"), png()).unwrap(); + std::fs::write(dir.join("b.png"), png()).unwrap(); + let image = |path: &str, name: &str| { + EntityKind::Image(Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(4.0, 2.0), + path: path.into(), + name: name.into(), + ..Image::default() + }) + }; + let mut d = Drawing::new_metric(); + // The same file in model space, in a block and on a layout; another only on the layout, + // saved with a folder that no longer exists (found by its name next to the drawing); and + // one that is nowhere. + d.add(&Space::Model, Common::default(), image("maps/a.png", "a")).unwrap(); + let mut blk = Block::new("MAP"); + let bh = d.new_handle(); + blk.entities.push(Entity::new(bh, image("maps/a.png", "a"))); + d.blocks.insert("MAP".into(), std::sync::Arc::new(blk)); + let layout = Space::Paper("Layout1".into()); + d.add(&layout, Common::default(), image("maps/a.png", "a")).unwrap(); + d.add(&layout, Common::default(), image("C:\\old\\place\\b.png", "b")).unwrap(); + d.add(&layout, Common::default(), image("gone.png", "gone")).unwrap(); + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + let mut s = Session::new(); + open(&mut s, &host); + let doc = s.doc().unwrap(); + assert_eq!(doc.rasters.len(), 2, "each file once"); + assert!(doc.rasters.get("maps/a.png").is_some() && doc.rasters.get("C:\\old\\place\\b.png").is_some()); + assert!(!s.state().unwrap().is_dirty()); + assert_eq!(s.log.iter().filter(|l| l.starts_with("Image ")).count(), 1, "{:?}", s.log); + let l = listed(&mut s); + let row = |name: &str| l.as_array().unwrap().iter().find(|r| r["name"] == name).cloned().unwrap(); + assert_eq!((row("a")["status"].clone(), row("a")["references"].clone()), (json!("Loaded"), json!(3))); + assert_eq!(row("b")["status"], "Loaded"); + assert_eq!(row("gone")["status"], "Not Found"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_drawing_opened_from_bytes_gets_its_pixels_handed_over() { + // The web app's way: no file system, the drawing arrives as bytes and so do the images. + use cadcraft_engine::doc::geom::{Vec2, Vec3}; + use cadcraft_engine::doc::{Common, Space}; + hooks(); + let mut d = Drawing::new_metric(); + let i = Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(4.0, 2.0), + path: "no-such-folder/web.png".into(), + name: "web".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + let dxf = cadcraft_io::write(&d, "plan.dxf").unwrap(); + + let mut s = Session::new(); + s.execute("open", &json!({ "data": base64_encode(&dxf), "name": "plan.dxf" })).unwrap(); + assert_eq!(images(&s).len(), 1, "opened with its image reference"); + assert!(s.doc().unwrap().rasters.is_empty()); + assert!(s.log.iter().any(|l| l.contains("web.png")), "{:?}", s.log); + assert_eq!(listed(&mut s)[0]["status"], "Not Found"); + assert_eq!(drawn_images(&s), 0); + + // The same file handed over as bytes: the reference that was there draws too. + s.execute("imageattach", &json!({ "path": "no-such-folder/web.png", "data": base64_encode(&png()), "at": [10, 0] })).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2)); + let l = listed(&mut s); + assert_eq!((l[0]["status"].clone(), l[0]["references"].clone(), l[0]["name"].clone()), (json!("Loaded"), json!(2), json!("web"))); + + // Saved and read back: the references are kept, the pixels are not in the file. + let bytes = cadcraft_io::write(s.doc().unwrap(), "plan.dxf").unwrap(); + let back = cadcraft_io::read(&bytes, "plan.dxf").unwrap(); + assert!(back.rasters.is_empty()); + assert_eq!(back.model.iter().filter(|e| matches!(e.kind, EntityKind::Image(_))).count(), 2); +} + +#[test] +fn many_files_are_loaded_together_and_reported_in_order() { + // More files than loaders, with missing and unreadable ones among them: each good file is + // loaded, each bad one reported once, in the order the drawing uses them. + use cadcraft_engine::doc::geom::{Vec2, Vec3}; + use cadcraft_engine::doc::{Common, Space}; + hooks(); + let dir = folder("many"); + let mut d = Drawing::new_metric(); + let mut expected_notes = Vec::new(); + for n in 0..60u32 { + let name = format!("tile{n:02}.png"); + match n % 10 { + 3 => expected_notes.push(format!("Image {name}: file not found")), + 7 => { + std::fs::write(dir.join(&name), b"not an image").unwrap(); + expected_notes.push(format!("Image {name}: ")); + } + _ => { + // A different width per file, so a mix-up would show. + let px = vec![200u8; (n as usize + 1) * 4]; + std::fs::write(dir.join(&name), cadcraft_io::raster::encode_png(&Raster::new(n + 1, 1, px).unwrap()).unwrap()).unwrap(); + } + } + let i = Image { + insert: Vec3::new(f64::from(n) * 3.0, 0.0, 0.0), + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(f64::from(n + 1), 1.0), + path: name.clone(), + name: format!("tile{n:02}"), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + } + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + for _ in 0..3 { + let mut s = Session::new(); + open(&mut s, &host); + let doc = s.doc().unwrap(); + assert_eq!(doc.rasters.len(), 48); + for n in 0..60u32 { + let loaded = doc.rasters.get(&format!("tile{n:02}.png")).map(|r| r.width()); + assert_eq!(loaded, if matches!(n % 10, 3 | 7) { None } else { Some(n + 1) }, "tile {n}"); + } + let notes: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Image ")).collect(); + assert_eq!(notes.len(), expected_notes.len(), "{notes:?}"); + assert!(notes.iter().zip(&expected_notes).all(|(got, want)| got.starts_with(want.as_str())), "{notes:?}"); + assert!(!s.state().unwrap().is_dirty()); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/image_decoder_panic.rs b/crates/engine/tests/image_decoder_panic.rs new file mode 100644 index 000000000..42e74253e --- /dev/null +++ b/crates/engine/tests/image_decoder_panic.rs @@ -0,0 +1,55 @@ +//! A raster image decoder that panics on a damaged file fails that image only (#956): the +//! drawing it belongs to still opens, and IMAGEATTACH reports an error. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io}; +use cadcraft_engine::doc::geom::{Vec2, Vec3}; +use cadcraft_engine::doc::{Common, Drawing, EntityKind, Image, Line, Space}; +use serde_json::json; + +fn hooks() { + set_io(IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: None, + }); + set_image_decoder(|_| panic!("decoder bug")); +} + +#[test] +fn a_panicking_decoder_fails_the_image_not_the_drawing() { + hooks(); + let dir = std::env::temp_dir().join(format!("cadcraft-image-panic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("map.png"), b"anything").unwrap(); + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 5.0, 0.0) })).unwrap(); + let i = Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(4.0, 2.0), + path: "map.png".into(), + name: "map".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + let mut s = Session::new(); + s.execute("open", &json!({ "path": host.to_string_lossy() })).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(doc.model.len(), 2, "the drawing opened with everything in it"); + assert!(doc.rasters.is_empty()); + assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("damaged")), "{:?}", s.log); + + let before = s.doc().unwrap().model.len(); + let e = s.execute("imageattach", &json!({ "path": "other.png", "data": base64_encode(b"bytes") })).unwrap_err().to_string(); + assert!(e.contains("damaged"), "{e}"); + assert_eq!(s.doc().unwrap().model.len(), before); + // The session still works. + s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), before + 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 2e8b3cc80..f719bbade 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -22,6 +22,7 @@ serde = { workspace = true } miniz_oxide = "0.9" craft-pdf = { workspace = true, features = ["write"] } quick-xml = { workspace = true } +image = { workspace = true } [dev-dependencies] # Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's). diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 8cad3f24f..f2898ac25 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -14,6 +14,8 @@ mod dxf_write; mod dxf_xref; pub mod pdf; pub mod plotter; +/// Raster image files (PNG, JPEG, BMP, TIFF) decoded to pixels. +pub mod raster; pub mod svg; mod svg_read; diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 3f35233a8..d242112bb 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -844,6 +844,8 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts: } } Kind::Point => point(s, &mut batch, &p.map, raw, m), + // Raster images are not written to PDF yet: their frame is. + Kind::Image { .. } => {} } } flush(s, &mut batch); diff --git a/crates/io/src/raster.rs b/crates/io/src/raster.rs new file mode 100644 index 000000000..3e09ef9c9 --- /dev/null +++ b/crates/io/src/raster.rs @@ -0,0 +1,79 @@ +//! Raster image files decoded to pixels (IMAGEATTACH): PNG, JPEG, BMP and TIFF, the format taken +//! from the content. Files are hostile input: the size is read from the header and checked +//! before any pixel is decoded. + +use std::io::Cursor; + +use cadcraft_doc::Raster; + +use crate::{IoError, Result}; + +/// Largest image file read (bytes). +pub const MAX_IMAGE_BYTES: usize = 256 << 20; +/// Most pixels decoded per image (four bytes each in memory). +pub const MAX_IMAGE_PIXELS: u64 = 128_000_000; + +fn bad(e: impl std::fmt::Display) -> IoError { + IoError::Format(format!("image: {e}")) +} + +/// A reader over an image file, once it is small enough and of a format we read. +fn reader(bytes: &[u8]) -> Result>> { + if bytes.len() > MAX_IMAGE_BYTES { + return Err(bad(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20))); + } + // The format comes from the file's first bytes, not from its name. + let file = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(bad)?; + if file.format().is_none() { + return Err(bad("not a PNG, JPEG, BMP or TIFF file")); + } + Ok(file) +} + +/// The width and height in pixels of the image in `bytes`, read from its header. +pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> { + let (width, height) = reader(bytes)?.into_dimensions().map_err(bad)?; + if width == 0 || height == 0 { + return Err(bad("the image is empty")); + } + if u64::from(width).saturating_mul(u64::from(height)) > MAX_IMAGE_PIXELS { + return Err(bad(format!("{width} x {height} pixels is more than {} megapixels", MAX_IMAGE_PIXELS / 1_000_000))); + } + Ok((width, height)) +} + +/// Decode the image in `bytes`. +pub fn decode(bytes: &[u8]) -> Result { + // The size is checked first, from the header alone. + let (width, height) = probe(bytes)?; + let mut file = reader(bytes)?; + file.limits(decode_limits(width, height)); + let pixels = file.decode().map_err(bad)?.into_rgba8(); + Raster::new(pixels.width(), pixels.height(), pixels.into_raw()).ok_or_else(|| bad("the image is empty")) +} + +/// What the decoder may allocate for an image of this size: the pixels themselves (up to 16 +/// bytes each before they become 8-bit RGBA) and its own working buffers. +fn decode_limits(width: u32, height: u32) -> image::Limits { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(width); + limits.max_image_height = Some(height); + limits.max_alloc = Some(pixels.saturating_mul(16).saturating_add(64 << 20)); + limits +} + +/// `raster` as a PNG file. +pub fn encode_png(raster: &Raster) -> Result> { + let mut out = Cursor::new(Vec::new()); + image::write_buffer_with_format( + &mut out, + raster.rgba(), + raster.width(), + raster.height(), + image::ExtendedColorType::Rgba8, + image::ImageFormat::Png, + ) + .map_err(bad)?; + Ok(out.into_inner()) +} diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index b5ace3bcf..2312c9d5f 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -96,6 +96,8 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option) } s.push_str(r#""/>"#); } + // Raster images are not written to SVG yet: their frame is. + Kind::Image { .. } => {} Kind::Point => { if let Some(q) = pts.first() { let _ = write!(s, r#""#, tx(q.x), ty(q.y), stroke); diff --git a/crates/io/tests/raster_decode.rs b/crates/io/tests/raster_decode.rs new file mode 100644 index 000000000..51a6130b7 --- /dev/null +++ b/crates/io/tests/raster_decode.rs @@ -0,0 +1,172 @@ +//! Raster image files decoded to pixels (IMAGEATTACH): round trip through PNG, and hostile files +//! (truncated, random, absurd sizes) refused without a panic. + +use cadcraft_doc::Raster; +use cadcraft_io::raster::{MAX_IMAGE_PIXELS, decode, encode_png, probe}; + +/// A 3 x 2 image with a different colour per pixel, one of them half transparent. +fn sample() -> Raster { + let px: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255], [0, 255, 255, 128], [10, 20, 30, 255]]; + Raster::new(3, 2, px.concat()).unwrap() +} + +#[test] +fn png_round_trips_pixel_for_pixel() { + let r = sample(); + let png = encode_png(&r).unwrap(); + assert_eq!(probe(&png).unwrap(), (3, 2)); + let back = decode(&png).unwrap(); + assert_eq!(back, r); + assert!(!back.opaque()); + assert_eq!(back.pixel(1, 1), Some([0, 255, 255, 128])); + assert_eq!(back.pixel(3, 0), None); +} + +#[test] +fn hostile_image_does_not_panic() { + let png = encode_png(&sample()).unwrap(); + // Empty, random, not an image, and every truncation of a real file. + for bytes in [&b""[..], &[0u8; 64], b"0\nSECTION\n2\nHEADER\n", &[0xff; 4096]] { + assert!(decode(bytes).is_err()); + assert!(probe(bytes).is_err()); + } + for n in 0..png.len() { + let _ = probe(&png[..n]); + let _ = decode(&png[..n]); + } + assert!(decode(&png[..png.len() / 2]).is_err()); + // Every byte flipped in turn: an error or an image, never a panic. + for i in 0..png.len() { + let mut b = png.clone(); + b[i] ^= 0xff; + let _ = decode(&b); + } +} + +#[test] +fn an_absurd_declared_size_is_refused_before_decoding() { + // A PNG declaring 60000 x 60000 pixels (3.6 gigapixels) with no pixel data. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = b"IHDR".to_vec(); + ihdr.extend(60000u32.to_be_bytes()); + ihdr.extend(60000u32.to_be_bytes()); + ihdr.extend([8, 6, 0, 0, 0]); + for chunk in [&ihdr[..], b"IDAT", b"IEND"] { + png.extend(u32::try_from(chunk.len() - 4).unwrap().to_be_bytes()); + png.extend(chunk); + png.extend(crc32(chunk).to_be_bytes()); + } + assert!(u64::from(60000u32) * 60000 > MAX_IMAGE_PIXELS); + let e = decode(&png).unwrap_err().to_string(); + assert!(e.contains("megapixels"), "{e}"); +} + +fn crc32(data: &[u8]) -> u32 { + let mut c = !0u32; + for b in data { + c ^= u32::from(*b); + for _ in 0..8 { + c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c +} + +#[test] +fn raster_new_checks_the_buffer() { + assert!(Raster::new(0, 4, Vec::new()).is_none()); + assert!(Raster::new(2, 2, vec![0; 15]).is_none()); + assert!(Raster::new(u32::MAX, u32::MAX, vec![0; 16]).is_none()); + assert!(Raster::new(2, 2, vec![255; 16]).unwrap().opaque()); +} + +/// `r` written in `format` by the image library. +fn encoded(r: &Raster, format: image::ImageFormat, color: image::ExtendedColorType, bytes: &[u8]) -> Vec { + let mut out = std::io::Cursor::new(Vec::new()); + image::write_buffer_with_format(&mut out, bytes, r.width(), r.height(), color, format).unwrap(); + out.into_inner() +} + +#[test] +fn bmp_tiff_and_jpeg_files_are_read() { + // Opaque colours in 8 x 8 blocks (JPEG works in blocks of that size). + let (w, h) = (16u32, 8u32); + let rgb: Vec = (0..h).flat_map(|_| (0..w).flat_map(|x| if x < 8 { [200u8, 30, 30] } else { [30, 30, 200] })).collect(); + let rgba: Vec = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let r = Raster::new(w, h, rgba).unwrap(); + + for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff] { + let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb); + assert_eq!(probe(&file).unwrap(), (w, h), "{format:?}"); + let back = decode(&file).unwrap(); + assert_eq!(back, r, "{format:?} keeps every pixel"); + assert!(back.opaque()); + } + + // JPEG loses a little: the colours are close, the size exact. + let file = encoded(&r, image::ImageFormat::Jpeg, image::ExtendedColorType::Rgb8, &rgb); + let back = decode(&file).unwrap(); + assert_eq!((back.width(), back.height()), (w, h)); + for (x, want) in [(2, [200u8, 30, 30]), (13, [30, 30, 200])] { + let got = back.pixel(x, 4).unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 12) && got[3] == 255, "{got:?} at {x}"); + } + + // Grey and grey-with-alpha files become RGBA. + let grey = Raster::new(2, 1, vec![0, 0, 0, 255, 255, 255, 255, 255]).unwrap(); + assert_eq!(decode(&encoded(&grey, image::ImageFormat::Png, image::ExtendedColorType::L8, &[0, 255])).unwrap(), grey); + let grey_alpha = Raster::new(2, 1, vec![9, 9, 9, 0, 255, 255, 255, 128]).unwrap(); + assert_eq!(decode(&encoded(&grey_alpha, image::ImageFormat::Png, image::ExtendedColorType::La8, &[9, 0, 255, 128])).unwrap(), grey_alpha); +} + +#[test] +fn damaged_files_of_every_format_do_not_panic() { + let (w, h) = (16u32, 8u32); + let rgb = vec![120u8; (w * h * 3) as usize]; + let r = Raster::new(w, h, vec![255; (w * h * 4) as usize]).unwrap(); + for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff, image::ImageFormat::Jpeg] { + let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb); + for n in 0..file.len() { + let _ = probe(&file[..n]); + let _ = decode(&file[..n]); + } + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0xff; + let _ = decode(&b); + b[i] = 0; + let _ = decode(&b); + } + } +} + +#[test] +fn the_pixels_never_reach_the_drawing_file() { + use cadcraft_doc::{Common, Drawing, EntityKind, Image, Space}; + use cadcraft_geom::{Vec2, Vec3}; + let mut d = Drawing::new_metric(); + let i = Image { + insert: Vec3::new(3.0, 4.0, 0.0), + u: Vec3::new(0.5, 0.0, 0.0), + v: Vec3::new(0.0, 0.5, 0.0), + size: Vec2::new(3.0, 2.0), + path: "maps/site.png".into(), + name: "site".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i.clone())).unwrap(); + d.rasters.insert("maps/site.png", std::sync::Arc::new(sample())); + let text = cadcraft_io::write_dxf(&d); + assert!(text.contains("maps/site.png")); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert!(back.rasters.is_empty(), "a drawing just read has no pixels"); + let got: Vec<&Image> = back.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i) } else { None }).collect(); + // An unclipped image is written with the boundary of the whole image, switched off. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(2.5, 1.5)], ..i }; + assert_eq!(got, [&i]); + // Loaded pixels don't make two drawings differ. + let mut bare = d.clone(); + bare.rasters.remove("maps/site.png"); + assert!(bare.rasters.is_empty() && d.rasters.len() == 1); + assert_eq!(bare, d); +} diff --git a/crates/io/tests/raster_foreign_files.rs b/crates/io/tests/raster_foreign_files.rs new file mode 100644 index 000000000..bf7c7ace7 --- /dev/null +++ b/crates/io/tests/raster_foreign_files.rs @@ -0,0 +1,222 @@ +//! Raster image files CADCraft did not write itself (#956): files made by another program, and +//! files laid out by hand from the formats' public descriptions, so reading is not only checked +//! against the library that also does the writing. +//! +//! The four embedded files hold our own synthetic picture (16 x 8 pixels: the left half +//! (200, 30, 30), the right half (30, 30, 200)), converted from a hand-built PNG with the macOS +//! command `sips` (`-s format jpeg|tiff|bmp|png`, colour profile removed). They contain nothing +//! but those pixels and the encoder's headers. + +use cadcraft_doc::Raster; +use cadcraft_io::raster::{decode, probe}; + +/// JPEG, baseline, quality 90, with JFIF and Exif headers. +const SIPS_JPEG: &str = "ffd8ffe000104a46494600010100004800480000ffe1004c4578696600004d4d002a00000008000187690004000000010000001a000000000003a001 \ + 000300000001ffff0000a00200040000000100000010a0030004000000010000000800000000ffed003850686f746f73686f7020332e30003842494d \ + 04040000000000003842494d0425000000000010d41d8cd98f00b204e9800998ecf8427effc00011080008001003012200021101031101ffc4001f00 \ + 00010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d0102030004110512213141 \ + 0613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758 \ + 595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8 \ + c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f01000301010101010101010100000000000001020304050607 \ + 08090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272 \ + d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a8283848586 \ + 8788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3 \ + f4f5f6f7f8f9faffdb004300020202020202030202030503030305060505050506080606060606080a0808080808080a0a0a0a0a0a0a0a0c0c0c0c0c \ + 0c0e0e0e0e0e0f0f0f0f0f0f0f0f0f0fffdb00430102030304040407040407100b090b10101010101010101010101010101010101010101010101010 \ + 10101010101010101010101010101010101010101010101010ffdd00040001ffda000c03010002110311003f00f90ebe7fafa02be7fafdc7e8f3ff00 \ + 331ffb87ff00b79fd1de3aff00cc07fdbfff00b69fffd9"; +/// TIFF, big-endian, LZW compressed. +const SIPS_TIFF_LZW: &str = "4d4d002a000000680002a00200040000000100000010a0030004000000010000000800000000803203c1e004160d0784424ac00554261d0f814121f1 \ + 38342e1b148a44631148b46e271a8f43a3b2184c82490791c9e0d2695002532d964aa5f2a98c9e672780800000100100000300000001001000000101 \ + 0003000000010008000001020003000000030000012e010300030000000100050000010600030000000100020000010a000300000001000100000111 \ + 00040000000100000026011200030000000100010000011500030000000100030000011600030000000100080000011700040000000100000041011c \ + 00030000000100010000012800030000000100020000013d000300000001000200000153000300000003000001348769000400000001000000080000 \ + 0000000800080008000100010001"; +/// TIFF, big-endian, not compressed. +const SIPS_TIFF_PLAIN: &str = "4d4d002a000001a60002a00200040000000100000010a0030004000000010000000800000000c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \ + 1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \ + 1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec8 \ + 1e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \ + 1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \ + 1ec8000f0100000300000001001000000101000300000001000800000102000300000003000002600103000300000001000100000106000300000001 \ + 00020000010a000300000001000100000111000400000001000000260112000300000001000100000115000300000001000300000116000300000001 \ + 00080000011700040000000100000180011c000300000001000100000128000300000001000200000153000300000003000002668769000400000001 \ + 0000000800000000000800080008000100010001"; +/// BMP, 24 bits, rows stored top-down (negative height). +const SIPS_BMP: &str = "424db601000000000000360000002800000010000000f8ffffff010018000000000080010000000000000000000000000000000000001e1ec81e1ec8 \ + 1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \ + c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e \ + c81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec8 \ + 1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \ + c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e"; +/// PNG as that program writes it (its own chunks and compression). +const SIPS_PNG: &str = "89504e470d0a1a0a0000000d49484452000000100000000808020000007f14e8c00000000467414d410000b18f0bfc6105000000206348524d00007a \ + 26000080840000fa00000080e8000075300000ea6000003a98000017709cba513c00000038655849664d4d002a000000080001876900040000000100 \ + 00001a000000000002a00200040000000100000010a0030004000000010000000800000000470d8f8b000001c869545874584d4c3a636f6d2e61646f \ + 62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020 \ + 436f726520362e302e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30 \ + 322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a2020 \ + 20202020202020202020786d6c6e733a657869663d22687474703a2f2f6e732e61646f62652e636f6d2f657869662f312e302f223e0a202020202020 \ + 2020203c657869663a436f6c6f7253706163653e313c2f657869663a436f6c6f7253706163653e0a2020202020202020203c657869663a506978656c \ + 5844696d656e73696f6e3e31363c2f657869663a506978656c5844696d656e73696f6e3e0a2020202020202020203c657869663a506978656c594469 \ + 6d656e73696f6e3e383c2f657869663a506978656c5944696d656e73696f6e3e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020 \ + 203c2f7264663a5244463e0a3c2f783a786d706d6574613e0ae6ba92b400000021494441541819633c2127c7800d8431acc226ccc08455148fe0a806 \ + 3c8103970200086302148ee3f7130000000049454e44ae426082"; + +fn bytes(hex: &str) -> Vec { + let digits: Vec = hex.bytes().filter(u8::is_ascii_hexdigit).collect(); + digits.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect() +} + +const LEFT: [u8; 3] = [200, 30, 30]; +const RIGHT: [u8; 3] = [30, 30, 200]; + +/// The picture is the two-colour 16 x 8 one, each colour within `tolerance`. +#[track_caller] +fn is_the_picture(r: &Raster, tolerance: u8, what: &str) { + assert_eq!((r.width(), r.height()), (16, 8), "{what}"); + assert!(r.opaque(), "{what}"); + for y in 0..8 { + for x in 0..16 { + // A lossy file blends the columns around the colour change (JPEG stores colour at + // half resolution). + if tolerance > 3 && (6..=9).contains(&x) { + continue; + } + let want = if x < 8 { LEFT } else { RIGHT }; + let got = r.pixel(x, y).unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= tolerance), "{what}: {got:?} at {x},{y}, wanted {want:?}"); + } + } +} + +#[test] +fn files_written_by_another_program_are_read() { + for (hex, tolerance, what) in [ + (SIPS_PNG, 3, "PNG"), + (SIPS_BMP, 3, "top-down BMP"), + (SIPS_TIFF_LZW, 3, "LZW TIFF"), + (SIPS_TIFF_PLAIN, 3, "plain TIFF"), + (SIPS_JPEG, 14, "JPEG"), + ] { + let file = bytes(hex); + assert_eq!(probe(&file).unwrap(), (16, 8), "{what}"); + is_the_picture(&decode(&file).unwrap(), tolerance, what); + // Cut short or damaged, they are refused or read: never a panic. + for n in 0..file.len() { + let _ = decode(&file[..n]); + } + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0x55; + let _ = decode(&b); + } + } +} + +/// An uncompressed RGB TIFF of one strip, laid out by hand: header, pixels, then the directory. +fn tiff_by_hand(big_endian: bool, w: u32, h: u32, rgb: &[u8]) -> Vec { + let u16b = |v: u16| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }; + let u32b = |v: u32| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut f: Vec = if big_endian { b"MM".to_vec() } else { b"II".to_vec() }; + f.extend(u16b(42)); + let pixels_at = 8u32; + let bits_at = pixels_at + rgb.len() as u32; + let dir_at = bits_at + 6; + f.extend(u32b(dir_at)); + f.extend(rgb); + for _ in 0..3 { + f.extend(u16b(8)); + } + // (tag, type: 3 short / 4 long, count, value) + let entries: [(u16, u16, u32, u32); 10] = [ + (256, 4, 1, w), + (257, 4, 1, h), + (258, 3, 3, bits_at), + (259, 3, 1, 1), + (262, 3, 1, 2), + (273, 4, 1, pixels_at), + (277, 3, 1, 3), + (278, 4, 1, h), + (279, 4, 1, rgb.len() as u32), + (284, 3, 1, 1), + ]; + f.extend(u16b(entries.len() as u16)); + for (tag, kind, count, value) in entries { + f.extend(u16b(tag)); + f.extend(u16b(kind)); + f.extend(u32b(count)); + // A single short sits in the first two bytes of the value field. + if kind == 3 && count == 1 { + f.extend(u16b(value as u16)); + f.extend([0, 0]); + } else { + f.extend(u32b(value)); + } + } + f.extend(u32b(0)); + f +} + +/// A 24-bit BMP laid out by hand: rows bottom-up, blue-green-red, each padded to four bytes. +fn bmp_by_hand(w: u32, h: u32, rgb: &[u8]) -> Vec { + let row = (w as usize * 3).div_ceil(4) * 4; + let size = 54 + row * h as usize; + let mut f = b"BM".to_vec(); + f.extend((size as u32).to_le_bytes()); + f.extend([0; 4]); + f.extend(54u32.to_le_bytes()); + f.extend(40u32.to_le_bytes()); + f.extend((w as i32).to_le_bytes()); + f.extend((h as i32).to_le_bytes()); + f.extend(1u16.to_le_bytes()); + f.extend(24u16.to_le_bytes()); + f.extend([0; 24]); + for y in (0..h as usize).rev() { + let line = &rgb[y * w as usize * 3..(y + 1) * w as usize * 3]; + for p in line.chunks(3) { + f.extend([p[2], p[1], p[0]]); + } + f.extend(std::iter::repeat_n(0, row - w as usize * 3)); + } + f +} + +#[test] +fn files_laid_out_by_hand_are_read_pixel_for_pixel() { + // 3 x 2: an odd width, so BMP rows are padded; every pixel different. + let rgb: Vec = (0..18u8).map(|i| i * 13 + 5).collect(); + let rgba: Vec = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let want = Raster::new(3, 2, rgba).unwrap(); + for (file, what) in [ + (tiff_by_hand(false, 3, 2, &rgb), "little-endian TIFF"), + (tiff_by_hand(true, 3, 2, &rgb), "big-endian TIFF"), + (bmp_by_hand(3, 2, &rgb), "bottom-up BMP"), + ] { + assert_eq!(probe(&file).unwrap(), (3, 2), "{what}"); + assert_eq!(decode(&file).unwrap(), want, "{what}"); + } +} + +#[test] +fn hand_made_files_that_lie_about_their_size_are_refused() { + let rgb = [9u8; 18]; + // More pixels declared than the file holds. + for file in [tiff_by_hand(false, 300, 200, &rgb), tiff_by_hand(true, 3, 2000, &rgb), bmp_by_hand(3, 2, &rgb)[..60].to_vec()] { + assert!(decode(&file).is_err()); + } + // Sizes past the limit, before any pixel is read. + for file in [tiff_by_hand(false, 60_000, 60_000, &rgb), tiff_by_hand(true, u32::MAX, u32::MAX, &rgb)] { + assert!(decode(&file).is_err()); + } + let mut bmp = bmp_by_hand(3, 2, &rgb); + bmp[18..22].copy_from_slice(&60_000i32.to_le_bytes()); + bmp[22..26].copy_from_slice(&60_000i32.to_le_bytes()); + assert!(decode(&bmp).is_err()); + // A zero-sized image. + assert!(decode(&tiff_by_hand(false, 0, 0, &[])).is_err()); +} diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index e6ba6b7e9..5fa663974 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -22,6 +22,51 @@ pub enum Kind { Point, /// An infinite line or ray: `verts[start]` = base, `verts[start+1]` = unit direction. Infinite { ray: bool }, + /// A raster image, [`DisplayList::images`]`[index]`; `verts[start..start+4]` are its corners + /// (top left, top right, bottom right, bottom left of the pixel grid). + Image { index: u32 }, +} + +/// A raster image placed in the world. +#[derive(Clone, Debug, PartialEq)] +pub struct DImage { + pub raster: std::sync::Arc, + /// Where the top-left corner of the top-left pixel is. + pub origin: Vec2, + /// One pixel to the right and one pixel down, in world units. + pub right: Vec2, + pub down: Vec2, + /// Closed outlines the image is cut to (all of them: its own clip boundary, then the + /// viewports it is seen through). Empty: the whole image. + pub clips: Vec>, + /// Fade towards the background, percent (0..=100). + pub fade: u8, +} + +impl DImage { + /// The world position of pixel-grid point (`x`, `y`), `y` down from the top. + pub fn at(&self, x: f64, y: f64) -> Vec2 { + self.origin + self.right * x + self.down * y + } + /// The pixel-grid point at world position `q` (the inverse of [`DImage::at`]); `None` for an + /// image squashed to a line or a point. + pub fn pixel_of(&self, q: Vec2) -> Option { + let det = self.right.x * self.down.y - self.right.y * self.down.x; + if !(det.is_finite() && det.abs() > 0.0) { + return None; + } + let d = q - self.origin; + Some(Vec2::new((d.x * self.down.y - d.y * self.down.x) / det, (self.right.x * d.y - self.right.y * d.x) / det)) + } + /// How opaque the image is drawn, 0..=1: the object's opacity `alpha` (0..=255) less the fade. + pub fn opacity(&self, alpha: u8) -> f32 { + f32::from(alpha) / 255.0 * (1.0 - f32::from(self.fade.min(100)) / 100.0) + } + /// The corners: top left, top right, bottom right, bottom left. + pub fn corners(&self) -> [Vec2; 4] { + let (w, h) = (f64::from(self.raster.width()), f64::from(self.raster.height())); + [self.at(0.0, 0.0), self.at(w, 0.0), self.at(w, h), self.at(0.0, h)] + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -46,6 +91,8 @@ pub struct DisplayList { pub prims: Vec, pub verts: Vec, pub tris: Vec, + /// The raster images drawn by [`Kind::Image`] primitives. + pub images: Vec, /// Plots ([`build_plot`]): for each primitive, the index in [`Drawing::layers`] of the layer /// it is drawn on (block contents on layer 0: the block reference's; [`NO_LAYER`] when the /// layer isn't in the table), for PDF layers. Empty for other lists. @@ -131,14 +178,19 @@ impl DisplayList { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn append(dst: &mut DisplayList, src: DisplayList) { let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32); + let images = dst.images.len() as u32; // Pens are parallel to the primitives, with missing entries meaning "use object". if !src.pens.is_empty() { dst.pens.resize(dst.prims.len(), Pen::default()); } dst.prims.extend(src.prims.into_iter().map(|mut p| { p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts }; + if let Kind::Image { index } = &mut p.kind { + *index = index.saturating_add(images); + } p })); + dst.images.extend(src.images); dst.verts.extend(src.verts); dst.tris.extend(src.tris); dst.prim_layers.extend(src.prim_layers); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index cf37e28f7..d6ecede7c 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -186,9 +186,17 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { let o = i.insert.xy(); let u = i.u.xy() * i.size.x; let v = i.v.xy() * i.size.y; + // Display flag 1: show the image. Without pixels (file not found, not loaded yet, + // hidden) the rectangle is crossed out. + let raster = if i.display & 1 != 0 { ctx.d.rasters.get(&i.path) } else { None }; + if let Some(r) = raster { + b.image(ctx, rgb, i, r); + } b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); - b.polyline(ctx, rgb, lw, &[o, o + u + v]); - b.polyline(ctx, rgb, lw, &[o + u, o + v]); + if raster.is_none() { + b.polyline(ctx, rgb, lw, &[o, o + u + v]); + b.polyline(ctx, rgb, lw, &[o + u, o + v]); + } } EntityKind::Table(t) => table(b, ctx, t, rgb, lw), EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6fa236e4c..0e34ee68a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -186,6 +186,45 @@ impl Builder<'_> { len: pts.len() as u32, }); } + /// The pixels of image `i` over its rectangle, cut to its clip boundary. + fn image(&mut self, ctx: &Ctx, color: Ink, i: &cadcraft_doc::Image, raster: &std::sync::Arc) { + // Where the entity puts its picture: the top-left corner, and one of its pixels to the + // right and down. + let top_left = ctx.xf.apply(i.insert.xy() + i.v.xy() * i.size.y); + let right = ctx.xf.apply_vec(i.u.xy()); + let down = ctx.xf.apply_vec(i.v.xy() * -1.0); + let clip = image_clip(i, |q| top_left + right * q.x + down * q.y); + // The file found can have another pixel count than the entity records: its pixels are + // stretched over the same rectangle. + let img = DImage { + raster: raster.clone(), + origin: top_left, + right: right * (i.size.x / f64::from(raster.width())), + down: down * (i.size.y / f64::from(raster.height())), + clips: clip.into_iter().collect(), + fade: i.fade.min(100), + }; + let corners = img.corners(); + if !corners.iter().all(|q| q.x.is_finite() && q.y.is_finite()) { + return; + } + let (Ok(index), Ok(start)) = (u32::try_from(self.list.images.len()), u32::try_from(self.list.verts.len())) else { return }; + for q in corners { + self.list.bounds.add(q); + self.list.verts.push(q); + } + self.list.images.push(img); + self.list.prims.push(DPrim { + handle: ctx.top, + color: color.rgb, + aci7: color.aci7, + lw: 0.0, + transparency: self.transparency, + kind: Kind::Image { index }, + start, + len: 4, + }); + } fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) { self.tris_at(ctx, color, tris, ctx.xf); } @@ -298,6 +337,23 @@ impl Builder<'_> { } } +/// The clip boundary of image `i` as a closed outline, or `None` when the whole image shows. +/// `place` gives the position of a point of the entity's pixel grid (x right, y down from the +/// top-left corner of the image). +fn image_clip(i: &cadcraft_doc::Image, place: impl Fn(Vec2) -> Vec2) -> Option> { + if !i.clipping { + return None; + } + // Boundaries count from pixel centres: the top-left corner of the image is (-0.5, -0.5). + let at = |q: Vec2| place(Vec2::new(q.x + 0.5, q.y + 0.5)); + match i.clip.as_slice() { + // Two opposite corners of a rectangle. + [a, b] => Some(vec![at(*a), at(Vec2::new(b.x, a.y)), at(*b), at(Vec2::new(a.x, b.y))]), + polygon if polygon.len() >= 3 => Some(polygon.iter().map(|q| at(*q)).collect()), + _ => None, + } +} + mod table; pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of}; @@ -311,7 +367,7 @@ mod text_entity; mod viewports; pub use dim_draw::{dim_text, dimension_in}; -pub use display::{DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha}; +pub use display::{DImage, DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha}; pub use pipeline::{Options, build, build_entities, build_plot}; use props::{default_lineweight, resolve_common}; use text_entity::text_style; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 72ac821d0..761191dce 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4,7 +4,7 @@ use cadcraft_color::Rgb; use cadcraft_geom::{Bounds2, Vec2}; use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; -use crate::{DisplayList, Kind}; +use crate::{DImage, DisplayList, Kind}; /// A 2D view: world point at the image centre and pixels per drawing unit. #[derive(Clone, Copy, Debug, PartialEq)] @@ -147,6 +147,13 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { + if let Some(img) = list.images.get(index as usize) + && Bounds2::from_points(pts.iter().copied()).intersects(&vis) + { + image(&mut pm, img, view, p.alpha(), o.antialias); + } + } Kind::Infinite { ray } => { let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) else { continue }; let far = (vis.width() + vis.height()) * 2.0 + base.dist(vis.center()); @@ -222,6 +229,95 @@ impl Hairlines { } } +/// Draw `img` with the object opacity `alpha`, cut to its clip outlines. +fn image(pm: &mut Pixmap, img: &DImage, view: &View, alpha: u8, antialias: bool) { + let r = &img.raster; + let Some(to_screen) = grid_to_screen(img, view) else { return }; + let Ok(mask) = clip_mask(pm, img, view, antialias) else { return }; + // tiny-skia wants premultiplied pixels; opaque ones already are. + let owned; + let pixels = if r.opaque() { + tiny_skia::PixmapRef::from_bytes(r.rgba(), r.width(), r.height()) + } else { + owned = premultiplied(r); + owned.as_ref().map(Pixmap::as_ref) + }; + let Some(pixels) = pixels else { return }; + let paint = Paint { + shader: tiny_skia::Pattern::new( + pixels, + tiny_skia::SpreadMode::Pad, + tiny_skia::FilterQuality::Bilinear, + img.opacity(alpha), + Transform::identity(), + ), + anti_alias: antialias, + ..Paint::default() + }; + // The picture's own rectangle in its pixel grid, carried to the screen by `to_screen`. + // Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug assertion on narrow + // rects (see the points above), which a one-pixel image drawn at its own size would be. + if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, r.width() as f32, r.height() as f32) { + pm.fill_path(&PathBuilder::from_rect(rect), &paint, FillRule::Winding, to_screen, mask.as_ref()); + } +} + +/// The transform from `img`'s pixel grid (x right, y down) to screen pixels; `None` when the +/// image has no area on screen or lies out of number range. +fn grid_to_screen(img: &DImage, view: &View) -> Option { + let s = view.scale; + let (x, y) = view.to_screen(img.origin); + // Screen y runs down, world y up. + let ts = Transform::from_row((img.right.x * s) as f32, (-img.right.y * s) as f32, (img.down.x * s) as f32, (-img.down.y * s) as f32, x, y); + (ts.is_finite() && ts.invert().is_some()).then_some(ts) +} + +/// The image has a clip outline without area: nothing of it shows. +struct Hidden; + +/// The part of the picture `img`'s clip outlines leave visible: `None` for all of it. +fn clip_mask(pm: &Pixmap, img: &DImage, view: &View, antialias: bool) -> Result, Hidden> { + let mut mask: Option = None; + for outline in &img.clips { + let path = outline_path(outline, view).ok_or(Hidden)?; + match &mut mask { + // The first outline starts the mask; each further one narrows it. + None => { + let mut m = tiny_skia::Mask::new(pm.width(), pm.height()).ok_or(Hidden)?; + m.fill_path(&path, FillRule::EvenOdd, antialias, Transform::identity()); + mask = Some(m); + } + Some(m) => m.intersect_path(&path, FillRule::EvenOdd, antialias, Transform::identity()), + } + } + Ok(mask) +} + +/// A closed world-space outline as a screen path. +fn outline_path(outline: &[Vec2], view: &View) -> Option { + let mut pb = PathBuilder::new(); + for (i, q) in outline.iter().enumerate() { + let (x, y) = view.to_screen(*q); + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + pb.close(); + pb.finish() +} + +/// The pixels of `r` with each colour multiplied by its alpha. +fn premultiplied(r: &cadcraft_doc::Raster) -> Option { + let mut data = Vec::with_capacity(r.rgba().len()); + for [red, green, blue, a] in r.rgba().as_chunks::<4>().0 { + let m = |c: &u8| ((u16::from(*c) * u16::from(*a) + 127) / 255) as u8; + data.extend_from_slice(&[m(red), m(green), m(blue), *a]); + } + Pixmap::from_vec(data, tiny_skia::IntSize::from_wh(r.width(), r.height())?) +} + /// Render and encode as PNG. pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option> { render(list, view, o)?.encode_png().ok() diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6f1e57517..f4d4e7d98 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -1,6 +1,6 @@ #[cfg(not(target_arch = "wasm32"))] use super::display::append; -use super::display::{DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT}; +use super::display::{DImage, DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT}; use super::draw::entity; #[cfg(not(target_arch = "wasm32"))] use super::pipeline::PARALLEL_CHUNK; @@ -228,6 +228,11 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { push_raw(dst, p, Kind::Point, &[*q]); } } + (Kind::Image { index }, _) => { + if let Some(img) = src.images.get(index as usize) { + push_clipped_image(dst, p, img, pts, clip); + } + } (Kind::Infinite { ray }, Clip::Rect(rect)) => { if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) && let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect) @@ -246,6 +251,25 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { } } +/// Append image `img` (primitive `p`, corners `corners`) to `dst`, to be cut to `clip` when it +/// is drawn: the picture is not cut here, the outline is added to those it carries. +fn push_clipped_image(dst: &mut DisplayList, p: &DPrim, img: &DImage, corners: &[Vec2], clip: &Clip) { + let outline = match clip { + Clip::Rect(rect) => { + if !Bounds2::from_points(corners.iter().copied()).intersects(rect) { + return; + } + vec![rect.min, Vec2::new(rect.max.x, rect.min.y), rect.max, Vec2::new(rect.min.x, rect.max.y)] + } + Clip::Poly(c) => c.polygon().to_vec(), + }; + let Ok(index) = u32::try_from(dst.images.len()) else { return }; + let mut img = img.clone(); + img.clips.push(outline); + dst.images.push(img); + push_raw(dst, p, Kind::Image { index }, corners); +} + fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { let min = match kind { Kind::Polyline => 2, diff --git a/crates/render/tests/raster_image.rs b/crates/render/tests/raster_image.rs new file mode 100644 index 000000000..8cc2d09e8 --- /dev/null +++ b/crates/render/tests/raster_image.rs @@ -0,0 +1,338 @@ +//! Raster images (IMAGE): the pixels are drawn over the image's rectangle, rotated with it and +//! cut to its clip boundary; an image without loaded pixels is a crossed-out frame. + +use std::sync::Arc; + +use cadcraft_color::Rgb; +use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Image, Insert, Line, Raster, Space, Transparency, Viewport}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::raster::{RasterOptions, View, render}; +use cadcraft_render::{Kind, Options, build}; + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const BG: Rgb = Rgb(33, 40, 48); + +/// Two by two pixels: red and green on top, blue and white below. +fn pixels() -> Arc { + Arc::new(Raster::new(2, 2, [RED, GREEN, BLUE, WHITE].concat()).unwrap()) +} + +/// A 10 x 10 unit image of `map.png` with its lower-left corner at the origin. +fn image() -> Image { + Image { + insert: Vec3::ZERO, + u: Vec3::new(5.0, 0.0, 0.0), + v: Vec3::new(0.0, 5.0, 0.0), + size: Vec2::new(2.0, 2.0), + path: "map.png".into(), + ..Image::default() + } +} + +fn drawing(i: Image, loaded: bool) -> Drawing { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + if loaded { + d.rasters.insert("map.png", pixels()); + } + d +} + +/// The colour at the centre of each quarter of the 10 x 10 square: top left, top right, bottom +/// left, bottom right. +fn quarters(d: &Drawing) -> [[u8; 4]; 4] { + let list = build(d, &Space::Model, &Options::default()); + let view = View { center: Vec2::new(5.0, 5.0), scale: 10.0, width: 100, height: 100 }; + let pm = render(&list, &view, &RasterOptions { antialias: false, ..Default::default() }).unwrap(); + [(25, 25), (75, 25), (25, 75), (75, 75)].map(|(x, y)| { + let c = pm.pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) +} + +fn bg() -> [u8; 4] { + [BG.0, BG.1, BG.2, 255] +} + +/// The quarters show `want` (the filter blends a little of the neighbouring pixel in). +#[track_caller] +fn shows(got: [[u8; 4]; 4], want: [[u8; 4]; 4]) { + let near = got.iter().flatten().zip(want.iter().flatten()).all(|(a, b)| a.abs_diff(*b) <= 6); + assert!(near, "{got:?} is not {want:?}"); +} + +#[test] +fn the_pixels_are_drawn_over_the_rectangle() { + let d = drawing(image(), true); + shows(quarters(&d), [RED, GREEN, BLUE, WHITE]); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.images.len(), 1); + let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { .. })).unwrap(); + assert_eq!(list.points(p), [Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 0.0)]); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Polyline).count(), 1, "the frame, not crossed out"); +} + +#[test] +fn an_image_without_pixels_is_a_crossed_out_frame() { + for d in [drawing(image(), false), drawing(Image { display: 6, ..image() }, true)] { + let list = build(&d, &Space::Model, &Options::default()); + assert!(list.images.is_empty()); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Polyline).count(), 3, "frame and two diagonals"); + } +} + +#[test] +fn the_image_turns_with_its_axes() { + // Turned a quarter turn counter-clockwise about its lower-left corner, then moved back over + // the square: the image's top edge is now the square's left edge. + let i = Image { insert: Vec3::new(10.0, 0.0, 0.0), u: Vec3::new(0.0, 5.0, 0.0), v: Vec3::new(-5.0, 0.0, 0.0), ..image() }; + shows(quarters(&drawing(i, true)), [GREEN, WHITE, RED, BLUE]); +} + +#[test] +fn the_clip_boundary_cuts_the_image() { + // The left pixel column only (pixel coordinates from pixel centres, y down from the top). + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(0.5, 1.5)], clipping: true, ..image() }; + shows(quarters(&drawing(i, true)), [RED, bg(), BLUE, bg()]); + // A triangle over the top-left half. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(1.5, -0.5), Vec2::new(-0.5, 1.5)], clipping: true, ..image() }; + let q = quarters(&drawing(i, true)); + shows([q[0], q[3], q[0], q[3]], [RED, bg(), RED, bg()]); + // The boundary is kept but switched off. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(0.5, 1.5)], clipping: false, ..image() }; + shows(quarters(&drawing(i, true)), [RED, GREEN, BLUE, WHITE]); + // The boundary a file holds for an unclipped image is the whole image. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(1.5, 1.5)], clipping: true, ..image() }; + shows(quarters(&drawing(i, true)), [RED, GREEN, BLUE, WHITE]); +} + +#[test] +fn a_file_of_another_size_fills_the_same_rectangle() { + // The entity says 4 x 4 pixels of 2.5 units; the file found has 2 x 2. + let i = Image { u: Vec3::new(2.5, 0.0, 0.0), v: Vec3::new(0.0, 2.5, 0.0), size: Vec2::new(4.0, 4.0), ..image() }; + shows(quarters(&drawing(i, true)), [RED, GREEN, BLUE, WHITE]); +} + +#[test] +fn fade_and_degenerate_images_do_not_panic() { + let faded = quarters(&drawing(Image { fade: 100, ..image() }, true)); + assert_eq!(faded[0], bg(), "fully faded shows the background"); + for i in [ + Image { u: Vec3::ZERO, ..image() }, + Image { u: Vec3::new(f64::NAN, 0.0, 0.0), ..image() }, + Image { size: Vec2::new(0.0, -1.0), ..image() }, + Image { insert: Vec3::new(1e308, 1e308, 0.0), u: Vec3::new(1e308, 0.0, 0.0), ..image() }, + Image { clip: vec![Vec2::new(f64::INFINITY, 0.0), Vec2::new(1.0, f64::NAN)], clipping: true, ..image() }, + ] { + let _ = quarters(&drawing(i, true)); + } +} + +#[test] +fn a_one_pixel_image_at_its_own_size_does_not_panic() { + // One pixel drawn one screen pixel wide, at whole and at half pixel positions, with and + // without smoothing (tiny-skia asserts on narrow anti-aliased rectangles). + for x in [0.0, 0.25, 0.5] { + let mut d = Drawing::new_imperial(); + let i = Image { + insert: Vec3::new(x, x, 0.0), + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(1.0, 1.0), + path: "dot.png".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("dot.png", Arc::new(Raster::new(1, 1, RED.to_vec()).unwrap())); + let list = build(&d, &Space::Model, &Options::default()); + for antialias in [true, false] { + let view = View { center: Vec2::new(8.0, 8.0), scale: 1.0, width: 16, height: 16 }; + assert!(render(&list, &view, &RasterOptions { antialias, ..Default::default() }).is_some()); + } + } +} + +/// The colour of one pixel of the 100 x 100 picture of the 10 x 10 square. +fn pixel_at(d: &Drawing, opts: &Options, x: u32, y: u32) -> [u8; 4] { + let list = build(d, &Space::Model, opts); + let view = View { center: Vec2::new(5.0, 5.0), scale: 10.0, width: 100, height: 100 }; + let c = render(&list, &view, &RasterOptions { antialias: false, ..Default::default() }).unwrap().pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] +} + +#[track_caller] +fn near(got: [u8; 4], want: [u8; 4]) { + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 6), "{got:?} is not {want:?}"); +} + +#[test] +fn the_image_primitive_belongs_to_its_entity_and_widens_the_bounds() { + let mut d = Drawing::new_imperial(); + let i = Image { insert: Vec3::new(100.0, 200.0, 0.0), ..image() }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("map.png", pixels()); + let list = build(&d, &Space::Model, &Options::default()); + let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { .. })).unwrap(); + assert_eq!(p.handle, h, "picking and highlighting find the entity"); + assert_eq!((list.bounds.min, list.bounds.max), (Vec2::new(100.0, 200.0), Vec2::new(110.0, 210.0))); + let img = &list.images[0]; + assert_eq!((img.origin, img.right, img.down), (Vec2::new(100.0, 210.0), Vec2::new(5.0, 0.0), Vec2::new(0.0, -5.0))); + assert_eq!(img.at(2.0, 2.0), Vec2::new(110.0, 200.0)); +} + +#[test] +fn draw_order_is_kept_in_pictures() { + // A white line across the top half (y = 7.5, picture row 25). + let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 7.5, 0.0), b: Vec3::new(10.0, 7.5, 0.0) }); + let build_with = |image_last: bool| { + let mut d = Drawing::new_imperial(); + let order = if image_last { [line(), EntityKind::Image(image())] } else { [EntityKind::Image(image()), line()] }; + for k in order { + d.add(&Space::Model, Common::default(), k).unwrap(); + } + d.rasters.insert("map.png", pixels()); + d + }; + near(pixel_at(&build_with(true), &Options::default(), 25, 25), RED); + near(pixel_at(&build_with(false), &Options::default(), 25, 25), WHITE); +} + +#[test] +fn an_image_in_a_block_follows_the_block_reference() { + let mut d = Drawing::new_imperial(); + let mut blk = Block::new("MAP"); + // A 2 x 2 unit image in the block. + let small = Image { u: Vec3::new(1.0, 0.0, 0.0), v: Vec3::new(0.0, 1.0, 0.0), ..image() }; + blk.entities.push(Entity::new(Handle(900), EntityKind::Image(small))); + d.blocks.insert("MAP".into(), Arc::new(blk)); + // Inserted five times larger at the origin: the same 10 x 10 square. + let ins = Insert { + block: "MAP".into(), + insert: Vec3::ZERO, + scale: Vec3::new(5.0, 5.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins.clone())).unwrap(); + d.rasters.insert("map.png", pixels()); + shows(quarters(&d), [RED, GREEN, BLUE, WHITE]); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.prims.iter().find(|p| matches!(p.kind, Kind::Image { .. })).map(|p| p.handle), Some(h), "selected with its block reference"); + + // A quarter turn about the origin, moved back over the square. + let mut d2 = d.clone(); + let turned = Insert { insert: Vec3::new(10.0, 0.0, 0.0), rotation: std::f64::consts::FRAC_PI_2, ..ins }; + d2.model = Default::default(); + d2.add(&Space::Model, Common::default(), EntityKind::Insert(turned)).unwrap(); + shows(quarters(&d2), [GREEN, WHITE, RED, BLUE]); +} + +#[test] +fn a_layout_viewport_cuts_the_image_to_its_outline() { + let mut d = Drawing::new_metric(); + d.add( + &Space::Model, + Common::default(), + EntityKind::Image(Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(1.5, 0.5)], clipping: true, ..image() }), + ) + .unwrap(); + d.rasters.insert("map.png", pixels()); + // 90 x 100 paper units at 10:1, looking at the middle of the image: model x 0.5..9.5. + let vp = Viewport { + center: Vec3::new(60.0, 60.0, 0.0), + width: 90.0, + height: 100.0, + view_center: Vec2::new(5.0, 5.0), + view_height: 10.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + clip: None, + on: true, + }; + d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); + let list = build(&d, &Space::Paper("Layout1".into()), &Options::default()); + assert_eq!(list.images.len(), 1); + let img = &list.images[0]; + // In paper units: ten times larger, the image's top-left corner at (10, 110). + assert_eq!((img.origin, img.right, img.down), (Vec2::new(10.0, 110.0), Vec2::new(50.0, 0.0), Vec2::new(0.0, -50.0))); + assert_eq!(img.clips.len(), 2, "its own boundary, then the viewport"); + assert_eq!(img.clips[0], [Vec2::new(10.0, 110.0), Vec2::new(110.0, 110.0), Vec2::new(110.0, 60.0), Vec2::new(10.0, 60.0)], "the top pixel row"); + assert_eq!(img.clips[1], [Vec2::new(15.0, 10.0), Vec2::new(105.0, 10.0), Vec2::new(105.0, 110.0), Vec2::new(15.0, 110.0)]); + let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { index: 0 })).unwrap(); + assert_eq!(list.points(p).len(), 4); + + // Drawn: inside the viewport and the image's top row there is the picture; outside the + // viewport, or in the clipped-away bottom row, there is none. + let view = View { center: Vec2::new(60.0, 60.0), scale: 1.0, width: 120, height: 120 }; + let pm = render(&list, &view, &RasterOptions { antialias: false, ..Default::default() }).unwrap(); + let px = |x: u32, y: u32| { + let c = pm.pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] + }; + // (Sampled at pixel centres: an enlarged picture is smoothed between them.) + near(px(35, 35), RED); + near(px(85, 35), GREEN); + near(px(12, 35), bg()); + near(px(107, 35), bg()); + near(px(35, 85), bg()); + near(px(85, 85), bg()); + + // A viewport looking elsewhere shows nothing of it. + let mut away = d.clone(); + let Some(l) = away.layouts.iter_mut().find(|l| l.name == "Layout1") else { panic!("no Layout1") }; + l.entities = Default::default(); + let far = Viewport { + view_center: Vec2::new(500.0, 500.0), + ..match &d.layouts.iter().find(|l| l.name == "Layout1").unwrap().entities.iter().last().unwrap().kind { + EntityKind::Viewport(v) => v.clone(), + _ => panic!("not a viewport"), + } + }; + away.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(far)).unwrap(); + assert!(build(&away, &Space::Paper("Layout1".into()), &Options::default()).images.is_empty()); +} + +#[test] +fn object_transparency_and_pixel_alpha_blend_with_what_is_under() { + // Half transparent as an object. + let mut d = Drawing::new_imperial(); + let c = Common { transparency: Transparency::Percent(50), ..Default::default() }; + d.add(&Space::Model, c, EntityKind::Image(image())).unwrap(); + d.rasters.insert("map.png", pixels()); + let half = |c: u8, b: u8| ((u16::from(c) + u16::from(b)) / 2) as u8; + let mixed = [half(255, BG.0), half(0, BG.1), half(0, BG.2), 255]; + near(pixel_at(&d, &Options { transparency: true, ..Options::default() }, 25, 25), mixed); + near(pixel_at(&d, &Options::default(), 25, 25), RED); + + // Half transparent pixels in the file. + let mut d = drawing(image(), false); + d.rasters.insert("map.png", Arc::new(Raster::new(2, 2, [[255, 0, 0, 128], GREEN, BLUE, [0, 0, 0, 0]].concat()).unwrap())); + let q = quarters(&d); + near(q[0], mixed); + near(q[1], GREEN); + near(q[3], bg()); + + // Half faded. + let d = drawing(Image { fade: 50, ..image() }, true); + near(quarters(&d)[0], mixed); +} + +#[test] +fn two_references_share_one_set_of_pixels() { + let mut d = drawing(image(), true); + d.add(&Space::Model, Common::default(), EntityKind::Image(Image { insert: Vec3::new(20.0, 0.0, 0.0), ..image() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.images.len(), 2); + assert!(Arc::ptr_eq(&list.images[0].raster, &list.images[1].raster)); + let kinds: Vec = list.prims.iter().map(|p| p.kind).filter(|k| matches!(k, Kind::Image { .. })).collect(); + assert_eq!(kinds, [Kind::Image { index: 0 }, Kind::Image { index: 1 }]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fcc464be3..5e121688b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1154,4 +1154,5 @@ mod blockedit; mod compare; /// Background rebuilds of the display list for a new view. +mod images; mod rebuild; diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index 9ca1d9314..1e46926e8 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -22,6 +22,7 @@ fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]> /// Draw a display list. pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) { let vis = xf.world_bounds(); + super::images::draw(p, xf, list); let mut shapes: Vec = Vec::with_capacity(list.prims.len()); let px = 1.0 / xf.scale; for prim in &list.prims { @@ -79,6 +80,8 @@ pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, shapes.push(Shape::line_segment(seg, Stroke::new(1.0, col))); } } + // Drawn first, under the lines (`images::draw`). + Kind::Image { .. } => {} } } p.extend(shapes); @@ -228,6 +231,7 @@ pub(super) fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: c.mesh_origin = origin; c.mesh_ms = crate::now_ms() - t0; } + super::images::draw(p, xf, list); let center = xf.rect.center(); p.add(egui_wgpu::Callback::new_paint_callback( xf.rect, @@ -317,6 +321,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes); } } + Kind::Image { .. } => line(pts.iter().chain(pts.first()).map(|q| xf.to_screen(*q)).collect(), &mut shapes), } } if !fill.is_empty() { diff --git a/crates/ui-egui/src/canvas/images.rs b/crates/ui-egui/src/canvas/images.rs new file mode 100644 index 000000000..4818c079d --- /dev/null +++ b/crates/ui-egui/src/canvas/images.rs @@ -0,0 +1,554 @@ +//! Raster images on the canvas. Each image is a pyramid of levels, each half the size of the one +//! before, cut into tiles that are uploaded as egui textures when first seen. The level drawn is +//! the smallest that still has a pixel for every screen pixel, so a large scan stays sharp zoomed +//! in, doesn't shimmer zoomed out, and only what is on screen takes texture memory. Images are +//! drawn as meshes under the drawing's lines (the drawing itself goes through the GPU callback, +//! so images are not yet interleaved with it in draw order). + +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::{Arc, Mutex, Weak}; + +use cadcraft_doc::Raster; +use cadcraft_geom::{Bounds2, Vec2}; +use cadcraft_render::{DImage, DisplayList, Kind}; +use egui::{Color32, Mesh, Rect, Shape, TextureHandle, TextureOptions, epaint::Vertex, pos2}; + +use super::Xf; + +/// The longest tile side uploaded, whatever the device allows. +const MAX_TILE_SIDE: usize = 2048; +/// Above this many uploaded texels per image, tiles not drawn this frame are let go. +const MAX_UPLOADED_TEXELS: usize = 64_000_000; +/// The pyramid stops once a level's longer side is at most this. +const SMALLEST_LEVEL: usize = 256; + +/// One level of an image's pyramid. +struct Level { + width: usize, + height: usize, + /// Its pixels; empty for the first level, which is the raster's own. + rgba: Vec, + /// Tiles uploaded so far, by column and row. + tiles: HashMap<(usize, usize), TextureHandle>, +} + +/// The pyramid of one image; it goes when its pixels do. +struct Pyramid { + pixels: Weak, + levels: Vec, +} + +/// Pyramids by the address of the pixels they were made from, kept between frames. Behind a +/// mutex of its own: uploading a texture takes the context's lock, which `Context::data_mut` holds. +#[derive(Clone, Default)] +struct Textures(Arc>>); + +/// `rgba` (`width` x `height`) at half the size, each pixel the average of up to four. +fn halved(rgba: &[u8], width: usize, height: usize) -> Option<(Vec, usize, usize)> { + let (w, h) = (width.div_ceil(2), height.div_ceil(2)); + let mut out = Vec::with_capacity(w.checked_mul(h)?.checked_mul(4)?); + let pixel = |x: usize, y: usize| rgba.get(y.checked_mul(width)?.checked_add(x)?.checked_mul(4)?..)?.first_chunk::<4>(); + for y in 0..h { + let (y0, y1) = (y * 2, (y * 2 + 1).min(height - 1)); + for x in 0..w { + let (x0, x1) = (x * 2, (x * 2 + 1).min(width - 1)); + let (a, b, c, d) = (pixel(x0, y0)?, pixel(x1, y0)?, pixel(x0, y1)?, pixel(x1, y1)?); + for i in 0..4 { + let sum = u16::from(a[i]) + u16::from(b[i]) + u16::from(c[i]) + u16::from(d[i]); + out.push(((sum + 2) / 4) as u8); + } + } + } + Some((out, w, h)) +} + +/// The level to draw at `px_per_unit` device pixels per world unit: the smallest with at least one +/// pixel per device pixel. Level `n` is `2^n` raster pixels to its pixel. +fn level_for(img: &DImage, px_per_unit: f64) -> usize { + let per_device_pixel = 1.0 / (img.right.len().min(img.down.len()) * px_per_unit); + if !(per_device_pixel.is_finite() && per_device_pixel > 1.0) { + return 0; + } + per_device_pixel.log2().floor() as usize +} + +impl Pyramid { + fn new(raster: &Arc) -> Option { + let (width, height) = (usize::try_from(raster.width()).ok()?, usize::try_from(raster.height()).ok()?); + Some(Pyramid { pixels: Arc::downgrade(raster), levels: vec![Level { width, height, rgba: Vec::new(), tiles: HashMap::new() }] }) + } + + /// Make the levels up to `wanted` (fewer for a small image); the index of the last one to use. + fn reach(&mut self, raster: &Raster, wanted: usize) -> usize { + while self.levels.len() <= wanted { + let Some(last) = self.levels.last() else { return 0 }; + if last.width.max(last.height) <= SMALLEST_LEVEL { + break; + } + let rgba = if last.rgba.is_empty() { raster.rgba() } else { &last.rgba }; + let Some((rgba, width, height)) = halved(rgba, last.width, last.height) else { break }; + self.levels.push(Level { width, height, rgba, tiles: HashMap::new() }); + } + wanted.min(self.levels.len().saturating_sub(1)) + } + + fn texels(&self) -> usize { + let tile = |t: &TextureHandle| t.size()[0].saturating_mul(t.size()[1]); + self.levels.iter().flat_map(|l| l.tiles.values()).map(tile).sum() + } +} + +/// The texture of tile (`col`, `row`), `side` pixels on a side at most, of level `l`. +fn tile(ctx: &egui::Context, l: &mut Level, raster: &Raster, side: usize, col: usize, row: usize) -> Option { + if let Some(t) = l.tiles.get(&(col, row)) { + return Some(t.clone()); + } + let rgba = if l.rgba.is_empty() { raster.rgba() } else { &l.rgba }; + let (x, y) = (col.checked_mul(side)?, row.checked_mul(side)?); + let (w, h) = (side.min(l.width.checked_sub(x)?), side.min(l.height.checked_sub(y)?)); + let mut bytes = Vec::with_capacity(w.checked_mul(h)?.checked_mul(4)?); + for r in y..y + h { + let start = r.checked_mul(l.width)?.checked_add(x)?.checked_mul(4)?; + bytes.extend_from_slice(rgba.get(start..start.checked_add(w.checked_mul(4)?)?)?); + } + let name = format!("raster-{:x}-{}x{}-{col}-{row}", std::ptr::from_ref(raster) as usize, l.width, l.height); + let image = egui::ColorImage::from_rgba_unmultiplied([w, h], &bytes); + let texture = ctx.load_texture(name, image, TextureOptions::LINEAR); + l.tiles.insert((col, row), texture.clone()); + Some(texture) +} + +/// `poly` cut to the rectangle `min`..`max` (Sutherland-Hodgman; keeps the order of its points). +fn clip_to_rect(poly: &[Vec2], min: Vec2, max: Vec2) -> Vec { + let mut out = poly.to_vec(); + // Inside is `v * sign >= limit`, for each side in turn. + for (x_axis, sign, limit) in [(true, 1.0, min.x), (true, -1.0, -max.x), (false, 1.0, min.y), (false, -1.0, -max.y)] { + let dist = |q: Vec2| (if x_axis { q.x } else { q.y }) * sign - limit; + let input = std::mem::take(&mut out); + let mut prev = input.last().copied(); + for cur in input { + if let Some(p) = prev { + let (dp, dc) = (dist(p), dist(cur)); + if (dp < 0.0) != (dc < 0.0) { + out.push(p + (cur - p) * (dp / (dp - dc))); + } + } + if dist(cur) >= 0.0 { + out.push(cur); + } + prev = Some(cur); + } + } + out +} + +/// `outline` as a rectangle when it is one with sides along the axes. +fn axis_rect(outline: &[Vec2]) -> Option { + if outline.len() != 4 { + return None; + } + let bounds = Bounds2::from_points(outline.iter().copied()); + // Four points are such a rectangle when each lies on a corner of their bounding box. + let close = (bounds.width() + bounds.height()) * 1e-9; + let on_either = |v: f64, low: f64, high: f64| (v - low).abs() <= close || (v - high).abs() <= close; + let on_a_corner = |q: &Vec2| on_either(q.x, bounds.min.x, bounds.max.x) && on_either(q.y, bounds.min.y, bounds.max.y); + outline.iter().all(on_a_corner).then_some(bounds) +} + +/// Draw the images of `list`. +pub(super) fn draw(p: &egui::Painter, xf: &Xf, list: &DisplayList) { + if list.images.is_empty() { + return; + } + let vis = xf.world_bounds(); + for prim in &list.prims { + if let Kind::Image { index } = prim.kind + && let Some(img) = list.images.get(index as usize) + && Bounds2::from_points(img.corners()).intersects(&vis) + { + draw_one(p, xf, img, prim.alpha()); + } + } +} + +/// Draw one image as textured meshes, one per tile on screen: its rectangle, or the outline it is +/// clipped to. +fn draw_one(p: &egui::Painter, xf: &Xf, img: &DImage, alpha: u8) { + let ctx = p.ctx(); + let raster = &img.raster; + let cut = cut(img, xf, p.clip_rect()); + if !cut.rect.is_positive() { + return; + } + // Where in the raster the triangles are. + let Some(tris) = triangles(img, cut.outline).iter().map(|q| img.pixel_of(*q)).collect::>>() else { return }; + let color = Color32::from_white_alpha((img.opacity(alpha) * 255.0) as u8); + let textures = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("cadcraft-raster-textures")).clone()); + let Ok(mut pyramids) = textures.0.lock() else { return }; + // Pyramids whose pixels are gone (drawing closed, image undone) go too. + pyramids.retain(|_, pyramid| pyramid.pixels.strong_count() > 0); + let key = Arc::as_ptr(raster) as usize; + let pyramid = match pyramids.entry(key) { + Entry::Occupied(e) => e.into_mut(), + Entry::Vacant(e) => { + let Some(new) = Pyramid::new(raster) else { return }; + e.insert(new) + } + }; + let index = pyramid.reach(raster, level_for(img, xf.scale * f64::from(ctx.pixels_per_point()))); + let Some(level) = pyramid.levels.get_mut(index) else { return }; + let side = ctx.input(|i| i.max_texture_side).clamp(1, MAX_TILE_SIDE); + // Raster pixels per pixel of this level (not quite 2^index for an odd size). + let (kx, ky) = (f64::from(raster.width()) / level.width as f64, f64::from(raster.height()) / level.height as f64); + let mut drawn = Vec::new(); + for row in 0..level.height.div_ceil(side) { + for col in 0..level.width.div_ceil(side) { + let (tw, th) = (side.min(level.width - col * side), side.min(level.height - row * side)); + let min = Vec2::new((col * side) as f64 * kx, (row * side) as f64 * ky); + let max = Vec2::new(((col * side + tw) as f64) * kx, ((row * side + th) as f64) * ky); + let corners = [img.at(min.x, min.y), img.at(max.x, min.y), img.at(max.x, max.y), img.at(min.x, max.y)]; + let on_screen = Rect::from_points(&corners.map(|q| xf.to_screen(q))); + if !on_screen.intersects(cut.rect) { + continue; + } + let mut mesh = Mesh::default(); + for t in tris.as_chunks::<3>().0 { + let piece = clip_to_rect(t, min, max); + // A fan over the piece, each triangle laid out in turn. + for pair in piece.get(1..).unwrap_or(&[]).windows(2) { + for q in [piece.first(), pair.first(), pair.last()].into_iter().flatten() { + let at = img.at(q.x, q.y); + let uv = + pos2(((q.x - min.x) / (max.x - min.x)).clamp(0.0, 1.0) as f32, ((q.y - min.y) / (max.y - min.y)).clamp(0.0, 1.0) as f32); + mesh.indices.push(mesh.vertices.len() as u32); + mesh.vertices.push(Vertex { pos: xf.to_screen(at), uv, color }); + } + } + } + if mesh.indices.is_empty() { + continue; + } + let Some(texture) = tile(ctx, level, raster, side, col, row) else { continue }; + mesh.texture_id = texture.id(); + p.with_clip_rect(cut.rect).add(Shape::mesh(mesh)); + drawn.push((col, row)); + } + } + if pyramid.texels() > MAX_UPLOADED_TEXELS { + for (i, l) in pyramid.levels.iter_mut().enumerate() { + l.tiles.retain(|at, _| i == index && drawn.contains(at)); + } + } +} + +/// How an image's clip outlines are applied on the canvas, which can clip a painter to a +/// rectangle but has no general clip. +struct Cut<'a> { + /// The screen rectangle the painter is clipped to: the canvas, narrowed by every outline + /// that is a rectangle along the axes (viewports, an upright image's own boundary). + rect: Rect, + /// The first outline of another shape, which the mesh itself is cut to. Further ones are + /// not applied on screen. + outline: Option<&'a [Vec2]>, +} + +fn cut<'a>(img: &'a DImage, xf: &Xf, canvas: Rect) -> Cut<'a> { + let mut cut = Cut { rect: canvas, outline: None }; + for outline in &img.clips { + match axis_rect(outline) { + Some(b) => cut.rect = cut.rect.intersect(Rect::from_two_pos(xf.to_screen(b.min), xf.to_screen(b.max))), + None if cut.outline.is_none() => cut.outline = Some(outline), + None => {} + } + } + cut +} + +/// World points, three per triangle, covering what is drawn of `img`: the area inside +/// `outline`, or the whole rectangle. +fn triangles(img: &DImage, outline: Option<&[Vec2]>) -> Vec { + match outline { + Some(o) => { + let mut t = cadcraft_render::triangulate_evenodd(&[o.to_vec()]); + t.truncate(t.len() - t.len() % 3); + t + } + None => { + let [a, b, c, d] = img.corners(); + vec![a, b, c, a, c, d] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_doc::{Common, Drawing, EntityKind, Image, Space}; + use cadcraft_geom::Vec3; + use cadcraft_render::{Options, build}; + use egui::epaint::ClippedShape; + + fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) + } + + /// `w` x `h` pixels, each with its column in red and its row in green. + fn raster(w: u32, h: u32) -> Arc { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [x as u8, y as u8, 0, 255])).collect(); + Arc::new(Raster::new(w, h, px).unwrap()) + } + + /// A drawing with a 10 x 10 unit image of 2 x 2 pixels at the origin, changed by `f`. + fn list(f: impl FnOnce(&mut Image)) -> DisplayList { + let mut i = Image { u: Vec3::new(5.0, 0.0, 0.0), v: Vec3::new(0.0, 5.0, 0.0), size: v(2.0, 2.0), path: "a.png".into(), ..Image::default() }; + f(&mut i); + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("a.png", raster(2, 2)); + build(&d, &Space::Model, &Options::default()) + } + + /// A 100 x 100 point canvas showing the 10 x 10 square, and what `draw` painted on it. + fn painted(list: &DisplayList) -> (egui::Context, Vec) { + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(100.0, 100.0)); + let xf = Xf { rect, center: v(5.0, 5.0), scale: 10.0 }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + draw(&painter, &xf, list); + }); + // No renderer takes the uploads in a test. + out.textures_delta.clear(); + let shapes = std::mem::take(&mut out.shapes); + (ctx, shapes) + } + + fn meshes(shapes: &[ClippedShape]) -> Vec<(Rect, &Mesh)> { + shapes.iter().filter_map(|s| if let Shape::Mesh(m) = &s.shape { Some((s.clip_rect, &**m)) } else { None }).collect() + } + + #[test] + fn a_rectangle_along_the_axes_is_recognised() { + assert!(axis_rect(&[v(0.0, 0.0), v(4.0, 0.0), v(4.0, 2.0), v(0.0, 2.0)]).is_some()); + assert!(axis_rect(&[v(4.0, 2.0), v(0.0, 2.0), v(0.0, 0.0), v(4.0, 0.0)]).is_some()); + assert!(axis_rect(&[v(0.0, 0.0), v(4.0, 1.0), v(4.0, 2.0), v(0.0, 2.0)]).is_none(), "a slanted side"); + assert!(axis_rect(&[v(0.0, 0.0), v(4.0, 0.0), v(0.0, 2.0)]).is_none(), "a triangle"); + assert!(axis_rect(&[]).is_none()); + assert!(axis_rect(&[v(f64::NAN, 0.0), v(1.0, 0.0), v(1.0, 1.0), v(0.0, 1.0)]).is_none()); + } + + #[test] + fn each_level_is_half_the_size_and_averages_its_pixels() { + let r = raster(4, 3); + let (half, w, h) = halved(r.rgba(), 4, 3).unwrap(); + assert_eq!((w, h), (2, 2)); + assert_eq!(half.len(), 16); + // Columns 0 and 1 of rows 0 and 1: red 0.5 rounds up, green likewise. + assert_eq!(&half[..4], &[1, 1, 0, 255]); + // The odd last row counts twice in the bottom-right pixel: columns 2 and 3 of row 2. + assert_eq!(&half[12..16], &[3, 2, 0, 255]); + } + + #[test] + fn the_level_follows_the_zoom() { + let img = |px: f64| DImage { raster: raster(1, 1), origin: v(0.0, 0.0), right: v(px, 0.0), down: v(0.0, -px), clips: Vec::new(), fade: 0 }; + // One raster pixel to a screen pixel or larger: all of them. + assert_eq!(level_for(&img(1.0), 1.0), 0); + assert_eq!(level_for(&img(1.0), 5.0), 0); + // Eight raster pixels to a screen pixel. + assert_eq!(level_for(&img(1.0), 0.125), 3); + assert_eq!(level_for(&img(1.0), 0.2), 2); + // Squashed flat: never a number that means anything. + assert_eq!(level_for(&img(0.0), 1.0), 0); + } + + #[test] + fn clipping_keeps_the_points_inside_and_cuts_the_rest() { + let tri = [v(0.0, 0.0), v(10.0, 0.0), v(0.0, 10.0)]; + assert_eq!(clip_to_rect(&tri, v(-1.0, -1.0), v(11.0, 11.0)), tri, "all inside, in order"); + assert!(clip_to_rect(&tri, v(20.0, 20.0), v(30.0, 30.0)).is_empty()); + let cut = clip_to_rect(&tri, v(0.0, 0.0), v(5.0, 10.0)); + assert_eq!(cut.len(), 4); + assert!(cut.iter().all(|q| q.x <= 5.0)); + } + + #[test] + fn an_image_larger_than_a_tile_is_cut_into_tiles_that_meet() { + let side = 4096; + let d = { + let mut d = Drawing::new_imperial(); + let i = Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: v(side as f64, 1.0), + path: "a.png".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("a.png", Arc::new(Raster::new(side, 1, vec![255; side as usize * 4]).unwrap())); + d + }; + let l = build(&d, &Space::Model, &Options::default()); + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(8192.0, 100.0)); + let xf = Xf { rect, center: v(side as f64 / 2.0, 0.5), scale: 2.0 }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(1 << 14), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + draw(&painter, &xf, &l); + }); + out.textures_delta.clear(); + let m = meshes(&out.shapes); + assert_eq!(m.len(), 2, "two tiles of {MAX_TILE_SIDE} pixels"); + let edge = |m: &Mesh, right: bool| { + m.vertices.iter().map(|q| q.pos.x).fold(if right { f32::MIN } else { f32::MAX }, if right { f32::max } else { f32::min }) + }; + assert_eq!(edge(m[0].1, true), edge(m[1].1, false), "no gap between them"); + assert_ne!(m[0].1.texture_id, m[1].1.texture_id); + } + + #[test] + fn an_image_is_one_textured_rectangle() { + let l = list(|_| {}); + let (_ctx, shapes) = painted(&l); + let m = meshes(&shapes); + assert_eq!(m.len(), 1); + let (_, mesh) = m[0]; + assert_ne!(mesh.texture_id, egui::TextureId::default()); + assert_eq!(mesh.indices, [0, 1, 2, 3, 4, 5], "two triangles"); + // On screen and in the texture: top left, top right, bottom right, then top left, bottom + // right, bottom left of the picture. + let (tl, tr, br, bl) = (pos2(0.0, 0.0), pos2(100.0, 0.0), pos2(100.0, 100.0), pos2(0.0, 100.0)); + let uv = |p: egui::Pos2| pos2(p.x / 100.0, p.y / 100.0); + let got: Vec<(egui::Pos2, egui::Pos2)> = mesh.vertices.iter().map(|v| (v.pos, v.uv)).collect(); + assert_eq!(got, [tl, tr, br, tl, br, bl].map(|p| (p, uv(p)))); + assert!(mesh.vertices.iter().all(|v| v.color == Color32::WHITE)); + } + + #[test] + fn fade_dims_the_image_and_nothing_is_drawn_when_fully_faded_or_out_of_view() { + let (_c, shapes) = painted(&list(|i| i.fade = 50)); + assert_eq!(meshes(&shapes)[0].1.vertices[0].color, Color32::from_white_alpha(127)); + // Far from the view: skipped. + let (_c, shapes) = painted(&list(|i| i.insert = Vec3::new(1000.0, 1000.0, 0.0))); + assert!(meshes(&shapes).is_empty()); + // No images at all. + let (_c, shapes) = painted(&DisplayList::default()); + assert!(shapes.is_empty()); + } + + #[test] + fn an_upright_clip_boundary_clips_the_painter_and_another_shapes_the_mesh() { + // The left pixel column: a rectangle along the axes. + let (_c, shapes) = painted(&list(|i| { + i.clip = vec![v(-0.5, -0.5), v(0.5, 1.5)]; + i.clipping = true; + })); + let m = meshes(&shapes); + assert_eq!(m.len(), 1); + assert_eq!(m[0].0, Rect::from_min_max(pos2(0.0, 0.0), pos2(50.0, 100.0))); + assert_eq!(m[0].1.vertices.len(), 6, "the whole rectangle, cut by the clip"); + + // A triangle over the top-left half: the mesh is the triangle, with matching texture points. + let (_c, shapes) = painted(&list(|i| { + i.clip = vec![v(-0.5, -0.5), v(1.5, -0.5), v(-0.5, 1.5)]; + i.clipping = true; + })); + let m = meshes(&shapes); + let mesh = m[0].1; + assert!(mesh.vertices.len() >= 3 && mesh.indices.len().is_multiple_of(3) && mesh.indices.len() == mesh.vertices.len()); + for vert in &mesh.vertices { + assert!((vert.pos.x / 100.0 - vert.uv.x).abs() < 1e-4 && (vert.pos.y / 100.0 - vert.uv.y).abs() < 1e-4, "{vert:?}"); + assert!(vert.pos.x + vert.pos.y <= 100.0 + 1e-3, "inside the triangle: {vert:?}"); + } + } + + #[test] + fn a_tile_is_uploaded_once_and_dropped_with_its_pixels() { + let r = raster(4, 4); + let mut l = list(|_| {}); + // The list's pixels are this test's. + l.images[0].raster = r.clone(); + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(100.0, 100.0)); + let xf = Xf { rect, center: v(5.0, 5.0), scale: 10.0 }; + let frame = |list: &DisplayList| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + draw(&painter, &xf, list); + }); + let uploads = out.textures_delta.set.len(); + out.textures_delta.clear(); + uploads + }; + assert!(frame(&l) >= 1, "the first frame also uploads the font atlas"); + assert_eq!(frame(&l), 0, "already uploaded"); + // The pixels gone (drawing closed, image undone): the pyramid goes on the next draw. + let count = |ctx: &egui::Context| { + let t = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("cadcraft-raster-textures")).clone()); + t.0.lock().unwrap().len() + }; + assert_eq!(count(&ctx), 1); + drop(l); + drop(r); + frame(&list(|_| {})); + assert_eq!(count(&ctx), 1, "only the new image's"); + } + + /// A known limit, kept visible here: on screen images are painted first, so they lie under + /// lines drawn before them. (Pictures and plots keep the draw order; see the renderer's + /// `draw_order_is_kept_in_pictures`.) When the canvas learns to interleave them, this test + /// changes with it. + #[test] + fn on_screen_images_lie_under_the_lines_whatever_the_draw_order() { + use cadcraft_doc::Line; + let line = EntityKind::Line(Line { a: Vec3::new(0.0, 5.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) }); + let image = EntityKind::Image(Image { + u: Vec3::new(5.0, 0.0, 0.0), + v: Vec3::new(0.0, 5.0, 0.0), + size: v(2.0, 2.0), + path: "a.png".into(), + ..Image::default() + }); + for order in [[line.clone(), image.clone()], [image, line]] { + let mut d = Drawing::new_imperial(); + for k in order { + d.add(&Space::Model, Common::default(), k).unwrap(); + } + d.rasters.insert("a.png", raster(2, 2)); + let l = build(&d, &Space::Model, &Options::default()); + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(100.0, 100.0)); + let xf = Xf { rect, center: v(5.0, 5.0), scale: 10.0 }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + crate::canvas::draw_list(&painter, &xf, &l, cadcraft_color::Rgb(0, 0, 0), false); + }); + out.textures_delta.clear(); + let kinds: Vec = out.shapes.iter().map(|s| matches!(s.shape, Shape::Mesh(_))).collect(); + assert!(kinds.len() >= 2, "the image and the lines: {kinds:?}"); + assert_eq!(kinds.iter().position(|m| *m), Some(0), "the image comes first: {kinds:?}"); + assert_eq!(kinds.iter().filter(|m| **m).count(), 1); + } + } + + #[test] + fn degenerate_images_do_not_panic() { + for f in [ + (|i: &mut Image| i.u = Vec3::ZERO) as fn(&mut Image), + |i| i.v = i.u, + |i| i.u = Vec3::new(1e300, 0.0, 0.0), + |i| i.insert = Vec3::new(-1e300, 1e300, 0.0), + |i| { + i.clip = vec![v(0.0, 0.0), v(0.0, 0.0), v(0.0, 0.0)]; + i.clipping = true; + }, + |i| { + i.clip = vec![v(f64::NAN, 0.0), v(1.0, f64::INFINITY), v(-1e300, 1e300)]; + i.clipping = true; + }, + ] { + let _ = painted(&list(f)); + } + } +} diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs index c79354b6c..d273af4b6 100644 --- a/crates/ui-egui/src/canvas/viewport.rs +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -162,6 +162,9 @@ pub(super) fn draw_vp_content(c: &mut VpContent, gpu: bool, p: &egui::Painter, f draw_list(p, xf, &c.list, bg, lwdisplay); return; } + // Images first, under the lines, as in `draw_list_gpu` (`p` clips them to the viewport's + // rectangle; a clipped viewport's outline masks the GPU geometry only). + super::images::draw(p, xf, &c.list); let mut stencil = crate::gpu::Stencil::Off; if let Some(outline) = &f.outline { draw_vp_mask(c, p, outline, xf_paper); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 30f3b0a85..095c2d192 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -232,6 +232,8 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> } // Drawn on the CPU each frame (clipped to the view). Kind::Infinite { .. } => {} + // Drawn by the canvas as a textured mesh. + Kind::Image { .. } => {} } } m @@ -281,6 +283,13 @@ pub fn build_highlight_mesh(list: &DisplayList, prims: &[usize], origin: Vec2, c } } Kind::Infinite { .. } => {} + Kind::Image { .. } => { + let mut run = 0.0; + for (a, b) in pts.iter().zip(pts.iter().cycle().skip(1)) { + push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, width, run as f32); + run += a.dist(*b); + } + } } } m diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3f2de02df..df13e48c8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -198,6 +198,8 @@ Cylinder Циліндр DWG DWG DWG Reference... Посилання на DWG... + Raster Image Reference... Посилання на растрове зображення... + Raster Images Растрові зображення DXF DXF Dark Темна Data Extraction... Вилучення даних... diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d2af85803..a9f74a6fd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (attribute value edits refresh exported baselines) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (raster images are drawn on screen) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -86,7 +86,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | SOLID, TRACE, 3DFACE | yes | yes | | | ACAD_TABLE | yes | yes (entity + block) | | | VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) | -| IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | no attach command or display of the picture yet (FF10) | +| IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | the picture is loaded from its file and drawn (#1756); brightness and contrast are kept but not applied (FF10) | | WIPEOUT | yes | yes (#147, fixes #79) | Implicit or explicit closure in memory; explicit closing point on save. Same logical grips and edit results before/after DXF/DWG round trips (#2256). | | MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | | | REGION | other programs' REGIONs (ACIS data) kept as Unknown | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | | @@ -112,7 +112,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | | PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 | | PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | -| Raster images attach (PNG, JPEG, TIFF, BMP …) | yes | IMAGE entity exists, no attach/loading/display path | 6–10 | +| Raster images attach (PNG, JPEG, TIFF, BMP …) | yes | **partial** (#956): IMAGEATTACH (JSON form) reads PNG, JPEG, BMP and TIFF; drawn on screen and in PNG output with rotation, clip boundary and fade; reloaded on open, a missing file stays a crossed-out frame. Not yet: PDF and SVG output of the picture, prompts and dialog, detach/reload/unload, IMAGECLIP, IMAGEADJUST, images stored in the drawing | 14–22 | | PNG / JPEG / BMP / TIFF export | PNGOUT, JPGOUT, BMPOUT, TIFOUT | PNG; SVG too (AutoCAD doesn't export SVG) | 1–2 | | DWF/DWFx export, DWF underlay | yes | no | 10–15 | | DGN import/export/underlay | yes | no (needs a public spec; owner decision) | 25–40 | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Raster images: the picture is loaded from its file when a drawing opens and drawn on screen and in PNG output (#1756, viewport part; based on #1029) | | 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | diff --git a/docs/gaps.md b/docs/gaps.md index b270525d6..4de1e7c7b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (BEDIT in the Block Editor switches blocks: F3 updated) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (raster images are drawn on screen: FF10 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -82,7 +82,7 @@ Detail: [file-format-parity.md](file-format-parity.md). | FF7 | Table record and object handles from other programs' files are renumbered on the first save (entity handles are kept; `*D` blocks no longer duplicate and once reopened, CADCraft's own files keep every handle across saves, #495) | Low | 4–6 | | FF8 | SHX fonts (shape and big fonts) not read | Medium-high (text metrics differ) | 8–12 | | FF9 | `.pc3` plotter configuration files; linetype shape (SHX) elements are skipped (templates and `.dwt`, user `.lin`/`.pat` and `.pgp` alias files landed, #552; `.ctb`/`.stb`, #410) | Low-medium | 5–10 | -| FF10 | PDF import and underlays (PDFATTACH); raster image attach (IMAGEATTACH). PDF export has real text in embedded TrueType subsets, PDF layers, several sheets per file and hyperlinks (#567, #571) | Medium | 20–35 | +| FF10 | PDF import and underlays (PDFATTACH). Raster images (#956): IMAGEATTACH from JSON reads PNG, JPEG, BMP and TIFF, the picture is drawn on screen and in PNG exports and plot previews (rotation, clip boundary, fade), sharp at any zoom whatever its size (a pyramid of levels cut into tiles, uploaded as they come into view), and reloaded when the drawing opens; left: PDF and SVG output write only the frame, no prompts, dialog or menu row, no -IMAGE options (detach, reload, unload, path), IMAGECLIP, IMAGEADJUST or IMAGEFRAME, brightness and contrast not applied, images lie under all other objects on screen whatever their draw order, size not taken from the file's resolution, images can't be stored in the drawing. PDF export has real text in embedded TrueType subsets, PDF layers, several sheets per file and hyperlinks (#567, #571) | Medium | 24–40 | | FF11 | DWF/DWFx, DGN, WMF, EPS, ACIS/STL and other exchange formats | Low-medium | 50–100 | | FF12 | DWG in the web build | Medium | 6–10 | | FF13 | R12 binary DXF, pre-R13 DWG | Low | 10–20 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Raster images are drawn on screen and in PNG output, large ones in tiles at the level the zoom needs (#1756, viewport part; based on #1029): FF10 narrowed | | 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-26 | minor | BEDIT in the Block Editor (typed, Edit Block, or the Edit Block Definition dialog) switches to another block as AutoCAD's does, asking whether to save the changes not saved yet; the JSON form takes `save`: F3 evidence | From f1312113d286fa6cefbf54fae8fd5bf07dd4e5c1 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 15:01:55 -0700 Subject: [PATCH 2/6] Images: format after the merge with main --- crates/io/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6060af741..c44ab3a42 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -14,10 +14,10 @@ mod dxf_write; mod dxf_xref; pub mod pdf; pub mod plotter; -/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5). -pub mod roundtrip; /// Raster image files (PNG, JPEG, BMP, TIFF) decoded to pixels. pub mod raster; +/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5). +pub mod roundtrip; pub mod svg; mod svg_read; From 238aa1789c0f98dcdb5e4dcc128869d7f3707cd8 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 15:03:06 -0700 Subject: [PATCH 3/6] Images: draw an IMAGE in its own function, under the complexity limit --- crates/render/src/draw.rs | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index b6645a6a9..1d2d69cb9 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -193,22 +193,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } EntityKind::Wipeout(w) => draw_wipeout(b, ctx, w, rgb, lw), - EntityKind::Image(i) => { - let o = i.insert.xy(); - let u = i.u.xy() * i.size.x; - let v = i.v.xy() * i.size.y; - // Display flag 1: show the image. Without pixels (file not found, not loaded yet, - // hidden) the rectangle is crossed out. - let raster = if i.display & 1 != 0 { ctx.d.rasters.get(&i.path) } else { None }; - if let Some(r) = raster { - b.image(ctx, rgb, i, r); - } - b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); - if raster.is_none() { - b.polyline(ctx, rgb, lw, &[o, o + u + v]); - b.polyline(ctx, rgb, lw, &[o + u, o + v]); - } - } + EntityKind::Image(i) => draw_image(b, ctx, i, rgb, lw), EntityKind::Table(t) => table(b, ctx, t, rgb, lw), EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol), EntityKind::Leader(l) => { @@ -297,6 +282,23 @@ fn draw_mleader(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &cadcraft_doc::MLeade } } +fn draw_image(b: &mut Builder, ctx: &Ctx, i: &cadcraft_doc::Image, rgb: Ink, lw: f32) { + let o = i.insert.xy(); + let u = i.u.xy() * i.size.x; + let v = i.v.xy() * i.size.y; + // Display flag 1: show the image. Without pixels (file not found, not loaded yet, hidden) + // the rectangle is crossed out. + let raster = if i.display & 1 != 0 { ctx.d.rasters.get(&i.path) } else { None }; + if let Some(r) = raster { + b.image(ctx, rgb, i, r); + } + b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); + if raster.is_none() { + b.polyline(ctx, rgb, lw, &[o, o + u + v]); + b.polyline(ctx, rgb, lw, &[o + u, o + v]); + } +} + fn draw_wipeout(b: &mut Builder, ctx: &Ctx, w: &cadcraft_doc::Wipeout, rgb: Ink, lw: f32) { // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. From d3226e629e28d5a2a7a4451aa576be066cf8a847 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 15:04:12 -0700 Subject: [PATCH 4/6] Images: the highlight outline uses main's world-point line helper --- crates/ui-egui/src/canvas/draw.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index f8b6adf8a..8ac5745da 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -385,7 +385,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri line(&outline, &mut shapes); } } - Kind::Image { .. } => line(pts.iter().chain(pts.first()).map(|q| xf.to_screen(*q)).collect(), &mut shapes), + Kind::Image { .. } => line(&pts.iter().chain(pts.first()).copied().collect::>(), &mut shapes), } } if !fill.is_empty() { From 15afc4729625bc032ed155563138c8d8686f02e7 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 15:12:28 -0700 Subject: [PATCH 5/6] Images: Chinese labels for IMAGEATTACH and IMAGE --- crates/ui-egui/src/i18n/zh.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index eae92cc14..f0564315c 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -593,6 +593,8 @@ Quit CADCraft 退出 CADCraft Radius 半径 Raster Image 光栅图像 + Raster Image Reference... 光栅图像参照... + Raster Images 光栅图像 Ray 射线 Read-only 只读 Real name 真实姓名 From f10e41409ddde31adf4e01111478ff44aaaed4ca Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 16:52:11 -0700 Subject: [PATCH 6/6] Images: read no more than the limit, keep plot bounds to what a viewport shows, free textures with no images left Review fixes from CodeRabbit on #2749: - the image file is read through read_capped, so a file that grows after the size check can't exhaust memory - an image cut by a layout viewport widens the sheet's bounds only by the part inside it, as other primitives do - uploaded tiles of closed drawings are dropped also when the next list has no images --- crates/engine/src/cmd/image.rs | 6 +++++- crates/render/src/viewports.rs | 20 ++++++++++++-------- crates/render/tests/raster_image.rs | 3 +++ crates/ui-egui/src/canvas/images.rs | 26 ++++++++++++++++++-------- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index 240664891..daf46557b 100644 --- a/crates/engine/src/cmd/image.rs +++ b/crates/engine/src/cmd/image.rs @@ -79,7 +79,11 @@ fn read_file(path: &str, host: Option<&str>) -> std::result::Result, Str if len > MAX_IMAGE_BYTES { return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)); } - std::fs::read(&file).map_err(|e| e.to_string()) + // Read no more than the limit: the file can grow or change after the check above. + let limit = usize::try_from(MAX_IMAGE_BYTES).unwrap_or(usize::MAX); + crate::capped_read::read_capped(&file, limit).map_err(|e| { + if e.kind() == std::io::ErrorKind::InvalidData { format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20) } else { e.to_string() } + }) } #[cfg(target_arch = "wasm32")] { diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6b501e840..f6e8c8cb0 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -255,20 +255,24 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { /// Append image `img` (primitive `p`, corners `corners`) to `dst`, to be cut to `clip` when it /// is drawn: the picture is not cut here, the outline is added to those it carries. fn push_clipped_image(dst: &mut DisplayList, p: &DPrim, img: &DImage, corners: &[Vec2], clip: &Clip) { - let outline = match clip { - Clip::Rect(rect) => { - if !Bounds2::from_points(corners.iter().copied()).intersects(rect) { - return; - } - vec![rect.min, Vec2::new(rect.max.x, rect.min.y), rect.max, Vec2::new(rect.min.x, rect.max.y)] - } - Clip::Poly(c) => c.polygon().to_vec(), + let (outline, area) = match clip { + Clip::Rect(rect) => (vec![rect.min, Vec2::new(rect.max.x, rect.min.y), rect.max, Vec2::new(rect.min.x, rect.max.y)], *rect), + Clip::Poly(c) => (c.polygon().to_vec(), c.bounds()), }; + let own = Bounds2::from_points(corners.iter().copied()); + if !own.intersects(&area) { + return; + } let Ok(index) = u32::try_from(dst.images.len()) else { return }; let mut img = img.clone(); img.clips.push(outline); dst.images.push(img); + // The list's bounds take only the part inside the clip, as for the other primitives. + let before = dst.bounds; push_raw(dst, p, Kind::Image { index }, corners); + dst.bounds = before; + dst.bounds.add(own.min.max(area.min)); + dst.bounds.add(own.max.min(area.max)); } fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { diff --git a/crates/render/tests/raster_image.rs b/crates/render/tests/raster_image.rs index 8cc2d09e8..bdf03dd44 100644 --- a/crates/render/tests/raster_image.rs +++ b/crates/render/tests/raster_image.rs @@ -269,6 +269,9 @@ fn a_layout_viewport_cuts_the_image_to_its_outline() { assert_eq!(img.clips[1], [Vec2::new(15.0, 10.0), Vec2::new(105.0, 10.0), Vec2::new(105.0, 110.0), Vec2::new(15.0, 110.0)]); let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { index: 0 })).unwrap(); assert_eq!(list.points(p).len(), 4); + // The image (10..110 across) is wider than the viewport (15..105): only what shows counts + // towards the sheet's bounds, which a plot fits to. + assert!(list.bounds.min.x >= 15.0 - 1e-9 && list.bounds.max.x <= 105.0 + 1e-9, "{:?}", list.bounds); // Drawn: inside the viewport and the image's top row there is the picture; outside the // viewport, or in the clipped-away bottom row, there is none. diff --git a/crates/ui-egui/src/canvas/images.rs b/crates/ui-egui/src/canvas/images.rs index 4818c079d..afde7bee3 100644 --- a/crates/ui-egui/src/canvas/images.rs +++ b/crates/ui-egui/src/canvas/images.rs @@ -158,6 +158,11 @@ fn axis_rect(outline: &[Vec2]) -> Option { /// Draw the images of `list`. pub(super) fn draw(p: &egui::Painter, xf: &Xf, list: &DisplayList) { + // Pyramids whose pixels are gone (drawing closed, image undone) go, also when the list has + // no images left to draw. + if let Ok(mut pyramids) = textures(p.ctx()).0.lock() { + pyramids.retain(|_, pyramid| pyramid.pixels.strong_count() > 0); + } if list.images.is_empty() { return; } @@ -172,6 +177,11 @@ pub(super) fn draw(p: &egui::Painter, xf: &Xf, list: &DisplayList) { } } +/// The image tiles uploaded so far, kept between frames. +fn textures(ctx: &egui::Context) -> Textures { + ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("cadcraft-raster-textures")).clone()) +} + /// Draw one image as textured meshes, one per tile on screen: its rectangle, or the outline it is /// clipped to. fn draw_one(p: &egui::Painter, xf: &Xf, img: &DImage, alpha: u8) { @@ -184,10 +194,8 @@ fn draw_one(p: &egui::Painter, xf: &Xf, img: &DImage, alpha: u8) { // Where in the raster the triangles are. let Some(tris) = triangles(img, cut.outline).iter().map(|q| img.pixel_of(*q)).collect::>>() else { return }; let color = Color32::from_white_alpha((img.opacity(alpha) * 255.0) as u8); - let textures = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("cadcraft-raster-textures")).clone()); + let textures = textures(ctx); let Ok(mut pyramids) = textures.0.lock() else { return }; - // Pyramids whose pixels are gone (drawing closed, image undone) go too. - pyramids.retain(|_, pyramid| pyramid.pixels.strong_count() > 0); let key = Arc::as_ptr(raster) as usize; let pyramid = match pyramids.entry(key) { Entry::Occupied(e) => e.into_mut(), @@ -484,15 +492,17 @@ mod tests { assert!(frame(&l) >= 1, "the first frame also uploads the font atlas"); assert_eq!(frame(&l), 0, "already uploaded"); // The pixels gone (drawing closed, image undone): the pyramid goes on the next draw. - let count = |ctx: &egui::Context| { - let t = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("cadcraft-raster-textures")).clone()); - t.0.lock().unwrap().len() - }; + let count = |ctx: &egui::Context| textures(ctx).0.lock().unwrap().len(); assert_eq!(count(&ctx), 1); drop(l); drop(r); - frame(&list(|_| {})); + let next = list(|_| {}); + frame(&next); assert_eq!(count(&ctx), 1, "only the new image's"); + // A list with no images still lets go of the pictures that are gone. + drop(next); + frame(&DisplayList::default()); + assert_eq!(count(&ctx), 0); } /// A known limit, kept visible here: on screen images are painted first, so they lie under