Merge #557: Fix #525: Image Trace of a large photo no longer crashes the app

# Conflicts:
#	ROADMAP.md
This commit is contained in:
bflatastic 2026-10-09 01:35:31 +07:00
commit d7b4d85d3c
9 changed files with 301 additions and 49 deletions

File diff suppressed because one or more lines are too long

View File

@ -640,6 +640,11 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
}
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
/// refused with advice instead (#525).
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
@ -671,7 +676,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let res = tr::trace(&raster, &params);
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;

View File

@ -27,6 +27,8 @@ mod fit;
mod mosaic;
mod quantize;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::PathData;
@ -41,8 +43,18 @@ pub enum TraceError {
Decode(String),
#[error("image is empty")]
Empty,
#[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")]
TooLarge { width: u32, height: u32, max: u64 },
#[error(
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
)]
TooComplex { max: usize },
}
/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are
/// refused instead of running out of memory.
pub const MAX_PIXELS: u64 = 64 << 20;
/// An RGBA8 raster (row-major, top row first).
#[derive(Clone, Debug, PartialEq)]
pub struct Raster {
@ -67,13 +79,19 @@ impl Raster {
}
Self { width, height, rgba }
}
/// Decode PNG / JPEG / WebP / GIF bytes.
/// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding).
pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> {
let img = image::load_from_memory(bytes).map_err(|e| TraceError::Decode(e.to_string()))?.to_rgba8();
let (w, h) = img.dimensions();
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 });
}
let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8();
let (w, h) = img.dimensions();
Ok(Self { width: w, height: h, rgba: img.into_raw() })
}
/// Encode as PNG.
@ -262,17 +280,25 @@ fn is_white(c: [u8; 3]) -> bool {
/// Trace `img` with `params`.
pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
// No trace has more than `usize::MAX` anchors.
trace_within(img, params, usize::MAX).unwrap_or_default()
}
/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more
/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The
/// colour layers are traced in parallel where threads are available.
pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
let (w, h) = (img.width as usize, img.height as usize);
if w == 0 || h == 0 {
return TraceResult::default();
if w == 0 || h == 0 || img.rgba.len() != w * h * 4 {
return Ok(TraceResult::default());
}
let mut q = quantize(img, params);
denoise(&mut q.labels, w, h, params.noise as usize);
// Pixel count per palette entry.
let mut counts = vec![0usize; q.palette.len()];
for &l in &q.labels {
if l != TRANSPARENT {
counts[l as usize] += 1;
if let Some(c) = counts.get_mut(l as usize) {
*c += 1;
}
}
// Layers bottom → top: largest area first.
@ -282,46 +308,117 @@ pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
for (r, &i) in order.iter().enumerate() {
rank[i] = r;
}
let opts = fit::FitOptions {
polygon_tol: params.polygon_tolerance(),
fit_tol: params.fit_tolerance(),
corner_angle: params.corner_angle(),
snap_lines: params.snap_curves_to_lines,
let layers: Vec<(usize, usize)> =
order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_white(q.palette[ci]))).collect();
let job = Layers {
q: &q,
rank: &rank,
params,
opts: fit::FitOptions {
polygon_tol: params.polygon_tolerance(),
fit_tol: params.fit_tolerance(),
corner_angle: params.corner_angle(),
snap_lines: params.snap_curves_to_lines,
},
w,
h,
anchors: AtomicUsize::new(0),
max_anchors,
};
let min_hole = params.noise.max(1) as i64;
let mut out = TraceResult::default();
let mut mask = vec![false; w * h];
for (r, &ci) in order.iter().enumerate() {
for (paths, &(_, ci)) in parallel_map(&layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
out.paths.extend(paths.ok_or(TraceError::TooComplex { max: max_anchors })?);
let color = q.palette[ci];
if params.ignore_white && is_white(color) {
continue;
}
let overlapping = params.method == Method::Overlapping;
for (m, &l) in mask.iter_mut().zip(&q.labels) {
*m = l != TRANSPARENT && if overlapping { rank[l as usize] >= r } else { l as usize == ci };
}
for comp in trace_mask(&mask, w, h) {
let mut subs = Vec::with_capacity(1 + comp.holes.len());
if let Some(sp) = fit::fit_loop(&comp.outer, &opts) {
subs.push(sp);
} else {
continue;
}
for hole in &comp.holes {
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
continue;
}
if let Some(sp) = fit::fit_loop(hole, &opts) {
subs.push(sp);
}
}
out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: comp.pixels });
}
if !out.palette.contains(&color) {
out.palette.push(color);
}
}
out
Ok(out)
}
/// What tracing one colour layer needs.
struct Layers<'a> {
q: &'a Quantized,
/// Each palette entry's layer, bottom (0) up.
rank: &'a [usize],
params: &'a TraceParams,
opts: fit::FitOptions,
w: usize,
h: usize,
/// Anchor points made so far, by every layer.
anchors: AtomicUsize,
max_anchors: usize,
}
impl Layers<'_> {
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
/// maximum.
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
let (params, opts) = (self.params, &self.opts);
let color = *self.q.palette.get(ci)?;
let overlapping = params.method == Method::Overlapping;
let mask: Vec<bool> = self
.q
.labels
.iter()
.map(|&l| l != TRANSPARENT && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci })
.collect();
let min_hole = params.noise.max(1) as i64;
let mut paths = vec![];
for comp in trace_mask(&mask, self.w, self.h) {
let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue };
let mut subs = vec![outer];
for hole in &comp.holes {
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
continue;
}
subs.extend(fit::fit_loop(hole, opts));
}
let path = PathData::new(subs);
let n = path.anchor_count();
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
return None;
}
paths.push(TracedPath { path, color, pixels: comp.pixels });
}
Some(paths)
}
}
/// `f` of each of `items`, in order, spread over the available cores.
#[cfg(not(target_arch = "wasm32"))]
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len());
if threads <= 1 {
return items.iter().map(f).collect();
}
let next = AtomicUsize::new(0);
let mut done: Vec<(usize, R)> = std::thread::scope(|s| {
let workers: Vec<_> = (0..threads)
.map(|_| {
s.spawn(|| {
let mut mine = vec![];
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(item) = items.get(i) else { break };
mine.push((i, f(item)));
}
mine
})
})
.collect();
// A worker's panic goes on in this thread, as if it had run here (the engine's guard
// reports it).
workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect()
});
done.sort_by_key(|(i, _)| *i);
done.into_iter().map(|(_, r)| r).collect()
}
/// `f` of each of `items`, in order (no threads on the web).
#[cfg(target_arch = "wasm32")]
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
items.iter().map(f).collect()
}
#[cfg(test)]

