feat(thumbnail): on-screen framing + PNG/RLE8 preview, far smaller files
The embedded DWG preview was the whole drawing fit to a canvas, encoded as a 24-bit uncompressed DIB (~180 KB at 256²) — the bulk of the gap between an OCS-saved file and AutoCAD's. Three changes: - Frame it like the screen. `from_scene` now projects every wire vertex through the live camera into a viewport-aspect canvas, so the thumbnail is the current pan / zoom / rotation and only the visible region, not the full extent. Off-screen / clipped segments don't draw (a `None` projection or NaN separator breaks the polyline run). Works for any view. `stamp_thumbnail` passes the model pane's pixel size and the target version. - PNG for R2013+ (AC1027+). A line drawing on a flat background is almost one colour, so PNG collapses to a few KB. PNG previews are only valid from R2013, so older targets keep a BMP. - BMP is now 8-bit palettised, BI_RLE8-compressed (was 24-bit raw). The long single-colour runs of a preview compress to a handful of KB; a view with >256 colours (rare) falls back to the 24-bit DIB. A full 256-entry palette is written so the reader's `(1<<bpp)*4` pixel-data offset lands correctly. Net: the preview drops from ~120 KB to ~2-5 KB on every version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3326c5f0c8
commit
f2ad1cc0c8
2 changed files with 224 additions and 79 deletions
|
|
@ -749,9 +749,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
/// Rasterize the current drawing into the document's DWG preview image, so
|
/// 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 saved file shows a thumbnail in file browsers and other CAD apps.
|
||||||
/// A degenerate/empty drawing clears any stale preview.
|
/// A degenerate/empty drawing clears any stale preview.
|
||||||
pub(super) fn stamp_thumbnail(&mut self, i: usize) {
|
/// `target` is the version the file is about to be written as — a PNG
|
||||||
|
/// preview (few KB vs a ~180 KB DIB) is only valid from R2013 (AC1027) on.
|
||||||
|
pub(super) fn stamp_thumbnail(&mut self, i: usize, target: acadrust::DxfVersion) {
|
||||||
|
let png = target >= acadrust::DxfVersion::AC1027;
|
||||||
|
// Frame the thumbnail exactly as the model pane shows it now (current
|
||||||
|
// pan/zoom/rotation, visible region only), so pass the pane pixel size.
|
||||||
self.tabs[i].scene.document.preview =
|
self.tabs[i].scene.document.preview =
|
||||||
crate::io::thumbnail::from_scene(&self.tabs[i].scene);
|
crate::io::thumbnail::from_scene(&self.tabs[i].scene, png, self.vp_size);
|
||||||
// The on-disk thumbnail will change — drop the cached Start-page handle
|
// The on-disk thumbnail will change — drop the cached Start-page handle
|
||||||
// so it re-reads the updated file on the next refresh.
|
// so it re-reads the updated file on the next refresh.
|
||||||
if let Some(p) = self.tabs[i].current_path.clone() {
|
if let Some(p) = self.tabs[i].current_path.clone() {
|
||||||
|
|
@ -784,7 +789,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
crate::io::write_backup(&path);
|
crate::io::write_backup(&path);
|
||||||
}
|
}
|
||||||
self.sync_truck_solids_to_acis(i);
|
self.sync_truck_solids_to_acis(i);
|
||||||
self.stamp_thumbnail(i);
|
let ver = self.tabs[i].scene.document.version;
|
||||||
|
self.stamp_thumbnail(i, ver);
|
||||||
match crate::io::save(&self.tabs[i].scene.document, &path) {
|
match crate::io::save(&self.tabs[i].scene.document, &path) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.command_line
|
self.command_line
|
||||||
|
|
@ -890,7 +896,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||||
self.stamp_header_sysvars(i);
|
self.stamp_header_sysvars(i);
|
||||||
self.sync_truck_solids_to_acis(i);
|
self.sync_truck_solids_to_acis(i);
|
||||||
self.stamp_thumbnail(i);
|
self.stamp_thumbnail(i, version);
|
||||||
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
|
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
crate::sys::download_bytes(&filename, &bytes);
|
crate::sys::download_bytes(&filename, &bytes);
|
||||||
|
|
@ -931,7 +937,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
// Persist Ortho / running OSNAP into the header (Save-As path).
|
// Persist Ortho / running OSNAP into the header (Save-As path).
|
||||||
self.stamp_header_sysvars(i);
|
self.stamp_header_sysvars(i);
|
||||||
self.sync_truck_solids_to_acis(i);
|
self.sync_truck_solids_to_acis(i);
|
||||||
self.stamp_thumbnail(i);
|
self.stamp_thumbnail(i, version);
|
||||||
if self.backup_on_save {
|
if self.backup_on_save {
|
||||||
crate::io::write_backup(&path);
|
crate::io::write_backup(&path);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
//! DWG preview thumbnails.
|
//! DWG preview thumbnails.
|
||||||
//!
|
//!
|
||||||
//! - [`from_scene`] / [`from_wires`] rasterize the current layout's wires into a
|
//! - [`from_scene`] rasterizes the drawing exactly as it is framed on screen —
|
||||||
//! small DIB [`acadrust::Preview`], embedded on save so OCS drawings show a
|
//! the live camera's pan / zoom / rotation, only the currently visible region,
|
||||||
//! thumbnail in file browsers and other CAD apps.
|
//! not the whole extent — into a small [`acadrust::Preview`] embedded on save
|
||||||
|
//! so OCS drawings show a thumbnail in file browsers and other CAD apps.
|
||||||
//! - [`read_handle`] / [`extract_to_png`] read a DWG's *embedded* preview back
|
//! - [`read_handle`] / [`extract_to_png`] read a DWG's *embedded* preview back
|
||||||
//! for the Start page and the OS file-manager thumbnailer. Extraction lives in
|
//! for the Start page and the OS file-manager thumbnailer. Extraction lives in
|
||||||
//! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS
|
//! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS
|
||||||
//! thumbnail handlers).
|
//! thumbnail handlers).
|
||||||
|
|
||||||
use acadrust::{Preview, PreviewFormat};
|
use acadrust::{Preview, PreviewFormat};
|
||||||
|
use iced::Rectangle;
|
||||||
use image::{ImageFormat, Rgb, RgbImage};
|
use image::{ImageFormat, Rgb, RgbImage};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
|
@ -16,61 +18,66 @@ use crate::scene::{Scene, WireModel};
|
||||||
|
|
||||||
/// Longest edge of the generated thumbnail, in pixels.
|
/// Longest edge of the generated thumbnail, in pixels.
|
||||||
const MAX_DIM: u32 = 256;
|
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
|
/// Build a preview matching what is currently on screen: the drawing's wires
|
||||||
/// nothing finite to draw (empty or degenerate drawing).
|
/// projected through the live camera into a `viewport`-aspect canvas, so the
|
||||||
pub fn from_scene(scene: &Scene) -> Option<Preview> {
|
/// thumbnail is the visible framing (pan / zoom / rotation, only the on-screen
|
||||||
from_wires(&scene.entity_wires(), scene.bg_color)
|
/// region), not the whole extent. `viewport` is the model pane's pixel size.
|
||||||
}
|
/// `None` when the drawing is empty (clears any stale preview).
|
||||||
|
///
|
||||||
/// Rasterize `wires` (world XY) onto a `bg`-filled canvas and encode a DIB.
|
/// `png` picks the encoding: a line drawing on a flat background is almost all
|
||||||
pub fn from_wires(wires: &[WireModel], bg: [f32; 4]) -> Option<Preview> {
|
/// one colour, so a **PNG** collapses to a few KB where the uncompressed
|
||||||
// ── World-XY bounds over every finite vertex ─────────────────────────────
|
/// **BMP/DIB** stays ~180 KB at 256². PNG previews are only valid from R2013
|
||||||
let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
|
/// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB.
|
||||||
let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
|
pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option<Preview> {
|
||||||
for w in wires {
|
let wires = scene.entity_wires();
|
||||||
for (i, p) in w.points.iter().enumerate() {
|
if wires.is_empty() {
|
||||||
if !p[0].is_finite() || !p[1].is_finite() {
|
return None;
|
||||||
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);
|
let (vw, vh) = viewport;
|
||||||
if !dw.is_finite() || !dh.is_finite() || (dw <= 0.0 && dh <= 0.0) {
|
if !(vw > 0.0 && vh > 0.0) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Canvas sized to the drawing's aspect, longest edge = MAX_DIM ─────────
|
// Canvas keeps the viewport's aspect so the framing is undistorted; longest
|
||||||
let aspect = if dh > 0.0 { dw / dh } else { f64::INFINITY };
|
// edge = MAX_DIM. Projecting with the canvas rectangle as the camera bounds
|
||||||
let (cw, ch) = if aspect >= 1.0 {
|
// makes `project` return pixel coordinates already in canvas space.
|
||||||
|
let (cw, ch) = canvas_dims((vw / vh) as f64);
|
||||||
|
let bounds = Rectangle {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
width: cw as f32,
|
||||||
|
height: ch as f32,
|
||||||
|
};
|
||||||
|
let cam = scene.camera.borrow();
|
||||||
|
rasterize(&wires, cw, ch, scene.bg_color, png, |x, y, z| {
|
||||||
|
cam.project(glam::DVec3::new(x, y, z), bounds)
|
||||||
|
.map(|s| (s.x.round() as i32, s.y.round() as i32))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canvas dimensions for an aspect ratio, longest edge = [`MAX_DIM`].
|
||||||
|
fn canvas_dims(aspect: f64) -> (u32, u32) {
|
||||||
|
if aspect >= 1.0 {
|
||||||
(MAX_DIM, ((MAX_DIM as f64 / aspect).round() as u32).clamp(16, MAX_DIM))
|
(MAX_DIM, ((MAX_DIM as f64 / aspect).round() as u32).clamp(16, MAX_DIM))
|
||||||
} else {
|
} else {
|
||||||
(((MAX_DIM as f64 * aspect).round() as u32).clamp(16, MAX_DIM), MAX_DIM)
|
(((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,
|
/// Rasterize `wires` onto a `bg`-filled `cw`×`ch` canvas, placing each vertex
|
||||||
// Y flipped (world up → image row 0 at top).
|
/// with `project` (world XYZ → canvas pixel, `None` = not projectable), and
|
||||||
let sx = (cw as f64 - 2.0 * MARGIN) / dw.max(1e-9);
|
/// encode the result. A `None` from `project` breaks the polyline run, as does a
|
||||||
let sy = (ch as f64 - 2.0 * MARGIN) / dh.max(1e-9);
|
/// NaN separator, so off-screen / clipped segments simply don't draw.
|
||||||
let scale = sx.min(sy);
|
fn rasterize(
|
||||||
let off_x = (cw as f64 - dw * scale) * 0.5;
|
wires: &[WireModel],
|
||||||
let off_y = (ch as f64 - dh * scale) * 0.5;
|
cw: u32,
|
||||||
let to_px = |x: f64, y: f64| -> (i32, i32) {
|
ch: u32,
|
||||||
let px = off_x + (x - min_x) * scale;
|
bg: [f32; 4],
|
||||||
let py = ch as f64 - (off_y + (y - min_y) * scale); // flip Y
|
png: bool,
|
||||||
(px.round() as i32, py.round() as i32)
|
project: impl Fn(f64, f64, f64) -> Option<(i32, i32)>,
|
||||||
};
|
) -> Option<Preview> {
|
||||||
|
let mut img = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg)));
|
||||||
// ── Rasterize ────────────────────────────────────────────────────────────
|
|
||||||
let bg_rgb = to_rgb(bg);
|
|
||||||
let mut img = RgbImage::from_pixel(cw, ch, Rgb(bg_rgb));
|
|
||||||
for w in wires {
|
for w in wires {
|
||||||
let col = Rgb(to_rgb(w.color));
|
let col = Rgb(to_rgb(w.color));
|
||||||
let mut prev: Option<(i32, i32)> = None;
|
let mut prev: Option<(i32, i32)> = None;
|
||||||
|
|
@ -79,33 +86,120 @@ pub fn from_wires(wires: &[WireModel], bg: [f32; 4]) -> Option<Preview> {
|
||||||
prev = None; // NaN separator breaks the run
|
prev = None; // NaN separator breaks the run
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (x, y) = abs_xy(w, i, p);
|
let (x, y, z) = abs_xyz(w, i, p);
|
||||||
let cur = to_px(x, y);
|
let cur = project(x, y, z);
|
||||||
if let Some(p0) = prev {
|
if let (Some(a), Some(b)) = (prev, cur) {
|
||||||
draw_line(&mut img, p0, cur, col);
|
draw_line(&mut img, a, b, col);
|
||||||
}
|
}
|
||||||
prev = Some(cur);
|
prev = cur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
encode(img, png)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Encode BMP, strip the 14-byte BITMAPFILEHEADER → DIB ─────────────────
|
/// Encode the canvas. PNG for R2013+ targets (few KB); else a BMP → DIB (no
|
||||||
|
/// 14-byte BITMAPFILEHEADER, which the DWG preview container doesn't carry).
|
||||||
|
/// The BMP is an 8-bit **RLE8**-compressed DIB — a line drawing on a flat
|
||||||
|
/// background is a few distinct colours with long single-colour runs, so it
|
||||||
|
/// collapses from the ~180 KB of a 24-bit DIB to a handful of KB. A view with
|
||||||
|
/// more than 256 distinct colours (rare) can't be palettised, so it falls back
|
||||||
|
/// to the 24-bit uncompressed DIB.
|
||||||
|
fn encode(img: RgbImage, png: bool) -> Option<Preview> {
|
||||||
|
if png {
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, ImageFormat::Png).ok()?;
|
||||||
|
let data = buf.into_inner();
|
||||||
|
return (!data.is_empty()).then_some(Preview { format: PreviewFormat::Png, data });
|
||||||
|
}
|
||||||
|
let data = rle8_dib(&img).or_else(|| bmp24_dib(&img))?;
|
||||||
|
Some(Preview { format: PreviewFormat::Bmp, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an 8-bit palettised, `BI_RLE8`-compressed DIB. `None` when the image
|
||||||
|
/// holds more than 256 distinct colours (the caller then uses 24-bit).
|
||||||
|
fn rle8_dib(img: &RgbImage) -> Option<Vec<u8>> {
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
// Exact palette + per-pixel index (top-to-bottom, left-to-right).
|
||||||
|
let mut palette: Vec<[u8; 3]> = Vec::new();
|
||||||
|
let mut lut: std::collections::HashMap<[u8; 3], u8> = std::collections::HashMap::new();
|
||||||
|
let mut idx = Vec::with_capacity((w * h) as usize);
|
||||||
|
for px in img.pixels() {
|
||||||
|
let c = [px.0[0], px.0[1], px.0[2]];
|
||||||
|
let i = if let Some(&i) = lut.get(&c) {
|
||||||
|
i
|
||||||
|
} else {
|
||||||
|
if palette.len() >= 256 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let i = palette.len() as u8;
|
||||||
|
palette.push(c);
|
||||||
|
lut.insert(c, i);
|
||||||
|
i
|
||||||
|
};
|
||||||
|
idx.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RLE8 body, rows bottom-up (BMP stores the last image row first). Encoded
|
||||||
|
// runs only: 2 bytes per single-colour run — ideal for flat-fill previews.
|
||||||
|
let mut rle = Vec::new();
|
||||||
|
for (n, row) in (0..h).rev().enumerate() {
|
||||||
|
let line = &idx[(row * w) as usize..(row * w + w) as usize];
|
||||||
|
let mut x = 0usize;
|
||||||
|
while x < line.len() {
|
||||||
|
let v = line[x];
|
||||||
|
let mut run = 1usize;
|
||||||
|
while x + run < line.len() && line[x + run] == v && run < 255 {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
rle.push(run as u8);
|
||||||
|
rle.push(v);
|
||||||
|
x += run;
|
||||||
|
}
|
||||||
|
if n + 1 < h as usize {
|
||||||
|
rle.extend_from_slice(&[0, 0]); // end of line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rle.extend_from_slice(&[0, 1]); // end of bitmap
|
||||||
|
|
||||||
|
// BITMAPINFOHEADER (40) + full 256-entry palette (BGRA) + RLE body. The
|
||||||
|
// 256-entry palette is required: the reader derives the pixel-data offset as
|
||||||
|
// `(1 << bitCount) * 4`, so a short palette would misplace it.
|
||||||
|
let mut dib = Vec::with_capacity(40 + 1024 + rle.len());
|
||||||
|
dib.extend_from_slice(&40u32.to_le_bytes()); // biSize
|
||||||
|
dib.extend_from_slice(&(w as i32).to_le_bytes());
|
||||||
|
dib.extend_from_slice(&(h as i32).to_le_bytes()); // + = bottom-up
|
||||||
|
dib.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||||
|
dib.extend_from_slice(&8u16.to_le_bytes()); // biBitCount
|
||||||
|
dib.extend_from_slice(&1u32.to_le_bytes()); // biCompression = BI_RLE8
|
||||||
|
dib.extend_from_slice(&(rle.len() as u32).to_le_bytes()); // biSizeImage
|
||||||
|
dib.extend_from_slice(&0i32.to_le_bytes()); // biXPelsPerMeter
|
||||||
|
dib.extend_from_slice(&0i32.to_le_bytes()); // biYPelsPerMeter
|
||||||
|
dib.extend_from_slice(&256u32.to_le_bytes()); // biClrUsed
|
||||||
|
dib.extend_from_slice(&0u32.to_le_bytes()); // biClrImportant
|
||||||
|
for i in 0..256 {
|
||||||
|
let c = palette.get(i).copied().unwrap_or([0, 0, 0]);
|
||||||
|
dib.extend_from_slice(&[c[2], c[1], c[0], 0]); // BGRA
|
||||||
|
}
|
||||||
|
dib.extend_from_slice(&rle);
|
||||||
|
Some(dib)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 24-bit uncompressed DIB (fallback): the `image` BMP minus its file header.
|
||||||
|
fn bmp24_dib(img: &RgbImage) -> Option<Vec<u8>> {
|
||||||
let mut buf = Cursor::new(Vec::new());
|
let mut buf = Cursor::new(Vec::new());
|
||||||
img.write_to(&mut buf, ImageFormat::Bmp).ok()?;
|
img.write_to(&mut buf, ImageFormat::Bmp).ok()?;
|
||||||
let bmp = buf.into_inner();
|
let bmp = buf.into_inner();
|
||||||
if bmp.len() <= 14 {
|
(bmp.len() > 14).then(|| bmp[14..].to_vec())
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Preview { format: PreviewFormat::Bmp, data: bmp[14..].to_vec() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute world XY of vertex `i`, reconstructing the double-single residual.
|
/// Absolute world XYZ of vertex `i`, reconstructing the double-single residual.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn abs_xy(w: &WireModel, i: usize, p: &[f32; 3]) -> (f64, f64) {
|
fn abs_xyz(w: &WireModel, i: usize, p: &[f32; 3]) -> (f64, f64, f64) {
|
||||||
let (lx, ly) = w
|
let (lx, ly, lz) = w
|
||||||
.points_low
|
.points_low
|
||||||
.get(i)
|
.get(i)
|
||||||
.map_or((0.0, 0.0), |l| (l[0] as f64, l[1] as f64));
|
.map_or((0.0, 0.0, 0.0), |l| (l[0] as f64, l[1] as f64, l[2] as f64));
|
||||||
(p[0] as f64 + lx, p[1] as f64 + ly)
|
(p[0] as f64 + lx, p[1] as f64 + ly, p[2] as f64 + lz)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|
@ -157,40 +251,85 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepend a `BITMAPFILEHEADER` to a 24-bit DIB so `image` can decode it.
|
/// Prepend a `BITMAPFILEHEADER` so `image` can decode the DIB. Mirrors the
|
||||||
|
/// palette-aware offset the shared `dwg_thumbnailer::dib_to_bmp` computes.
|
||||||
fn dib_to_bmp(dib: &[u8]) -> Vec<u8> {
|
fn dib_to_bmp(dib: &[u8]) -> Vec<u8> {
|
||||||
|
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());
|
let mut v = Vec::with_capacity(14 + dib.len());
|
||||||
v.extend_from_slice(b"BM");
|
v.extend_from_slice(b"BM");
|
||||||
v.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
|
v.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
|
||||||
v.extend_from_slice(&0u32.to_le_bytes());
|
v.extend_from_slice(&0u32.to_le_bytes());
|
||||||
v.extend_from_slice(&54u32.to_le_bytes()); // 14 + 40, no palette (24-bit)
|
v.extend_from_slice(&((14 + bi_size + palette) as u32).to_le_bytes());
|
||||||
v.extend_from_slice(dib);
|
v.extend_from_slice(dib);
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_input_yields_none() {
|
fn canvas_keeps_aspect_with_max_dim_edge() {
|
||||||
assert!(from_wires(&[], [0.0, 0.0, 0.0, 1.0]).is_none());
|
assert_eq!(canvas_dims(1.0), (MAX_DIM, MAX_DIM));
|
||||||
|
assert_eq!(canvas_dims(2.0), (MAX_DIM, MAX_DIM / 2)); // wide
|
||||||
|
assert_eq!(canvas_dims(0.5), (MAX_DIM / 2, MAX_DIM)); // tall
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draws_a_valid_non_blank_dib() {
|
fn rasterize_draws_a_valid_non_blank_dib() {
|
||||||
let bg = [0.1, 0.1, 0.1, 1.0];
|
let bg = [0.1, 0.1, 0.1, 1.0];
|
||||||
// A closed square (connected polyline) in a distinct colour.
|
// 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]],
|
let sq = wire(
|
||||||
[1.0, 0.0, 0.0, 1.0]);
|
&[[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0], [10.0, 10.0]],
|
||||||
let p = from_wires(&[sq], bg).expect("some preview");
|
[1.0, 0.0, 0.0, 1.0],
|
||||||
|
);
|
||||||
|
// Trivial projector: world XY straight to pixels (Y flipped), z ignored.
|
||||||
|
let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, |x, y, _| {
|
||||||
|
Some((x.round() as i32, (MAX_DIM as f64 - y).round() as i32))
|
||||||
|
})
|
||||||
|
.expect("some preview");
|
||||||
assert_eq!(p.format, PreviewFormat::Bmp);
|
assert_eq!(p.format, PreviewFormat::Bmp);
|
||||||
// DIB starts with a 40-byte BITMAPINFOHEADER.
|
// DIB starts with a 40-byte BITMAPINFOHEADER.
|
||||||
assert_eq!(&p.data[0..4], &40u32.to_le_bytes());
|
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();
|
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));
|
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);
|
let bg_px = to_rgb(bg);
|
||||||
assert!(img.pixels().any(|px| px.0 != bg_px), "nothing drawn");
|
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");
|
assert!(img.pixels().any(|px| px.0[0] > 128 && px.0[1] < 64), "square not red");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rle8_bmp_is_8bit_compressed_and_round_trips() {
|
||||||
|
let bg = [1.0, 1.0, 1.0, 1.0];
|
||||||
|
let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
let proj = |x: f64, y: f64, _z: f64| Some((x.round() as i32, y.round() as i32));
|
||||||
|
let bmp = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, proj).unwrap();
|
||||||
|
assert_eq!(bmp.format, PreviewFormat::Bmp);
|
||||||
|
// 8-bit, BI_RLE8.
|
||||||
|
assert_eq!(u16::from_le_bytes([bmp.data[14], bmp.data[15]]), 8, "bitcount");
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes([bmp.data[16], bmp.data[17], bmp.data[18], bmp.data[19]]),
|
||||||
|
1,
|
||||||
|
"compression = BI_RLE8"
|
||||||
|
);
|
||||||
|
// Far under a 24-bit DIB of the same canvas (256·256·3 = 196 608).
|
||||||
|
assert!(bmp.data.len() < 196_608 / 10, "rle8 {} not << 24-bit", bmp.data.len());
|
||||||
|
// Decodes through the exact path the reader uses, line preserved.
|
||||||
|
let img = image::load_from_memory(&dib_to_bmp(&bmp.data)).expect("rle8 decodes").to_rgb8();
|
||||||
|
assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM));
|
||||||
|
assert!(img.pixels().any(|px| px.0 == [0, 0, 0]), "black line missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn png_preview_decodes() {
|
||||||
|
let bg = [1.0, 1.0, 1.0, 1.0];
|
||||||
|
let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, true, |x, y, _| {
|
||||||
|
Some((x.round() as i32, y.round() as i32))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.format, PreviewFormat::Png);
|
||||||
|
let img = image::load_from_memory_with_format(&p.data, ImageFormat::Png).expect("png decodes");
|
||||||
|
assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bresenham line, clipped to the image bounds.
|
/// Bresenham line, clipped to the image bounds.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue