From 5c4ba97c5fc6aafaf6bfab09217e3df7acf30bad Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 10 Jul 2026 23:03:23 +0300 Subject: [PATCH] feat(thumbnail): embed a DWG preview on save, show it on the Start page Generate a small wire-raster thumbnail when saving a DWG and embed it via acadrust's preview API, so OCS drawings show a thumbnail in file browsers and other CAD applications. Read the embedded preview back cheaply (file header + preview bytes only, no full parse) to show a thumbnail per file in the Start page's Recent Documents panel. - io/thumbnail.rs: CPU rasterizer (current-layout wires -> BMP DIB) plus a preview-only reader/decoder (BMP/PNG -> iced handle) for the Start page. - Embedded on save via stamp_thumbnail on both save paths. - recent_thumbs cache filled on config load / recents change (never from a view); a thumbnail is drawn beside each recent file. Requires acadrust at 3111694 (preview read/write), already pinned in Cargo.lock. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- src/app/mod.rs | 6 + src/app/recent.rs | 12 ++ src/app/update/file.rs | 16 +++ src/app/view/mod.rs | 47 ++++++-- src/io/mod.rs | 1 + src/io/thumbnail.rs | 252 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 src/io/thumbnail.rs diff --git a/Cargo.lock b/Cargo.lock index 8bd2dad9b..7f80eda2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/HakanSeven12/acadrust?branch=main#716d4b41c99e9540deb1fe009223d09b82fdfca9" +source = "git+https://github.com/HakanSeven12/acadrust?branch=main#3111694c734bf6d86f1c3693e98657733070a277" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/mod.rs b/src/app/mod.rs index c0bd332b4..f64fa28ea 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -227,6 +227,11 @@ pub(super) struct OpenCADStudio { ribbon: Ribbon, /// Recently opened files, newest first — backs the Start page panel. recent_files: Vec, + /// Decoded DWG preview thumbnails for the Start page, keyed by path. + /// `None` = the file has no readable preview (DXF, missing, WMF). Filled + /// lazily by `refresh_recent_thumbs`; never read from a `view` call. + recent_thumbs: + std::collections::HashMap>, /// How many recent files to keep (user-set on the Start page, persisted). recent_limit: usize, /// Live text of the recent-limit input box (may differ from `recent_limit` @@ -2090,6 +2095,7 @@ impl OpenCADStudio { // Populated from the consolidated config after construction // (`apply_config`); default empty here. recent_files: Vec::new(), + recent_thumbs: std::collections::HashMap::new(), recent_limit: recent::RECENT_DEFAULT, recent_limit_input: recent::RECENT_DEFAULT.to_string(), command_line: CommandLine::new(), diff --git a/src/app/recent.rs b/src/app/recent.rs index f041f1608..2646a5b80 100644 --- a/src/app/recent.rs +++ b/src/app/recent.rs @@ -17,9 +17,21 @@ impl OpenCADStudio { self.recent_files.retain(|r| r != &path); self.recent_files.insert(0, path); self.recent_files.truncate(self.recent_limit); + self.refresh_recent_thumbs(); self.save_config(); } + /// Decode any not-yet-cached DWG preview thumbnails for the current recents + /// (cheap preview-only reads). Cached per path; safe to call repeatedly. + /// Call when the recents list changes — never from a `view`. + pub(super) fn refresh_recent_thumbs(&mut self) { + for path in self.recent_files.clone() { + self.recent_thumbs + .entry(path.clone()) + .or_insert_with(|| crate::io::thumbnail::read_handle(&path)); + } + } + /// Drop a path from the recents list (manual removal from the Start page). pub(super) fn remove_recent(&mut self, path: &Path) { self.recent_files.retain(|r| r.as_path() != path); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index d6e06face..e5e5280a4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -296,6 +296,7 @@ impl OpenCADStudio { .clamp(crate::app::recent::RECENT_MIN, crate::app::recent::RECENT_MAX); self.recent_files.truncate(self.recent_limit); self.recent_limit_input = self.recent_limit.to_string(); + self.refresh_recent_thumbs(); self.statusbar_config = cfg.statusbar; self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; @@ -670,6 +671,19 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } + /// Rasterize the current drawing into the document's DWG preview image, so + /// a saved file shows a thumbnail in file browsers and other CAD apps. + /// A degenerate/empty drawing clears any stale preview. + pub(super) fn stamp_thumbnail(&mut self, i: usize) { + self.tabs[i].scene.document.preview = + crate::io::thumbnail::from_scene(&self.tabs[i].scene); + // The on-disk thumbnail will change — drop the cached Start-page handle + // so it re-reads the updated file on the next refresh. + if let Some(p) = self.tabs[i].current_path.clone() { + self.recent_thumbs.remove(&p); + } + } + pub(super) fn on_save_file(&mut self) -> Task { if self.read_only { self.command_line @@ -695,6 +709,7 @@ pub(super) fn on_open_file(&mut self) -> Task { crate::io::write_backup(&path); } self.sync_truck_solids_to_acis(i); + self.stamp_thumbnail(i); match crate::io::save(&self.tabs[i].scene.document, &path) { Ok(()) => { self.command_line @@ -755,6 +770,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Persist Ortho / running OSNAP into the header (Save-As path). self.stamp_header_sysvars(i); self.sync_truck_solids_to_acis(i); + self.stamp_thumbnail(i); // Native: write to the chosen path. Web: download the bytes // under the chosen name (no filesystem). diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 021669d6f..2f799ad5a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -118,6 +118,7 @@ impl OpenCADStudio { start_page_view( &self.patrons, &self.recent_files, + &self.recent_thumbs, self.recent_limit, &self.recent_limit_input, self.win_size.0, @@ -2187,6 +2188,10 @@ pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Mes pub(super) fn start_page_view<'a>( patrons: &'a [(String, i64)], recents: &'a [std::path::PathBuf], + thumbs: &'a std::collections::HashMap< + std::path::PathBuf, + Option, + >, recent_limit: usize, recent_limit_input: &'a str, avail_w: f32, @@ -2540,7 +2545,7 @@ pub(super) fn start_page_view<'a>( let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding let fits_all = avail >= recent_w + welcome_min + sup_w; - let recent = recent_files_panel(recents, recent_limit, recent_limit_input); + let recent = recent_files_panel(recents, thumbs, recent_limit, recent_limit_input); let welcome = container(content).width(Fill).height(Fill); // Right rail: Patreon supporters, fetched at boot. When the list is empty @@ -2719,6 +2724,10 @@ pub(super) fn start_page_view<'a>( // open — entries persist across sessions. pub(super) fn recent_files_panel<'a>( recents: &'a [std::path::PathBuf], + thumbs: &'a std::collections::HashMap< + std::path::PathBuf, + Option, + >, limit: usize, limit_input: &'a str, ) -> Element<'a, Message> { @@ -2779,17 +2788,37 @@ pub(super) fn recent_files_panel<'a>( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); + // Leading DWG preview thumbnail (fixed box keeps rows aligned even + // when a file has no readable preview). + let thumb: Element<'a, Message> = match thumbs.get(path).and_then(|o| o.as_ref()) { + Some(h) => container( + iced::widget::image(h.clone()) + .content_fit(iced::ContentFit::Contain) + .width(Fill) + .height(Fill), + ) + .width(46) + .height(34) + .into(), + None => iced::widget::Space::new().width(46).height(34).into(), + }; + let path_for_open = path.clone(); let open_btn = button( - column![ - text(crate::ui::text_util::elide(&name, 32)) - .size(12) - .color(TEXT), - text(crate::ui::text_util::elide(&dir, 42)) - .size(10) - .color(MUTED), + row![ + thumb, + column![ + text(crate::ui::text_util::elide(&name, 28)) + .size(12) + .color(TEXT), + text(crate::ui::text_util::elide(&dir, 38)) + .size(10) + .color(MUTED), + ] + .spacing(2), ] - .spacing(2), + .spacing(8) + .align_y(iced::Center), ) .on_press(Message::OpenRecent(path_for_open)) .padding([6, 12]) diff --git a/src/io/mod.rs b/src/io/mod.rs index b9471168c..15a3aa7dd 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -15,6 +15,7 @@ pub mod linetypes; pub mod patterns; pub mod update_check; pub mod paper_sizes; +pub mod thumbnail; use crate::scene::DerivedCaches; use acadrust::entities::EntityType; diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs new file mode 100644 index 000000000..0f8ccf68f --- /dev/null +++ b/src/io/thumbnail.rs @@ -0,0 +1,252 @@ +//! CPU wire-raster thumbnail for the DWG preview image. +//! +//! Rasterizes the current layout's wire set into a small bitmap and returns it +//! as an [`acadrust::Preview`] (a Windows DIB, universally accepted as a DWG +//! preview across versions). Embedded on save so OCS drawings show a thumbnail +//! in file browsers and other CAD applications. + +use acadrust::{Preview, PreviewFormat}; +use image::{ImageFormat, Rgb, RgbImage}; +use std::io::Cursor; + +use crate::scene::{Scene, WireModel}; + +/// Longest edge of the generated thumbnail, in pixels. +const MAX_DIM: u32 = 256; +/// Blank border kept around the drawing, in pixels. +const MARGIN: f64 = 6.0; + +/// Build a preview from the scene's current-layout wires. `None` when there is +/// nothing finite to draw (empty or degenerate drawing). +pub fn from_scene(scene: &Scene) -> Option { + from_wires(&scene.entity_wires(), scene.bg_color) +} + +/// Rasterize `wires` (world XY) onto a `bg`-filled canvas and encode a DIB. +pub fn from_wires(wires: &[WireModel], bg: [f32; 4]) -> Option { + // ── World-XY bounds over every finite vertex ───────────────────────────── + let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY); + let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY); + for w in wires { + for (i, p) in w.points.iter().enumerate() { + if !p[0].is_finite() || !p[1].is_finite() { + continue; + } + let (x, y) = abs_xy(w, i, p); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + let (dw, dh) = (max_x - min_x, max_y - min_y); + if !dw.is_finite() || !dh.is_finite() || (dw <= 0.0 && dh <= 0.0) { + return None; + } + + // ── Canvas sized to the drawing's aspect, longest edge = MAX_DIM ───────── + let aspect = if dh > 0.0 { dw / dh } else { f64::INFINITY }; + let (cw, ch) = if aspect >= 1.0 { + (MAX_DIM, ((MAX_DIM as f64 / aspect).round() as u32).clamp(16, MAX_DIM)) + } else { + (((MAX_DIM as f64 * aspect).round() as u32).clamp(16, MAX_DIM), MAX_DIM) + }; + + // World → pixel: uniform scale to fit inside the margin, drawing centered, + // Y flipped (world up → image row 0 at top). + let sx = (cw as f64 - 2.0 * MARGIN) / dw.max(1e-9); + let sy = (ch as f64 - 2.0 * MARGIN) / dh.max(1e-9); + let scale = sx.min(sy); + let off_x = (cw as f64 - dw * scale) * 0.5; + let off_y = (ch as f64 - dh * scale) * 0.5; + let to_px = |x: f64, y: f64| -> (i32, i32) { + let px = off_x + (x - min_x) * scale; + let py = ch as f64 - (off_y + (y - min_y) * scale); // flip Y + (px.round() as i32, py.round() as i32) + }; + + // ── Rasterize ──────────────────────────────────────────────────────────── + let bg_rgb = to_rgb(bg); + let mut img = RgbImage::from_pixel(cw, ch, Rgb(bg_rgb)); + for w in wires { + let col = Rgb(to_rgb(w.color)); + let mut prev: Option<(i32, i32)> = None; + for (i, p) in w.points.iter().enumerate() { + if !p[0].is_finite() || !p[1].is_finite() { + prev = None; // NaN separator breaks the run + continue; + } + let (x, y) = abs_xy(w, i, p); + let cur = to_px(x, y); + if let Some(p0) = prev { + draw_line(&mut img, p0, cur, col); + } + prev = Some(cur); + } + } + + // ── Encode BMP, strip the 14-byte BITMAPFILEHEADER → DIB ───────────────── + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, ImageFormat::Bmp).ok()?; + let bmp = buf.into_inner(); + if bmp.len() <= 14 { + return None; + } + Some(Preview { format: PreviewFormat::Bmp, data: bmp[14..].to_vec() }) +} + +/// Absolute world XY of vertex `i`, reconstructing the double-single residual. +#[inline] +fn abs_xy(w: &WireModel, i: usize, p: &[f32; 3]) -> (f64, f64) { + let (lx, ly) = w + .points_low + .get(i) + .map_or((0.0, 0.0), |l| (l[0] as f64, l[1] as f64)); + (p[0] as f64 + lx, p[1] as f64 + ly) +} + +#[inline] +fn to_rgb(c: [f32; 4]) -> [u8; 3] { + [ + (c[0].clamp(0.0, 1.0) * 255.0).round() as u8, + (c[1].clamp(0.0, 1.0) * 255.0).round() as u8, + (c[2].clamp(0.0, 1.0) * 255.0).round() as u8, + ] +} + +/// Cheaply read ONLY the embedded preview from a DWG (file header + preview +/// bytes, no full document parse) and decode it to an iced image handle. +/// `None` for DXF/other files, a missing or empty preview, or a format the +/// `image` crate can't decode (WMF). Used to show recent-file thumbnails. +pub fn read_handle(path: &std::path::Path) -> Option { + decode(&read_preview(path)?) +} + +/// Read the preview image bytes straight from the file's raw preview offset. +fn read_preview(path: &std::path::Path) -> Option { + use std::io::{Read, Seek, SeekFrom}; + let mut f = std::fs::File::open(path).ok()?; + let mut ver = [0u8; 6]; + f.read_exact(&mut ver).ok()?; + if &ver[..2] != b"AC" { + return None; // not a DWG (DXF has no thumbnail) + } + // Preview seeker: absolute file offset at header byte 0x0D. + f.seek(SeekFrom::Start(0x0D)).ok()?; + let mut a = [0u8; 4]; + f.read_exact(&mut a).ok()?; + let base = i32::from_le_bytes(a); + if base <= 0 { + return None; + } + let base = base as u64; + f.seek(SeekFrom::Start(base)).ok()?; + let mut head = [0u8; 20]; + f.read_exact(&mut head).ok()?; + let overall = acadrust::io::dwg::preview::overall_size(&head)?; + if overall == 0 || overall > 64 * 1024 * 1024 { + return None; + } + let total = acadrust::io::dwg::preview::container_len(overall); + f.seek(SeekFrom::Start(base)).ok()?; + let mut buf = vec![0u8; total]; + f.read_exact(&mut buf).ok()?; + acadrust::io::dwg::preview::parse_preview(&buf, base) +} + +/// Decode a stored preview to an iced RGBA handle. +fn decode(p: &Preview) -> Option { + let img = match p.format { + PreviewFormat::Png => image::load_from_memory_with_format(&p.data, ImageFormat::Png).ok()?, + PreviewFormat::Bmp => { + image::load_from_memory_with_format(&dib_to_bmp(&p.data), ImageFormat::Bmp).ok()? + } + PreviewFormat::Wmf => return None, + }; + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + Some(iced::widget::image::Handle::from_rgba(w, h, rgba.into_raw())) +} + +/// Reconstruct a full in-memory BMP from a stored DIB (prepend the 14-byte +/// `BITMAPFILEHEADER`) so a BMP decoder can read it. +fn dib_to_bmp(dib: &[u8]) -> Vec { + if dib.len() < 16 { + return Vec::new(); + } + let bi_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]) as usize; + let bpp = u16::from_le_bytes([dib[14], dib[15]]) as usize; + let palette = if (1..=8).contains(&bpp) { (1usize << bpp) * 4 } else { 0 }; + let mut v = Vec::with_capacity(14 + dib.len()); + v.extend_from_slice(b"BM"); + v.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&((14 + bi_size + palette) as u32).to_le_bytes()); + v.extend_from_slice(dib); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wire(pts: &[[f32; 2]], color: [f32; 4]) -> WireModel { + WireModel { + points: pts.iter().map(|&[x, y]| [x, y, 0.0]).collect(), + color, + ..Default::default() + } + } + + #[test] + fn empty_input_yields_none() { + assert!(from_wires(&[], [0.0, 0.0, 0.0, 1.0]).is_none()); + } + + #[test] + fn draws_a_valid_non_blank_dib() { + let bg = [0.1, 0.1, 0.1, 1.0]; + // A closed square (connected polyline) in a distinct colour. + let sq = wire(&[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]], + [1.0, 0.0, 0.0, 1.0]); + let p = from_wires(&[sq], bg).expect("some preview"); + assert_eq!(p.format, PreviewFormat::Bmp); + // DIB starts with a 40-byte BITMAPINFOHEADER. + assert_eq!(&p.data[0..4], &40u32.to_le_bytes()); + // Decodes to a square canvas (drawing aspect 1:1 → MAX_DIM²). + let img = image::load_from_memory(&dib_to_bmp(&p.data)).expect("decodes").to_rgb8(); + assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); + // At least one red pixel was drawn (not a blank fill). + let bg_px = to_rgb(bg); + assert!(img.pixels().any(|px| px.0 != bg_px), "nothing drawn"); + assert!(img.pixels().any(|px| px.0[0] > 128 && px.0[1] < 64), "square not red"); + } +} + +/// Bresenham line, clipped to the image bounds. +fn draw_line(img: &mut RgbImage, (x0, y0): (i32, i32), (x1, y1): (i32, i32), col: Rgb) { + let (w, h) = (img.width() as i32, img.height() as i32); + let (mut x0, mut y0) = (x0, y0); + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + if x0 >= 0 && x0 < w && y0 >= 0 && y0 < h { + img.put_pixel(x0 as u32, y0 as u32, col); + } + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } +}