Keep fill-image display GPU tier lazy for deep zoom

Store encoded media in RAM, upload a shape-sized display texture
eagerly, and only promote the native full texture when zoom needs
it so oversized fillImages do not saturate integrated GPUs.
This commit is contained in:
Alejandro Alonso 2026-08-13 09:17:11 +02:00
parent 0d8981f0ce
commit f8825af25e
6 changed files with 494 additions and 116 deletions

View File

@ -874,30 +874,18 @@
nil)
(defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
[texture]
(let [gl-obj (unchecked-get wasm/internal-module "GL")
textures (.-textures ^js gl-obj)
new-id (.getNewId ^js gl-obj textures)]
(aset textures new-id texture)
new-id))
(defn- svg-blob?
[^js blob]
(str/starts-with? (.-type blob) "image/svg"))
(defn- store-svg-image
"Sends raw SVG bytes to WASM so Skia parses and rasterizes them there.
Browsers reject SVG blobs in `createImageBitmap`, so SVGs skip the
shared-texture path."
(defn- store-image-bytes
"Sends encoded image bytes to WASM. Rust keeps them in RAM and builds the
GPU display tier (shape @ 100% zoom); full native stays lazy for deep zoom.
Used for both SVG and raster fills so we never upload a full-res GL texture
from JS."
[shape-id image-id thumbnail? ^js blob]
(-> (.arrayBuffer blob)
(p/then
(fn [buffer]
(let [image-bytes (js/Uint8Array. buffer)
;; Header: 16 bytes shape uuid + 16 bytes image uuid
;; + 4 bytes thumbnail flag, then the raw SVG payload.
;; + 4 bytes thumbnail flag, then the encoded payload.
offset (mem/alloc (+ 36 (.-byteLength image-bytes)))
heap (mem/get-heap-u8)
dview (mem/get-data-view)]
@ -909,46 +897,9 @@
(h/call wasm/internal-module "_store_image")
true)))))
(defn- store-image-texture
"Creates a WebGL texture from a decoded image and passes the texture ID to
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
[shape-id image-id thumbnail? img]
(when-let [gl (webgl/get-webgl-context)]
(let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img)
height (.-height ^js img)
;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail)
;; + 4 bytes (texture ID) + 8 bytes (dimensions)
total-bytes 48
offset (mem/alloc->offset-32 total-bytes)
heap32 (mem/get-heap-u32)]
;; 1. Set shape id (offset + 0 to offset + 3)
(mem.h32/write-uuid offset heap32 shape-id)
;; 2. Set image id (offset + 4 to offset + 7)
(mem.h32/write-uuid (+ offset 4) heap32 image-id)
;; 3. Set thumbnail flag as u32 (offset + 8)
(aset heap32 (+ offset 8) (if thumbnail? 1 0))
;; 4. Set texture ID (offset + 9)
(aset heap32 (+ offset 9) texture-id)
;; 5. Set width (offset + 10)
(aset heap32 (+ offset 10) width)
;; 6. Set height (offset + 11)
(aset heap32 (+ offset 11) height)
(h/call wasm/internal-module "_store_image_from_texture")
true)))
(defn- fetch-image
"Loads an image and hands it to WASM. Raster images are decoded by the
browser and shared as a WebGL texture; SVG images are sent as raw bytes
so Skia rasterizes them."
"Loads an image and hands encoded bytes to WASM (`_store_image`). LOD /
display-vs-full GPU tiers are handled entirely in Rust."
[shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
{:key url
@ -959,11 +910,7 @@
(p/then (fn [^js response] (.blob response)))))
(rx/mapcat
(fn [^js blob]
(rx/from
(if (svg-blob? blob)
(store-svg-image shape-id image-id thumbnail? blob)
(p/then (js/createImageBitmap blob)
(partial store-image-texture shape-id image-id thumbnail?))))))
(rx/from (store-image-bytes shape-id image-id thumbnail? blob))))
(rx/catch
(fn [cause]
(log/error :hint "Could not fetch image"
@ -1005,7 +952,7 @@
(defn set-shape-fills
[shape-id fills thumbnail?]
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
;; browser-only (bytes → Rust ImageStore).
(when-let [fills (props/write-shape-fills! fills)]
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
@ -1022,7 +969,7 @@
(defn set-shape-strokes
[shape-id strokes thumbnail?]
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
;; browser-only (bytes → Rust ImageStore).
(keep (fn [image-id]
(let [buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"

View File

@ -4,7 +4,7 @@ pub mod filters;
mod fonts;
pub mod gpu_state;
pub mod grid_layout;
mod images;
pub mod images;
mod options;
pub mod pdf;
pub mod raster;

View File

@ -88,7 +88,15 @@ fn draw_image_fill(
return;
}
let Some(image) = get_resources().images.get(&image_fill.id()) else {
let view_scale = render_state.get_view_scale();
let dpr = render_state.options.dpr.max(1.0);
let display_side = super::images::shape_side_px(&shape.selrect, dpr);
let needed_side = super::images::shape_side_px(&shape.selrect, view_scale);
let Some(image) = get_resources().images.get_for_draw(
&image_fill.id(),
display_side,
needed_side,
) else {
return;
};

View File

@ -55,8 +55,44 @@ pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill
MathRect::from_xywh(source_x, source_y, source_width, source_height)
}
/// Longest design-space side of a shape, in device pixels at the given scale
/// (`zoom × dpr` for needed; `dpr` alone for the 100%-zoom display tier).
pub fn shape_side_px(selrect: &MathRect, scale: f32) -> i32 {
let side = selrect.width().max(selrect.height()).max(1.0);
(side * scale.max(1e-6)).ceil().max(1.0) as i32
}
fn rgba_bytes(image: &Image) -> usize {
let d = image.dimensions();
(d.width.max(0) as usize) * (d.height.max(0) as usize) * 4
}
fn fit_size(native: ISize, max_side: i32) -> ISize {
let longest = native.width.max(native.height).max(1);
if max_side <= 0 || longest <= max_side {
return native;
}
let scale = max_side as f32 / longest as f32;
ISize::new(
(native.width as f32 * scale).round().max(1.0) as i32,
(native.height as f32 * scale).round().max(1.0) as i32,
)
}
/// Encoded raster kept in RAM, with optional GPU tiers:
/// - `display`: shape @ 100% zoom (eager, small)
/// - `full`: native resolution (lazy, for deep zoom)
struct RasterStored {
raw: Vec<u8>,
native: ISize,
display: Option<Image>,
display_side: i32,
full: Option<Image>,
}
enum StoredImage {
Raw(Vec<u8>),
Raster(RasterStored),
/// Legacy / thumbnail path: a single GPU texture (often from a shared GL tex).
Gpu(Image),
Svg {
dom: skia::svg::Dom,
@ -65,12 +101,13 @@ enum StoredImage {
// exports). The shape fill path draws the DOM directly instead.
raster: Option<Image>,
},
/// Encoded bytes not yet classified (decode failed at add time).
Raw(Vec<u8>),
}
struct StoredEntry {
image: StoredImage,
/// Approximate retained cost: encoded byte length (raw/svg) or the
/// decoded RGBA size for images registered from a GL texture.
/// Approximate retained cost: encoded bytes + resident GPU RGBA.
bytes: usize,
/// LRU tick; `Cell` so read paths can touch it without `&mut self`.
last_used: Cell<u64>,
@ -128,19 +165,36 @@ fn create_image_from_gl_texture(
Ok(image)
}
// Decode and upload to GPU
fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Image> {
fn codec_native_size(raw_data: &[u8]) -> Option<ISize> {
let data = unsafe { skia::Data::new_bytes(raw_data) };
let codec = Codec::from_data(&data)?;
let image = Image::from_encoded(&data)?;
let mut dimensions = codec.dimensions();
if codec.origin().swaps_width_height() {
dimensions.width = codec.dimensions().height;
dimensions.height = codec.dimensions().width;
}
Some(dimensions)
}
let image_info = skia::ImageInfo::new_n32_premul(dimensions, None);
/// Decode `raw_data` and upload a GPU texture whose longest side is at most
/// `max_side` (or native size when `max_side` is 0 / larger than native).
fn decode_image_to_max_side(
context: &mut Box<DirectContext>,
raw_data: &[u8],
max_side: i32,
) -> Option<(Image, ISize)> {
let data = unsafe { skia::Data::new_bytes(raw_data) };
let codec = Codec::from_data(&data)?;
let encoded = Image::from_encoded(&data)?;
let mut native = codec.dimensions();
if codec.origin().swaps_width_height() {
native.width = codec.dimensions().height;
native.height = codec.dimensions().width;
}
let dst = fit_size(native, max_side);
let image_info = skia::ImageInfo::new_n32_premul(dst, None);
let mut surface = surfaces::render_target(
context,
@ -154,13 +208,29 @@ fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Ima
)?;
let dest_rect: MathRect =
MathRect::from_xywh(0.0, 0.0, dimensions.width as f32, dimensions.height as f32);
MathRect::from_xywh(0.0, 0.0, dst.width as f32, dst.height as f32);
surface
.canvas()
.draw_image_rect(&image, None, dest_rect, &skia::Paint::default());
.draw_image_rect(&encoded, None, dest_rect, &skia::Paint::default());
Some(surface.image_snapshot())
Some((surface.image_snapshot(), native))
}
// Decode and upload to GPU at native size.
fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Image> {
decode_image_to_max_side(context, raw_data, 0).map(|(img, _)| img)
}
fn raster_retained_bytes(raster: &RasterStored) -> usize {
let mut bytes = raster.raw.len();
if let Some(ref img) = raster.display {
bytes += rgba_bytes(img);
}
if let Some(ref img) = raster.full {
bytes += rgba_bytes(img);
}
bytes
}
// Size for SVGs without intrinsic dimensions nor a viewBox.
@ -262,6 +332,22 @@ impl ImageStore {
);
}
fn recompute_entry_bytes(&mut self, key: (Uuid, bool)) {
if let Some(entry) = self.images.get_mut(&key) {
let new_bytes = match &entry.image {
StoredImage::Raster(r) => raster_retained_bytes(r),
StoredImage::Gpu(img) => rgba_bytes(img),
StoredImage::Svg { .. } => entry.bytes, // keep prior encoded estimate
StoredImage::Raw(raw) => raw.len(),
};
self.total_bytes = self
.total_bytes
.saturating_sub(entry.bytes)
.saturating_add(new_bytes);
entry.bytes = new_bytes;
}
}
/// Evicts least-recently-used images until the store retains at most
/// `max_bytes`. Meant to be called by the headless exporter *between*
/// requests, so an image can never disappear under a running render;
@ -295,15 +381,22 @@ impl ImageStore {
evicted
}
/// Stores encoded image bytes. When `display_side` is set (shape @ 100% ×
/// dpr), eagerly builds that GPU tier and keeps the full native decode
/// lazy until deep zoom asks for it.
pub fn add(
&mut self,
id: Uuid,
is_thumbnail: bool,
image_data: &[u8],
display_side: Option<i32>,
) -> crate::error::Result<()> {
let key = (id, is_thumbnail);
if self.images.contains_key(&key) {
if let Some(side) = display_side.filter(|s| *s > 0) {
self.ensure_display_side(key, side);
}
return Ok(());
}
@ -311,10 +404,8 @@ impl ImageStore {
let bytes = raw_data.len();
match self.context.as_mut() {
Some(context) => {
if let Some(gpu_image) = decode_image(context, &raw_data) {
self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes);
} else if let Some((dom, size)) = parse_svg(&raw_data) {
Some(_) => {
if let Some((dom, size)) = parse_svg(&raw_data) {
self.insert_entry(
key,
StoredImage::Svg {
@ -324,6 +415,42 @@ impl ImageStore {
},
bytes,
);
} else if let Some(native) = codec_native_size(&raw_data) {
let mut raster = RasterStored {
raw: raw_data,
native,
display: None,
display_side: 0,
full: None,
};
// Thumbnails are already small: decode once at native.
// Otherwise eagerly build the display tier when we know
// the shape side at 100% zoom.
let eager_side = if is_thumbnail {
native.width.max(native.height)
} else {
display_side.unwrap_or(0).max(0)
};
if eager_side > 0 {
let target = fit_size(native, eager_side);
let target_side = target.width.max(target.height);
if let Some(context) = self.context.as_mut() {
if let Some((img, _)) =
decode_image_to_max_side(context, &raster.raw, target_side)
{
// Always park the eager decode in `display`.
// `full` is reserved for a larger lazy promote.
raster.display = Some(img);
raster.display_side =
target_side.min(native.width.max(native.height));
}
}
}
let retained = raster_retained_bytes(&raster);
self.insert_entry(key, StoredImage::Raster(raster), retained);
} else {
// The lazy re-decode in `get_internal` only retries raster codecs,
// so SVGs that fail to parse here stay raw.
@ -385,13 +512,251 @@ impl ImageStore {
self.images.contains_key(&(*id, is_thumbnail))
}
pub fn get(&mut self, id: &Uuid) -> Option<&Image> {
// Try to get full image first, fallback to thumbnail
let has_full = self.images.contains_key(&(*id, false));
if has_full {
self.get_internal(id, false)
/// Grow/create the display GPU tier so it covers at least `side` px
/// (capped to native). Used when the shape grows while zoomed out.
fn ensure_display_side(&mut self, key: (Uuid, bool), side: i32) {
let Some(entry) = self.images.get_mut(&key) else {
return;
};
let StoredImage::Raster(raster) = &mut entry.image else {
return;
};
let native_side = raster.native.width.max(raster.native.height);
let target = side.min(native_side).max(1);
if raster.display_side >= target {
return;
}
// If we already have full and target is native, display is redundant.
if target >= native_side {
if raster.full.is_none() && raster.display.is_none() {
let raw = raster.raw.clone();
let Some(context) = self.context.as_mut() else {
return;
};
if let Some((img, _)) = decode_image_to_max_side(context, &raw, 0) {
if let Some(entry) = self.images.get_mut(&key) {
if let StoredImage::Raster(raster) = &mut entry.image {
// Native-sized tier lives in `display` so eviction
// logic never drops the only GPU image.
raster.display = Some(img);
raster.display_side = native_side;
raster.full = None;
}
}
self.recompute_entry_bytes(key);
}
} else if let Some(entry) = self.images.get_mut(&key) {
if let StoredImage::Raster(raster) = &mut entry.image {
if raster.display.is_none() {
if let Some(full) = raster.full.take() {
raster.display = Some(full);
}
}
raster.display_side = native_side;
raster.full = None;
}
self.recompute_entry_bytes(key);
}
return;
}
let raw = match self.images.get(&key) {
Some(StoredEntry {
image: StoredImage::Raster(r),
..
}) => r.raw.clone(),
_ => return,
};
let Some(context) = self.context.as_mut() else {
return;
};
let Some((img, _)) = decode_image_to_max_side(context, &raw, target) else {
return;
};
if let Some(entry) = self.images.get_mut(&key) {
if let StoredImage::Raster(raster) = &mut entry.image {
raster.display = Some(img);
raster.display_side = target;
}
}
self.recompute_entry_bytes(key);
}
fn ensure_full(&mut self, key: (Uuid, bool)) -> bool {
let raw = match self.images.get(&key) {
Some(StoredEntry {
image: StoredImage::Raster(r),
..
}) => {
if r.full.is_some() {
return true;
}
r.raw.clone()
}
_ => return false,
};
let Some(context) = self.context.as_mut() else {
return false;
};
let Some((img, _)) = decode_image_to_max_side(context, &raw, 0) else {
return false;
};
if let Some(entry) = self.images.get_mut(&key) {
if let StoredImage::Raster(raster) = &mut entry.image {
raster.full = Some(img);
}
}
self.recompute_entry_bytes(key);
true
}
fn drop_full_if_unneeded(&mut self, key: (Uuid, bool), needed_side: i32) {
let should_drop = {
let Some(entry) = self.images.get(&key) else {
return;
};
let StoredImage::Raster(raster) = &entry.image else {
return;
};
if raster.full.is_none() {
return;
}
// Only evict full when a real display texture still covers the
// request. If display was cleared because the tier is native-sized
// (full is the only GPU image), dropping it leaves nothing to draw.
raster.display.is_some()
&& raster.display_side > 0
&& needed_side <= raster.display_side
};
if !should_drop {
return;
}
if let Some(entry) = self.images.get_mut(&key) {
if let StoredImage::Raster(raster) = &mut entry.image {
raster.full = None;
}
}
self.recompute_entry_bytes(key);
}
/// Picks display vs full for painting. `display_side` is shape@100%×dpr;
/// `needed_side` is shape×view_scale (zoom×dpr).
pub fn get_for_draw(
&mut self,
id: &Uuid,
display_side: i32,
needed_side: i32,
) -> Option<&Image> {
let key = if self.images.contains_key(&(*id, false)) {
(*id, false)
} else if self.images.contains_key(&(*id, true)) {
(*id, true)
} else {
self.get_internal(id, true)
return None;
};
// Promote Raw → Raster on first draw if needed.
if matches!(
self.images.get(&key).map(|e| &e.image),
Some(StoredImage::Raw(_))
) {
let raw = match self.images.remove(&key) {
Some(StoredEntry {
image: StoredImage::Raw(raw),
bytes,
last_used,
}) => {
self.total_bytes = self.total_bytes.saturating_sub(bytes);
let _ = last_used;
raw
}
Some(other) => {
self.images.insert(key, other);
return None;
}
None => return None,
};
if let Some(native) = codec_native_size(&raw) {
let retained = raw.len();
self.insert_entry(
key,
StoredImage::Raster(RasterStored {
raw,
native,
display: None,
display_side: 0,
full: None,
}),
retained,
);
} else {
self.insert_entry(key, StoredImage::Raw(raw), 0);
return None;
}
}
if display_side > 0 {
self.ensure_display_side(key, display_side);
}
let use_full = {
let entry = self.images.get(&key)?;
match &entry.image {
StoredImage::Raster(r) => {
// Need full when display is missing or too small for needed.
let display_covers = r.display.is_some()
&& r.display_side > 0
&& needed_side <= r.display_side;
!display_covers
}
_ => false,
}
};
if use_full {
self.ensure_full(key);
} else {
self.drop_full_if_unneeded(key, needed_side);
}
if matches!(
self.images.get(&key).map(|e| &e.image),
Some(StoredImage::Svg { .. })
) {
return self.get_internal(id, key.1);
}
let tick = self.next_tick();
let entry = self.images.get(&key)?;
entry.last_used.set(tick);
match &entry.image {
StoredImage::Raster(r) => {
let prefer_display = r.display.is_some()
&& r.display_side > 0
&& needed_side <= r.display_side;
if prefer_display {
r.display.as_ref()
} else {
// Prefer full when needed; fall back to display if full
// decode failed (e.g. GPU limit) so the shape stays visible.
r.full.as_ref().or(r.display.as_ref())
}
}
StoredImage::Gpu(img) => Some(img),
StoredImage::Svg { .. } => None, // handled above
StoredImage::Raw(_) => None,
}
}
pub fn get(&mut self, id: &Uuid) -> Option<&Image> {
// Legacy callers without LOD: prefer full when present, else display.
let has_full = self.images.contains_key(&(*id, false));
let key_thumb = !has_full;
if has_full {
// Request a huge needed side so full is materialized when possible.
self.get_for_draw(id, 0, i32::MAX)
} else {
self.get_internal(id, key_thumb)
}
}
@ -417,6 +782,10 @@ impl ImageStore {
let data = unsafe { skia::Data::new_bytes(raw_data) };
Image::from_encoded(&data)
}
StoredImage::Raster(r) => {
let data = unsafe { skia::Data::new_bytes(&r.raw) };
Image::from_encoded(&data)
}
StoredImage::Gpu(img) => Some(img.clone()),
StoredImage::Svg { dom, size, .. } => {
// No GPU context in the headless path: rasterize on a CPU
@ -447,33 +816,67 @@ impl ImageStore {
let key = (*id, is_thumbnail);
let tick = self.tick.get() + 1;
self.tick.set(tick);
// Use entry API to mutate the HashMap in-place if needed
if let Some(entry) = self.images.get_mut(&key) {
entry.last_used.set(tick);
let image = &mut entry.image;
match image {
StoredImage::Gpu(ref img) => Some(img),
StoredImage::Raw(raw_data) => {
let context = self.context.as_mut()?;
let gpu_image = decode_image(context, raw_data)?;
*image = StoredImage::Gpu(gpu_image);
if let StoredImage::Gpu(ref img) = image {
Some(img)
} else {
None
}
}
StoredImage::Svg { dom, size, raster } => {
if raster.is_none() {
let context = self.context.as_mut()?;
*raster = rasterize_svg(context, dom, *size);
}
raster.as_ref()
let needs_full_decode = matches!(
self.images.get(&key).map(|e| &e.image),
Some(StoredImage::Raster(r)) if r.full.is_none() && r.display.is_none()
);
let needs_gpu_from_raw = matches!(
self.images.get(&key).map(|e| &e.image),
Some(StoredImage::Raw(_))
);
let needs_svg_raster = matches!(
self.images.get(&key).map(|e| &e.image),
Some(StoredImage::Svg { raster: None, .. })
);
if needs_full_decode {
let raw = match self.images.get(&key) {
Some(StoredEntry {
image: StoredImage::Raster(r),
..
}) => r.raw.clone(),
_ => return None,
};
let context = self.context.as_mut()?;
let gpu_image = decode_image(context, &raw)?;
if let Some(entry) = self.images.get_mut(&key) {
if let StoredImage::Raster(r) = &mut entry.image {
r.full = Some(gpu_image);
}
}
} else {
None
self.recompute_entry_bytes(key);
} else if needs_gpu_from_raw {
let raw = match self.images.get(&key) {
Some(StoredEntry {
image: StoredImage::Raw(raw),
..
}) => raw.clone(),
_ => return None,
};
let context = self.context.as_mut()?;
let gpu_image = decode_image(context, &raw)?;
if let Some(entry) = self.images.get_mut(&key) {
entry.image = StoredImage::Gpu(gpu_image);
}
} else if needs_svg_raster {
let ImageStore {
context, images, ..
} = self;
let context = context.as_mut()?;
let entry = images.get_mut(&key)?;
if let StoredImage::Svg { dom, size, raster } = &mut entry.image {
*raster = rasterize_svg(context, dom, *size);
}
}
let entry = self.images.get(&key)?;
entry.last_used.set(tick);
match &entry.image {
StoredImage::Gpu(img) => Some(img),
StoredImage::Raster(r) => r.full.as_ref().or(r.display.as_ref()),
StoredImage::Svg { raster, .. } => raster.as_ref(),
StoredImage::Raw(_) => None,
}
}
}

View File

@ -568,7 +568,15 @@ fn draw_image_stroke_in_container(
} else {
stroke
};
let Some(image) = get_resources().images.get(&image_fill.id()) else {
let view_scale = render_state.get_view_scale();
let dpr = render_state.options.dpr.max(1.0);
let display_side = super::images::shape_side_px(&shape.selrect, dpr);
let needed_side = super::images::shape_side_px(&shape.selrect, view_scale);
let Some(image) = get_resources().images.get_for_draw(
&image_fill.id(),
display_side,
needed_side,
) else {
return Ok(());
};

View File

@ -127,10 +127,22 @@ pub extern "C" fn store_image() -> Result<()> {
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
with_state!(state, {
if let Err(msg) = get_resources()
.images
.add(ids.image_id, is_thumbnail, image_bytes)
{
let display_side = if is_thumbnail {
None
} else {
state.shapes.get(&ids.shape_id).map(|shape| {
// Display tier = shape size at 100% zoom in device pixels (× dpr).
let dpr = crate::get_render_state().options.dpr.max(1.0);
crate::render::images::shape_side_px(&shape.selrect, dpr)
})
};
if let Err(msg) = get_resources().images.add(
ids.image_id,
is_thumbnail,
image_bytes,
display_side,
) {
eprintln!("{}", msg);
}
touch_shapes_with_image(state, ids.image_id);