main
Drew Owens 0.9.20 source 5e44698 16d ago
//! `swx_render_image` — a high-resolution image of the active document,
//! rendered by SOLIDWORKS's own viewport (0.9.18, track A).
//!
//! Drew, 2026-10-02: *"Does SW have rendering like fusion so we can get a really
//! gorgeous shot of this assembly?"* — and, if it works, *"add a bunch of intel
//! into the bridge to handle rendering."* The Fusion bridge has no render verb
//! at all (docs/FUSION-PARITY-REPORT.md).
//!
//! MEASURED: PhotoView 360 is ABSENT on this edition, so the renderer is the
//! viewport itself — RealView, ambient occlusion, shadows and perspective on the
//! laptop's GPU. `swx_screenshot` (ab's PrintWindow) works but is a screen grab
//! capped at the window (~1568 px wide).
//!
//! HOW — MEASURED 2026-10-05 on the laptop (SOLIDWORKS 2026 SP3, the 110-document
//! Moveo assembly, window MINIMIZED): 1920x1080 in 10.4 s and 3840x2160 in
//! 11.6 s with isometric + shaded + perspective + RealView + AO + zoomToFit, the
//! PNG header matching, every setting read back restored. See docs/RENDERING.md:
//!   * the export is `IModelDoc2::SaveBMP(FileName, Width, Height)` — "Saves the
//!     current view as a bitmap (BMP) file … If WidthIn or HeightIn is less than
//!     or equal to 0, then the view size is based on the current window size."
//!     <https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelDoc2~SaveBMP.html>
//!     It takes a pixel size independent of the window, which the TIF/PNG
//!     "print capture" options do NOT for a model: swTiffPrintDPI, the paper
//!     size and the custom width/height are documented "For drawings only"
//!     (System Options > Export > TIF/PSD/JPG/PNG,
//!     <https://help.solidworks.com/2017/english/api/swconst/filesaveastifpsdoptions.htm>);
//!   * the bridge then converts the BMP to a PNG itself and verifies it BY
//!     EFFECT: the file exists, starts with the PNG signature, and its IHDR
//!     width × height is read back — never echoed from the arguments. If the
//!     export came back smaller than asked (window-capped), the reply says
//!     `capped:true` with the real size;
//!   * every display setting the render changes is CAPTURED BEFORE, RESTORED
//!     AFTER (on error too), read back, and listed in `restoredSettings`. The
//!     document is never saved. The user's settings are never changed silently.

use serde_json::{json, Value};

pub const MAX_SIDE: u64 = 8192;
pub const DEFAULT_W: u64 = 1920;
pub const DEFAULT_H: u64 = 1080;

/// Standard views, `swStandardViews_e` (published:
/// <https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swStandardViews_e.html>).
pub const VIEWS: &[(&str, i32)] = &[
    ("front", 1),
    ("back", 2),
    ("left", 3),
    ("right", 4),
    ("top", 5),
    ("bottom", 6),
    ("isometric", 7),
    ("trimetric", 8),
    ("dimetric", 9),
];

/// Display styles, `swViewDisplayMode_e` (published:
/// <https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swViewDisplayMode_e.html>),
/// set through `IModelView::DisplayMode`.
pub const DISPLAY_STYLES: &[(&str, i32)] = &[
    ("wireframe", 1),
    ("hiddenLinesRemoved", 2),
    ("shaded", 4),
    ("shadedWithEdges", 5),
];

/// Backgrounds this bridge can set: a PLAIN viewport colour
/// (`swColorsBackgroundAppearance_e.swColorsBackgroundAppearance_Plain` = 0,
/// <https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swColorsBackgroundAppearance_e.html>)
/// with `swSystemColorsViewportBackground` as a Win32 COLORREF.
pub const BACKGROUNDS: &[(&str, i64)] = &[("white", 0x00FF_FFFF), ("black", 0)];

/// Does this request change anything ON SCREEN? Anything but a plain export of
/// the current view does (orientation, display style, RealView, AO, shadows,
/// perspective, background, zoom) — and that must respect the user-presence
/// guard exactly as a sweep does.
pub fn changes_display(args: &Value) -> bool {
    let set = |k: &str| args.get(k).map(|v| !v.is_null()).unwrap_or(false);
    let view = args.get("view").and_then(|v| v.as_str()).unwrap_or("current");
    let bg = args.get("background").and_then(|v| v.as_str()).unwrap_or("current");
    view != "current"
        || bg != "current"
        || ["displayStyle", "perspective", "realView", "ambientOcclusion", "shadows"].iter().any(|k| set(k))
        || args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false)
        || args.get("zoomFactor").and_then(|v| v.as_f64()).map(|z| (z - 1.0).abs() > 1e-9).unwrap_or(false)
}

/// The validated request, as the com host receives it.
pub struct Request {
    pub png: std::path::PathBuf,
    pub bmp: std::path::PathBuf,
    pub width: u64,
    pub height: u64,
    pub host_args: Value,
}

fn sanitize(s: &str) -> String {
    let s: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();
    let s = s.trim_matches('_').to_string();
    if s.is_empty() { "document".into() } else { s }
}

/// VALIDATE. Never creates a directory outside the data root's `exports\`.
pub fn request(args: &Value, doc_title: &str) -> Result<Request, Value> {
    let bad = |code: &str, msg: String, hint: &str| Err(crate::verbs::err_code(code, msg, hint));
    let dim = |k: &str, d: u64| -> Result<u64, Value> {
        match args.get(k) {
            None | Some(Value::Null) => Ok(d),
            Some(v) => match v.as_u64() {
                Some(n) if (16..=MAX_SIDE).contains(&n) => Ok(n),
                _ => Err(crate::verbs::err_code("bad_size", format!("{k} must be a whole number of pixels from 16 to {MAX_SIDE}"), "e.g. width:3840, height:2160")),
            },
        }
    };
    let width = dim("width", DEFAULT_W)?;
    let height = dim("height", DEFAULT_H)?;
    let view = args.get("view").and_then(|v| v.as_str()).unwrap_or("current").to_string();
    if view != "current" && !VIEWS.iter().any(|(n, _)| *n == view) {
        return bad("bad_view", format!("view {view:?} is not one of current, {}", VIEWS.iter().map(|v| v.0).collect::<Vec<_>>().join(", ")), "view:\"isometric\" is the usual hero angle");
    }
    let style = args.get("displayStyle").and_then(|v| v.as_str());
    if let Some(s) = style {
        if !DISPLAY_STYLES.iter().any(|(n, _)| *n == s) {
            return bad("bad_display_style", format!("displayStyle {s:?} is not one of {}", DISPLAY_STYLES.iter().map(|v| v.0).collect::<Vec<_>>().join(", ")), "displayStyle:\"shaded\" for a render");
        }
    }
    // FRAMING (0.9.19): MEASURED 2026-10-05, zoomToFit leaves the Moveo
    // assembly at ~25% of a 16:9 frame (the fit box is the window's aspect).
    // `zoomFactor` multiplies IModelView::Scale2 AFTER any fit; the camera is
    // restored as before. MEASURED 2026-10-05: >1 zooms IN (Scale2 0.176 -> 0.352
    // at 2.0, the model twice as large) — and 2.0 clipped the tall Moveo arm;
    // 1.4–1.6 is the suggestion after a fit.
    let zoom_factor = match args.get("zoomFactor") {
        None | Some(Value::Null) => 1.0,
        Some(v) => match v.as_f64() {
            Some(z) if (0.1..=20.0).contains(&z) => z,
            _ => return bad("bad_zoom_factor", "zoomFactor must be a number from 0.1 to 20 (1.0 = as framed)".into(), "e.g. zoomToFit:true, zoomFactor:2.5 to fill a 16:9 frame"),
        },
    };
    let transparent = args.get("transparentBackground").and_then(|v| v.as_bool()).unwrap_or(false);
    let background = args.get("background").and_then(|v| v.as_str()).unwrap_or(if transparent { "transparent" } else { "current" }).to_string();
    if background == "transparent" {
        return bad(
            "render_option_unsupported",
            "a transparent background is not available: SaveBMP writes a 24-bit image with no alpha channel".into(),
            "Use background:\"white\" (or \"black\") and remove it afterwards, or leave it \"current\". Nothing was changed.",
        );
    }
    if background != "current" && !BACKGROUNDS.iter().any(|(n, _)| *n == background) {
        return bad("bad_background", format!("background {background:?} is not one of current, white, black, transparent"), "background:\"white\" is the usual product shot");
    }
    let exports = crate::scratch::root().join("exports");
    let png = match args.get("path").and_then(|p| p.as_str()).filter(|p| !p.trim().is_empty()) {
        Some(p) => {
            let p = crate::verbs::safe_out(p)?;
            let pb = std::path::PathBuf::from(&p);
            if !p.to_lowercase().ends_with(".png") {
                return bad("bad_path", format!("path must end in .png: {p}"), "The render is written as a PNG.");
            }
            match pb.parent() {
                Some(dir) if dir.is_dir() => {}
                _ => {
                    return bad(
                        "output_dir_missing",
                        format!("the folder for {p} does not exist"),
                        "Create it first, or omit `path` to write into the bridge's exports folder. The bridge never creates folders outside its own data root.",
                    )
                }
            }
            if pb.exists() {
                return bad("output_exists", format!("{p} already exists"), "Choose a new file name; the bridge never writes over an existing file.");
            }
            pb
        }
        None => {
            if let Err(e) = std::fs::create_dir_all(&exports) {
                return bad("output_dir_missing", format!("the exports folder {} could not be created: {e}", exports.display()), "Point SWX_SCRATCH_DIR somewhere writable, or pass `path`.");
            }
            let ts = crate::launchos::epoch_ms();
            exports.join(format!("{}-{}-{}x{}-{}.png", sanitize(doc_title.rsplit(['\\', '/']).next().unwrap_or(doc_title).split('.').next().unwrap_or("document")), sanitize(&view), width, height, ts))
        }
    };
    let _ = std::fs::create_dir_all(&exports);
    let bmp = exports.join(format!(".render-{}.bmp", crate::launchos::epoch_ms()));
    let host_args = json!({
        "bmpPath": bmp.to_string_lossy(),
        "width": width,
        "height": height,
        "view": view,
        "viewId": VIEWS.iter().find(|(n, _)| *n == view).map(|v| v.1),
        "displayStyle": style,
        "displayModeId": style.and_then(|s| DISPLAY_STYLES.iter().find(|(n, _)| *n == s).map(|v| v.1)),
        "perspective": args.get("perspective").and_then(|v| v.as_bool()),
        "realView": args.get("realView").and_then(|v| v.as_bool()),
        "ambientOcclusion": args.get("ambientOcclusion").and_then(|v| v.as_bool()),
        "shadows": args.get("shadows").and_then(|v| v.as_bool()),
        "background": background,
        "backgroundColor": BACKGROUNDS.iter().find(|(n, _)| *n == background).map(|v| v.1),
        "zoomToFit": args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false),
        "zoomFactor": zoom_factor,
    });
    Ok(Request { png, bmp, width, height, host_args })
}

// ── BMP in, PNG out ─────────────────────────────────────────────────────────

/// Decode a 24- or 32-bit uncompressed BMP into (width, height, RGB rows top-down).
pub fn decode_bmp(b: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
    if b.len() < 54 || &b[0..2] != b"BM" {
        return Err("not a BMP file".into());
    }
    let le32 = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
    let off = le32(10) as usize;
    let w = le32(18) as i32;
    let h = le32(22) as i32;
    let bpp = u16::from_le_bytes([b[28], b[29]]);
    let comp = le32(30);
    if w <= 0 || h == 0 || !(bpp == 24 || bpp == 32) || !(comp == 0 || comp == 3) {
        return Err(format!("unsupported BMP: {w}x{h}, {bpp} bpp, compression {comp}"));
    }
    let (w, top_down, h) = (w as u32, h < 0, h.unsigned_abs());
    let bytes_pp = (bpp / 8) as usize;
    let stride = ((w as usize * bytes_pp) + 3) & !3;
    if b.len() < off + stride * h as usize {
        return Err("the BMP is shorter than its header says".into());
    }
    let mut rgb = Vec::with_capacity(w as usize * h as usize * 3);
    for y in 0..h as usize {
        let src_row = if top_down { y } else { h as usize - 1 - y };
        let row = &b[off + src_row * stride..off + src_row * stride + w as usize * bytes_pp];
        for px in row.chunks(bytes_pp) {
            rgb.extend_from_slice(&[px[2], px[1], px[0]]);
        }
    }
    Ok((w, h, rgb))
}

fn crc32(data: &[u8]) -> u32 {
    let mut table = [0u32; 256];
    for (i, t) in table.iter_mut().enumerate() {
        let mut c = i as u32;
        for _ in 0..8 {
            c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
        }
        *t = c;
    }
    let mut c = 0xFFFF_FFFFu32;
    for &x in data {
        c = table[((c ^ x as u32) & 0xFF) as usize] ^ (c >> 8);
    }
    c ^ 0xFFFF_FFFF
}

/// Encode RGB8 as a PNG.
pub fn encode_png(w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
    let mut raw = Vec::with_capacity((w as usize * 3 + 1) * h as usize);
    for row in rgb.chunks(w as usize * 3) {
        raw.push(0); // filter: none
        raw.extend_from_slice(row);
    }
    let z = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
    let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
    let mut chunk = |kind: &[u8], data: &[u8]| {
        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
        let mut c = kind.to_vec();
        c.extend_from_slice(data);
        out.extend_from_slice(&c);
        out.extend_from_slice(&crc32(&c).to_be_bytes());
    };
    let mut ihdr = Vec::new();
    ihdr.extend_from_slice(&w.to_be_bytes());
    ihdr.extend_from_slice(&h.to_be_bytes());
    ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8-bit, truecolour, deflate, no filter, no interlace
    chunk(b"IHDR", &ihdr);
    chunk(b"IDAT", &z);
    chunk(b"IEND", &[]);
    out
}

/// READ BACK: (width, height) from a PNG's IHDR, or why it is not a PNG.
pub fn png_dims(b: &[u8]) -> Result<(u32, u32), String> {
    if b.len() < 24 || &b[0..8] != b"\x89PNG\r\n\x1a\n" {
        return Err("the file does not start with the PNG signature".into());
    }
    if &b[12..16] != b"IHDR" {
        return Err("the PNG's first chunk is not IHDR".into());
    }
    let be32 = |o: usize| u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
    Ok((be32(16), be32(20)))
}

// ── the verb ────────────────────────────────────────────────────────────────

pub fn render_image(ctx: &crate::verbs::Ctx, args: &Value) -> Value {
    let started = std::time::Instant::now();
    if args.get("reason").and_then(|r| r.as_str()).map(|r| r.trim().is_empty()).unwrap_or(true) {
        return crate::verbs::err_code("reason_required", "swx_render_image needs a reason", "Say what the image is for; it is ledgered.");
    }
    let state = ctx.call("app_state", json!({}), 30).ok();
    let title = state
        .as_ref()
        .and_then(|s| s.get("activeDocument").and_then(|d| d.as_str()))
        .unwrap_or("document")
        .to_string();
    let req = match request(args, &title) {
        Ok(r) => r,
        Err(e) => return e,
    };
    let job = ctx.jobs.start("render_image", format!("Rendering {}x{}", req.width, req.height), 60);
    job.update("capture, apply, export (SaveBMP), restore", json!({"stage": "render", "width": req.width, "height": req.height}));
    let host = ctx.call("render_image", req.host_args.clone(), crate::verbs3::SWEEP_MAX_SECS);
    let _cleanup = Bmp(req.bmp.clone());
    let r = match host {
        Ok(r) => r,
        Err(e) => {
            // The host restores before it errors; what it restored rides on the error.
            let mut out = crate::verbs::err_code(
                e.code.as_deref().unwrap_or("render_failed"),
                format!("the render failed: {}", e.message),
                "Every display setting the render changed was restored (see restoredSettings); nothing was saved.",
            );
            if let (Value::Object(m), Some(d)) = (&mut out, e.data.as_ref()) {
                for k in ["appliedSettings", "restoredSettings", "restoreFailures"] {
                    if let Some(v) = d.get(k) {
                        m.insert(k.into(), v.clone());
                    }
                }
            }
            crate::ledger::record("render_image", json!({"ok": false, "error": e.message, "reason": args.get("reason")}));
            return out;
        }
    };
    let restored = r.get("restoredSettings").cloned().unwrap_or(Value::Null);
    let applied = r.get("appliedSettings").cloned().unwrap_or(Value::Null);
    let fail = |code: &str, msg: String| {
        let mut out = crate::verbs::err_code(code, msg, "Settings were restored; nothing was saved.");
        if let Value::Object(m) = &mut out {
            m.insert("appliedSettings".into(), applied.clone());
            m.insert("restoredSettings".into(), restored.clone());
        }
        out
    };
    job.update("converting to PNG", json!({"stage": "convert"}));
    let bmp = match std::fs::read(&req.bmp) {
        Ok(b) => b,
        Err(e) => return fail("render_no_file", format!("SOLIDWORKS reported the export but no image is on disk at {} ({e})", req.bmp.display())),
    };
    let (w, h, rgb) = match decode_bmp(&bmp) {
        Ok(x) => x,
        Err(e) => return fail("render_bad_file", format!("the exported image could not be read: {e}")),
    };
    let png = encode_png(w, h, &rgb);
    if let Err(e) = std::fs::write(&req.png, &png) {
        return fail("render_write_failed", format!("the PNG could not be written to {}: {e}", req.png.display()));
    }
    // VERIFY BY EFFECT: read the file back.
    let back = std::fs::read(&req.png).unwrap_or_default();
    let (pw, ph) = match png_dims(&back) {
        Ok(d) => d,
        Err(e) => return fail("render_bad_file", format!("the written file is not a valid PNG: {e}")),
    };
    let capped = (pw as u64, ph as u64) != (req.width, req.height);
    let restore_failures = r.get("restoreFailures").cloned().unwrap_or(json!([]));
    let restore_incomplete = restore_failures.as_array().map(|a| !a.is_empty()).unwrap_or(false);
    // NOTES (0.9.20): a requested setting SOLIDWORKS ignored is said plainly.
    let settings = r.get("settings").cloned().unwrap_or(json!({}));
    let mut notes: Vec<String> = Vec::new();
    if let Some(m) = settings.as_object() {
        for (name, row) in m {
            if row.get("why").and_then(|w| w.as_str()) == Some("setIgnored") {
                notes.push(if name == "realView" {
                    "RealView was requested but SOLIDWORKS left it off — RealView needs a certified graphics \
                     card (this laptop has an RTX 4090 Laptop GPU, not on the certified list) and may also be \
                     unavailable while the window is minimized; the image was rendered shaded with shadows and \
                     ambient occlusion."
                        .to_string()
                } else {
                    format!(
                        "{name} was requested ({}) but SOLIDWORKS left it at {} — the image was rendered without it.",
                        row.get("requested").cloned().unwrap_or(Value::Null),
                        row.get("after").cloned().unwrap_or(Value::Null)
                    )
                });
            }
        }
    }
    crate::ledger::record(
        "render_image",
        json!({"ok": true, "path": req.png.to_string_lossy(), "width": pw, "height": ph, "capped": capped,
               "document": r.get("document"), "reason": args.get("reason")}),
    );
    crate::verbs::ok(json!({
        "path": std::fs::canonicalize(&req.png).unwrap_or(req.png.clone()).to_string_lossy(),
        "bytes": back.len(),
        "width": pw,
        "height": ph,
        "requested": {"width": req.width, "height": req.height},
        "capped": capped,
        "document": r.get("document"),
        "appliedSettings": applied,
        "restoredSettings": restored,
        "restoreFailures": restore_failures,
        // EVERY REQUESTED SETTING (0.9.19): {requested, before, after, changed,
        // restored, why} — "already as asked" is never confused with "not applied".
        "settings": settings,
        "notes": notes,
        // The swconst ids the host read from the installed interop DLL, for the record.
        "enumIds": r.get("enumIds").cloned().unwrap_or(Value::Null),
        // LOUD: a setting the render changed did not read back as restored.
        "restoreIncomplete": restore_incomplete,
        "renderer": "SOLIDWORKS viewport (IModelDoc2::SaveBMP), converted to PNG by the bridge",
        "durationMs": started.elapsed().as_millis() as u64,
        "progress": job.finished(),
        "measured": "2026-10-05 (window minimized; visible-window flicker, 8192 px and background changes still unmeasured)",
        "_hint": if restore_incomplete {
            "THE IMAGE WAS WRITTEN, BUT NOT EVERY DISPLAY SETTING READ BACK AS RESTORED — see restoreFailures and restoredSettings (value = what it was before). Tell the user which of their view settings may have changed; the document was not saved."
        } else if capped {
            "The image came back SMALLER than asked (capped at the window's size) — width/height are what is actually in the file. Settings were restored; the document was not saved."
        } else {
            "The image is at the requested size; width/height were read back from the PNG header. Every display setting the render changed was restored (restoredSettings; `settings` accounts for every one you asked for); the document was not saved. For hero-grade output ask for width:3840, height:2160 — measured 2026-10-05 it took only ~1.2 s longer than 1080p. zoomFactor >1 zooms in; after a fit, 1.4–1.6 usually fills the frame without clipping — 2.0 clipped a tall assembly. Read `notes`: a setting SOLIDWORKS ignored (RealView on an uncertified GPU) is listed there."
        },
    }))
}

/// The temporary BMP is removed on every path.
struct Bmp(std::path::PathBuf);
impl Drop for Bmp {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.0);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn bmp(w: u32, h: u32, top_down: bool) -> Vec<u8> {
        let stride = ((w * 3 + 3) & !3) as usize;
        let mut b = vec![0u8; 54 + stride * h as usize];
        b[0..2].copy_from_slice(b"BM");
        b[10..14].copy_from_slice(&54u32.to_le_bytes());
        b[14..18].copy_from_slice(&40u32.to_le_bytes());
        b[18..22].copy_from_slice(&(w as i32).to_le_bytes());
        b[22..26].copy_from_slice(&(if top_down { -(h as i32) } else { h as i32 }).to_le_bytes());
        b[26..28].copy_from_slice(&1u16.to_le_bytes());
        b[28..30].copy_from_slice(&24u16.to_le_bytes());
        // bottom-up: the LAST row in the file is the top row. Mark the top-left pixel red.
        let top_row = if top_down { 0 } else { h as usize - 1 };
        let o = 54 + top_row * stride;
        b[o..o + 3].copy_from_slice(&[0, 0, 255]); // BGR red
        b
    }

    #[test]
    fn a_bmp_becomes_a_png_whose_header_says_its_size() {
        for td in [false, true] {
            let (w, h, rgb) = decode_bmp(&bmp(37, 21, td)).unwrap();
            assert_eq!((w, h), (37, 21));
            assert_eq!(&rgb[0..3], &[255, 0, 0], "the top-left pixel is red (orientation kept)");
            let png = encode_png(w, h, &rgb);
            assert_eq!(png_dims(&png).unwrap(), (37, 21));
            // A real decoder agrees: inflate the IDAT back to the rows.
            let idat_len = u32::from_be_bytes([png[33], png[34], png[35], png[36]]) as usize;
            let raw = miniz_oxide::inflate::decompress_to_vec_zlib(&png[41..41 + idat_len]).unwrap();
            assert_eq!(raw.len(), (37 * 3 + 1) * 21);
        }
        assert!(png_dims(b"not a png at all, really not").is_err());
        assert!(decode_bmp(b"BMxx").is_err());
    }

    #[test]
    fn only_a_current_view_export_leaves_the_screen_alone() {
        assert!(!changes_display(&json!({"width": 3840, "height": 2160, "reason": "x"})));
        assert!(!changes_display(&json!({"view": "current", "background": "current"})));
        for a in [json!({"view": "isometric"}), json!({"realView": true}), json!({"shadows": false}), json!({"zoomToFit": true}), json!({"background": "white"}), json!({"displayStyle": "shaded"})] {
            assert!(changes_display(&a), "{a}");
        }
    }
}