diff --git a/src/io/mod.rs b/src/io/mod.rs index 445035485..2cbd5a5ed 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -2038,11 +2038,11 @@ pub async fn pick_plot_style() -> Result, Str /// Show a file-open dialog for raster images and decode the selected file. /// Returns `(path, pixel_width, pixel_height)` or an error string. /// -/// The bytes are read through the dialog handle and registered against the -/// returned path (name-only on the web — there is no filesystem behind it), -/// mirroring the PDF-underlay pick. Decoding then comes from those bytes, and -/// the entity the IMAGE command commits resolves through the same registry -/// instead of a `image::open` on a path that only exists natively. +/// On the web the bytes are read through the dialog handle and registered +/// against the returned name-only path (there is no filesystem behind it), +/// mirroring the PDF-underlay pick; decoding then comes from those bytes, and +/// the entity the IMAGE command commits resolves through the same registry. +/// Native decodes straight from the picked file. /// /// The filter names exactly the formats the `image` crate features build /// (Cargo.toml) — png/jpeg/bmp/tiff/webp/gif — and `io::web_drop` accepts the @@ -2061,13 +2061,19 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { .await .ok_or_else(|| "Cancelled".to_string())?; let path = crate::sys::handle_path(&handle); - let bytes = std::sync::Arc::new(handle.read().await); let reference = path.to_string_lossy().into_owned(); let short = std::path::Path::new(&reference) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| reference.clone()); - crate::scene::model::image_model::register_source(&reference, bytes); + // Web only: the name-only path has nothing behind it, so the bytes go + // into the registry. Native decodes from the file itself, so an edited + // image reloads and the bytes are not held for the session. + #[cfg(target_arch = "wasm32")] + crate::scene::model::image_model::register_source( + &reference, + std::sync::Arc::new(handle.read().await), + ); let decoded = crate::scene::model::image_model::resolve_image(&reference).ok_or_else(|| { // An over-budget decode refusal explains itself (dims + limit); // anything else stays the generic unsupported/corrupt message. diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 79dd2c793..ed544ab15 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -9,8 +9,8 @@ // // The web build has no system printers: `preview_page_pdf` / // `preview_pages_pdf` render the same PDF bytes, rasterise them, and show -// the pages in an in-page overlay — the way AutoCAD previews plot output on -// screen rather than handing a file to an external viewer. +// the pages in an in-page overlay — the plot preview stays on screen +// rather than handing a file to an external viewer. #[cfg(not(target_arch = "wasm32"))] use crate::io::pdf_export; diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 270346ba0..8bb860278 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -721,10 +721,11 @@ fn decode_reference(path: &str) -> Option { /// exceed it run with `image::Limits` pinned to the budget, so the decoder /// errors instead of ballooning the heap; this module turns that error into a /// localized refusal (see `refusal_message`) — the pixels are simply not -/// available at full size. Desktops have RAM to spare and rarely hit theirs; +/// available at full size. Desktops have more headroom than a tab, but the +/// whole decode transient lands at once, so it stays well below typical RAM; /// wasm lives inside a browser tab whose heap the decode must not pop. #[cfg(not(target_arch = "wasm32"))] -const MAX_DECODE_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_DECODE_BYTES: u64 = 2 * 1024 * 1024 * 1024; /// wasm: 512 MB of decoded RGBA — comfortably inside a tab's heap. #[cfg(target_arch = "wasm32")] const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024; @@ -836,11 +837,23 @@ fn tier_for_native( .saturating_mul(codec_factor) > max_decode_bytes { + return DecodeTier::OverBudget; + } + if pixels <= max_display_pixels { + return DecodeTier::Full; + } + // The downsample holds the decoded RGBA, the resize's vertical-pass + // intermediate (width × new height in RGBA f32, 16 B/px) and the output + // at once, so that sum must fit the budget too. + let (target_w, target_h) = downsample_to_cap(width, height, max_display_pixels); + let resize_peak = pixels + .saturating_mul(4) + .saturating_add((width as u64).saturating_mul(target_h as u64).saturating_mul(16)) + .saturating_add(target_w as u64 * target_h as u64 * 4); + if resize_peak > max_decode_bytes { DecodeTier::OverBudget - } else if pixels > max_display_pixels { - DecodeTier::Downsample } else { - DecodeTier::Full + DecodeTier::Downsample } } @@ -1524,7 +1537,10 @@ mod tests { assert_eq!(tier_for(64, 64, 1_000_000, 10_000), DecodeTier::Full); // Pixels over the display cap while RGBA stays within the decode // budget: decode, then downsample before storing. - assert_eq!(tier_for(400, 400, 1_000_000, 10_000), DecodeTier::Downsample); + assert_eq!(tier_for(400, 400, 2_000_000, 10_000), DecodeTier::Downsample); + // …unless the resize transient (decoded + f32 intermediate + output) + // is what overflows the budget. + assert_eq!(tier_for(400, 400, 1_000_000, 10_000), DecodeTier::OverBudget); // RGBA over the decode budget. assert_eq!(tier_for(2_000, 2_000, 1_000_000, 10_000), DecodeTier::OverBudget); // Exact boundary counts stay in the lower tier (limits are ">").