View File

@ -278,3 +278,73 @@ fn timing_1000x1000_bw_under_300ms() {
assert!(!res.paths.is_empty());
assert!(ms < 300.0, "{ms} ms");
}
/// Stripes of `n` colours, each with a few dots of the next one: many layers, many small shapes.
fn stripes(size: u32, n: u32) -> Raster {
let colour = |i: u32| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 53 % 256) as u8, 255];
Raster::from_fn(size, size, |x, y| {
let band = x * n / size;
if (x % 9 < 3) && (y % 9 < 3) { colour(band + 1) } else { colour(band) }
})
}
/// #525: past the anchor budget the trace gives up with advice instead of making millions of
/// anchors; under it, it is the plain trace.
#[test]
fn a_trace_past_its_anchor_budget_is_refused() {
let img = stripes(120, 8);
let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() };
let full = trace(&img, &p);
let anchors = full.anchor_count();
assert!(anchors > 100, "{anchors} anchors");
assert!(matches!(trace_within(&img, &p, anchors / 2), Err(TraceError::TooComplex { max }) if max == anchors / 2));
assert_eq!(trace_within(&img, &p, anchors).unwrap(), full);
}
/// Layers traced in parallel come out in the same order every time: bottom (largest) first.
#[test]
fn parallel_layers_keep_their_order() {
let img = stripes(90, 12);
let p = TraceParams { mode: Mode::Color, colors: 24, noise: 1, method: Method::Overlapping, ..TraceParams::default() };
let a = trace(&img, &p);
assert!(a.palette.len() > 8, "{} colours", a.palette.len());
for _ in 0..4 {
assert_eq!(trace(&img, &p), a);
}
// Overlapping: each layer's shapes cover the layers above, so the pixel counts fall upwards.
let firsts: Vec<usize> = a.palette.iter().filter_map(|c| a.paths.iter().find(|t| t.color == *c).map(|t| t.pixels)).collect();
assert!(firsts.windows(2).all(|w| w[0] >= w[1]), "{firsts:?}");
}
/// A PNG whose header says `w` × `h` pixels, with no image data.
fn png_header(w: u32, h: u32) -> Vec<u8> {
fn crc(bytes: &[u8]) -> u32 {
let mut c = !0u32;
for &b in bytes {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
let mut ihdr = b"IHDR".to_vec();
ihdr.extend(w.to_be_bytes());
ihdr.extend(h.to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
for chunk in [ihdr, b"IDAT".to_vec(), b"IEND".to_vec()] {
png.extend((chunk.len() as u32 - 4).to_be_bytes());
png.extend(&chunk);
png.extend(crc(&chunk).to_be_bytes());
}
png
}
/// An image too large to trace is refused from its header, before it is decoded.
#[test]
fn an_image_too_large_to_trace_is_refused_before_decoding() {
let err = Raster::decode(&png_header(30_000, 30_000)).unwrap_err();
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}");
assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding");
}

View File

@ -1041,6 +1041,27 @@ fn node_outline(n: &Node) -> BezPath {
bp
}
/// The most anchor points the selection's outlines and anchors, and the hover highlight, are drawn
/// for: an Image Trace of a photo selects hundreds of thousands of paths at once, whose outlines
/// would make a mesh larger than the GPU takes in one buffer (#525).
const OVERLAY_MAX_ANCHORS: usize = 100_000;
/// Whether `nodes` have more than [`OVERLAY_MAX_ANCHORS`] anchor points to outline (counting
/// stops there).
fn too_many_anchors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> bool {
fn over(n: &Node, left: &mut usize) -> bool {
if let NodeKind::Path { path, .. } = &n.kind {
match left.checked_sub(path.anchor_count()) {
Some(l) => *left = l,
None => return true,
}
}
!matches!(n.kind, NodeKind::Envelope { .. }) && n.children().into_iter().flatten().any(|c| over(c, left))
}
let mut left = OVERLAY_MAX_ANCHORS;
nodes.into_iter().any(|n| over(n, &mut left))
}
/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the
/// envelope shows its mesh instead).
fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
@ -1160,7 +1181,7 @@ fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
if st.selection.contains(id) {
return;
}
if let Some(n) = st.doc.node(id) {
if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) {
let color = c32(st.doc.layer_color(id));
stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color));
}
@ -1351,6 +1372,9 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
&& app.ui.view.bounding_box
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
let bbox = if show_box { app.selection_box() } else { None };
let st = app.session.active();
let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
let big_bounds = if big { app.selection_bounds() } else { None };
let app = &*app;
let Some(st) = app.session.active() else { return };
let tool = app.session.tool_id();
@ -1363,7 +1387,11 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
// The selected anchors' handles (drawn once they are counted: Show handles when multiple
// anchors are selected off shows them for a single one only).
let (mut handles, mut with_handles) = (vec![], 0);
for id in &st.selection.objects {
// Too many paths to outline: the selection's bounds stand for them.
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
}
for id in st.selection.objects.iter().filter(|_| !big) {
let Some(n) = st.doc.node(*id) else { continue };
let color = c32(st.doc.layer_color(*id));
let partial = st.selection.partial(*id);
@ -1633,11 +1661,13 @@ pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option<egui::Rect> {
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
if st.selection.is_empty() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
if app.session.active().is_none_or(|st| st.selection.is_empty())
|| !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")
{
return;
}
let Some(b) = st.doc.bounds_of(&st.selection.objects, true) else { return };
let Some(b) = app.selection_bounds() else { return };
let Some(st) = app.session.active() else { return };
let n = st.selection.len();
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
@ -2024,6 +2054,29 @@ mod tests {
assert_eq!(cmd_drag(&mut app, inside, inside), "selection");
}
/// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds,
/// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one
/// still shows its outline and anchors.
#[test]
fn a_huge_selection_shows_its_bounds_instead_of_every_outline() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let points = |n: usize| (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::<String>();
let ctx = egui::Context::default();
let mut drawn = |n: usize| {
app.session.execute("path.create", &json!({"d": format!("M20 20 {} Z", points(n))})).unwrap();
frame(&mut app, &ctx, vec![]);
let shapes = shapes(&mut app, &ctx);
let points: usize = shapes.iter().map(|s| if let Shape::Path(ps) = s { ps.points.len() } else { 0 }).sum();
let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count();
(points, squares)
};
let (points, squares) = drawn(OVERLAY_MAX_ANCHORS + 10);
assert!(points < 100 && squares < 100, "{points} outline points, {squares} squares");
let (points, squares) = drawn(200);
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
}
/// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened.
fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
let (shapes, mut delta) = frame_output(app, ctx);

View File

@ -3415,6 +3415,10 @@ axis Both Ambos
@msg select a stroke with a variable width selecciona un trazo con anchura variable
@msg select a symbol instance selecciona una instancia de símbolo
@msg select an Image Trace object selecciona un objeto de calco de imagen
@msg could not decode image: {0} no se pudo decodificar la imagen: {0}
@msg image is empty la imagen está vacía
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) la imagen mide {width} × {height} píxeles, más de lo que admite Calco de imagen ({max} megapíxeles)
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset este calco crearía más de {max} puntos de ancla, más de los que el documento puede mostrar con fluidez: sube Ruido, baja Trazados o Colores, o elige un preajuste de menor fidelidad
@msg select an envelope selecciona una envolvente
@msg select an image selecciona una imagen
@msg select an object in perspective selecciona un objeto en perspectiva

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3415,6 +3415,10 @@ axis Both Entrambi
@msg select a stroke with a variable width seleziona una traccia con larghezza variabile
@msg select a symbol instance seleziona un'istanza di simbolo
@msg select an Image Trace object seleziona un oggetto di ricalco immagine
@msg could not decode image: {0} impossibile decodificare l'immagine: {0}
@msg image is empty l'immagine è vuota
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) l'immagine misura {width} × {height} pixel, più di quanto accetti Ricalco immagine ({max} megapixel)
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset questo ricalco creerebbe più di {max} punti di ancoraggio, più di quanti il documento possa mostrare in modo fluido: aumenta Disturbo, riduci Tracciati o Colori, oppure scegli un preset a fedeltà inferiore
@msg select an envelope seleziona un involucro
@msg select an image seleziona un'immagine
@msg select an object in perspective seleziona un oggetto in prospettiva

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -711,7 +711,7 @@ fn complete_languages_translate_every_message() {
const COMPLETE_MESSAGES: &[&str] = &["es", "it"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad"];
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
/// Where a message literal starts: a status, an error value, a `thiserror` message.
const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("];

View File

@ -249,6 +249,8 @@ pub struct CanvasCache {
pub print_tiling: Option<PrintTilingCache>,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
/// [`VectorcraftApp::selection_bounds`] for (document uid, revision).
pub selection_bounds: Option<((u64, u64), Option<vectorcraft_geom::Rect>)>,
/// The tools' cursors as OS cursor bitmaps.
pub cursors: cursors::Images,
}
@ -389,6 +391,7 @@ impl VectorcraftApp {
slices: None,
print_tiling: None,
selection_box: None,
selection_bounds: None,
cursors: Default::default(),
},
perf: Perf::default(),
@ -704,6 +707,22 @@ impl VectorcraftApp {
self.canvas.selection_box = Some((key, b));
b
}
/// The selection's visual bounds (stroke and effects included), square to the page: measured
/// once per revision, as the canvas reads it every frame (a traced photo selects a group of
/// hundreds of thousands of paths).
pub fn selection_bounds(&mut self) -> Option<vectorcraft_geom::Rect> {
let st = self.session.active()?;
let key = (st.uid, st.revision);
if let Some((k, b)) = self.canvas.selection_bounds
&& k == key
{
return b;
}
let b = st.doc.bounds_of(&st.selection.objects, true);
self.canvas.selection_bounds = Some((key, b));
b
}
}
/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are