// ImageModel — CPU-side data for a raster image quad. // // Holds decoded RGBA pixel data and the world-space quad geometry derived // from the RasterImage entity's insertion point, u/v vectors, and pixel size. use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; /// One textured triangle vertex of an image's visible region: an RTE-split /// world position (`pos` high half / `pos_low` low half) plus its texture UV. #[derive(Clone, Copy, Debug)] pub struct ImageQuadVertex { pub pos: [f32; 3], pub uv: [f32; 2], pub pos_low: [f32; 3], } /// Build the two-triangle quad (6 verts) for an unclipped image from its /// corners. Texel (0,0) maps to the top-left corner. fn quad_verts(corners: &[[f32; 3]; 4], corners_low: &[[f32; 3]; 4]) -> Vec { let [p0, p1, p2, p3] = *corners; let [l0, l1, l2, l3] = *corners_low; vec![ ImageQuadVertex { pos: p0, uv: [0.0, 1.0], pos_low: l0 }, ImageQuadVertex { pos: p1, uv: [1.0, 1.0], pos_low: l1 }, ImageQuadVertex { pos: p2, uv: [1.0, 0.0], pos_low: l2 }, ImageQuadVertex { pos: p0, uv: [0.0, 1.0], pos_low: l0 }, ImageQuadVertex { pos: p2, uv: [1.0, 0.0], pos_low: l2 }, ImageQuadVertex { pos: p3, uv: [0.0, 0.0], pos_low: l3 }, ] } /// Visible-region triangles in image PIXEL space (flat, groups of 3): the whole /// image rectangle when unclipped, else the clip rectangle or the triangulated /// clip polygon, or the image with the polygon cut out when the boundary /// hides its inside. fn clip_triangles_px(img: &codec::entities::RasterImage) -> Vec<[f64; 2]> { use codec::entities::ClipMode; let w = img.size.x; let h = img.size.y; let quad = || { vec![ [0.0, 0.0], [w, 0.0], [w, h], [0.0, 0.0], [w, h], [0.0, h], ] }; let Some(poly) = crate::entities::raster_image::image_clip_polygon(img) else { return quad(); }; // Inside mode shows the image with the boundary cut out. let (points, triangles) = if img.clip_boundary.clip_mode == ClipMode::Inside { kernel::geom2d::triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &[poly]) } else { kernel::geom2d::triangulate(&poly, &[]) }; let tris: Vec<[f64; 2]> = triangles.into_iter().flat_map(|t| t.map(|i| points[i])).collect(); if tris.is_empty() { quad() } else { tris } } #[derive(Clone, Debug)] pub struct ImageModel { pub render_instance: Option, /// Original file path (used for reload / display in properties). pub file_path: String, /// RGBA8 pixel data in row-major order. Arc-wrapped so cloning ImageModel /// is O(1) — the pixel bytes are shared, not copied. pub pixels: Arc>, pub width: u32, pub height: u32, /// Opacity: 1.0 = opaque, 0.0 = transparent. pub opacity: f32, /// World-space quad corners (CCW), same order as image_corners() helper: /// [0] origin (bottom-left) /// [1] origin + U*W (bottom-right) /// [2] origin + U*W + V*H (top-right) /// [3] origin + V*H (top-left) pub corners: [[f32; 3]; 4], /// Low residual paired with `corners` (double-single) so the GPU keeps /// sub-unit precision at UTM-scale insertion points. pub corners_low: [[f32; 3]; 4], /// Normalized draw-order depth in (0,1); higher draws on top. Fed to the /// image pipeline as a small clip-z bias so the raster orders correctly /// against other entity types. pub draw_depth: f32, /// Textured triangles of the image's VISIBLE region — the full quad, or /// (when the entity carries a clip boundary) the triangulated clip polygon /// — so the raster is drawn only inside its clip boundary. pub verts: Vec, /// Magnified pixels stay square (raster images); otherwise they blend /// (PDF pages, OLE pictures). pub pixelated: bool, /// Pixel alpha applies. A raster image whose transparency is off draws /// every pixel opaque in its stored colour. pub use_alpha: bool, } /// The colour adjustments an underlay's content is drawn with over /// the background: contrast, monochrome, the background adjustment and (DGN) /// the fade. pub(crate) fn underlay_adjust( u: &codec::entities::Underlay, def: &codec::entities::UnderlayDefinition, background: [f32; 4], ) -> super::pdf_raster::PageAdjust { use codec::entities::{UnderlayDisplayFlags, UnderlayType}; use super::pdf_raster::PageAdjust; let page = crate::entities::underlay::page_of(def); // Dark means an HSL lightness under one half: pure blue counts as // light, (0, 128, 0) as dark. let bg_max = background[0].max(background[1]).max(background[2]); let bg_min = background[0].min(background[1]).min(background[2]); let bg_lum = (bg_max + bg_min) / 2.0; let dgn = def.underlay_type == UnderlayType::Dgn; // A DGN model is faded into the background before its contrast is // applied (the reference's order), so it is drawn opaque. let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8); let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100))); PageAdjust { contrast: u.contrast.min(100), // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are // turned over for a dark background; a DGN model's from its // own colours, over any background. contrast_pivot: match def.underlay_type { UnderlayType::Pdf => 500, UnderlayType::Dwf if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 => { 730 } UnderlayType::Dwf => 243, UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot( &def.file_path, page, &super::pdf_layers::hidden_layers(u), super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]), fade_before_contrast, ) .unwrap_or(404), }, monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), // A DGN model's colours follow the background through its // colour table, whatever this setting. adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && !dgn, dark_background: bg_lum < 0.5, fade_before_contrast, } } impl ImageModel { /// Build an ImageModel from a DXF RasterImage entity. /// Returns `None` if the image file cannot be opened or decoded. pub fn from_raster_image( img: &codec::entities::RasterImage, ) -> Option { let w = img.size.x; let h = img.size.y; // Model-space geometry is drawn in (WCS - world_offset) so large UTM- // scale coordinates stay within f32 precision; offset the image too. // Corners come from a large insertion point plus small u/v spans. // Split each into double-single (high, low) f32 so the GPU keeps // sub-unit precision at UTM scale and after a cross-drawing paste. let oxv = img.insertion_point.x; let oyv = img.insertion_point.y; let ozv = img.insertion_point.z; let ux = (img.u_vector.x * w) as f32; let uy = (img.u_vector.y * w) as f32; let uz = (img.u_vector.z * w) as f32; let vx = (img.v_vector.x * h) as f32; let vy = (img.v_vector.y * h) as f32; let vz = (img.v_vector.z * h) as f32; // High/low split of the anchor; the u/v spans are small and added to // the high half (their own residual is below f32 noise at this scale). let ox = oxv as f32; let oy = oyv as f32; let oz = ozv as f32; let oxl = (oxv - ox as f64) as f32; let oyl = (oyv - oy as f64) as f32; let ozl = (ozv - oz as f64) as f32; let corners = [ [ox, oy, oz], [ox + ux, oy + uy, oz + uz], [ox + ux + vx, oy + uy + vy, oz + uz + vz], [ox + vx, oy + vy, oz + vz], ]; let corners_low = [[oxl, oyl, ozl]; 4]; let opacity = 1.0 - img.fade as f32 / 100.0; // Visible region as textured triangles. When the entity carries a clip // boundary this is the triangulated clip polygon (each pixel-space // vertex mapped to world via u/v and to a texel UV), so the raster is // painted only inside its boundary; otherwise it's the full quad. let verts: Vec = clip_triangles_px(img) .iter() .map(|&[px, py]| { let fu = (px / w) as f32; let fv = (py / h) as f32; ImageQuadVertex { pos: [ox + ux * fu + vx * fv, oy + uy * fu + vy * fv, oz + uz * fu + vz * fv], uv: [fu, 1.0 - fv], pos_low: [oxl, oyl, ozl], } }) .collect(); let decoded = resolve_image(&img.file_path)?; Some(Self { render_instance: None, file_path: img.file_path.clone(), pixels: decoded.pixels, width: decoded.width, height: decoded.height, opacity, corners, corners_low, draw_depth: 0.0, verts, pixelated: true, use_alpha: img .flags .contains(codec::entities::ImageDisplayFlags::TRANSPARENCY_ON), }) } } impl ImageModel { /// Build an ImageModel from a PDF UNDERLAY + its definition object. /// /// The page rasterises through `pdf_raster` with no page background, so /// only the drawn content covers the drawing; contrast, monochrome and /// the background adjustment recolour that raster and fade lowers its /// opacity. The world quad is the page size in inches (1 drawing unit per /// page inch) times the entity scale, placed at the insertion with the /// entity rotation. A clip boundary (on, 2+ vertices, in underlay units) /// limits the drawn area to its inside, or to the page outside it when /// inverted. `None` when the underlay is off, non-PDF, or the page can't /// be rendered (caller keeps the outline placeholder). /// `world_per_pixel` is the view's scale: the page is rasterised near /// the size it shows on screen (full size when `None` or when larger), /// so thin lines stay one crisp pixel wide when zoomed out. pub fn from_underlay( u: &codec::entities::Underlay, def: &codec::entities::UnderlayDefinition, background: [f32; 4], world_per_pixel: Option, ) -> Option { use codec::entities::{UnderlayDisplayFlags, UnderlayType}; use super::pdf_raster; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } if def.unloaded { return None; } let page = crate::entities::underlay::page_of(def); let rect = crate::entities::underlay::definition_rect(def)?; // The page's longest side on screen, pixels (the view scale is taken // a step coarser, so the raster is never reduced on screen and its // one-pixel lines stay unbroken). let screen_side = world_per_pixel .filter(|wpp| *wpp > 0.0) .map(|wpp| { let w = (rect[2] - rect[0]) * u.x_scale.abs(); let h = (rect[3] - rect[1]) * u.y_scale.abs(); w.max(h) / wpp }); let (source, raster) = match def.underlay_type { UnderlayType::Pdf => { // Hidden PDF layers come from the underlay's layer overrides. let source = super::pdf_layers::underlay_source(u, &def.file_path); let inches = (rect[2] - rect[0]).max(rect[3] - rect[1]); let raster = match screen_side { Some(side) if inches > 0.0 && side / inches < pdf_raster::DISPLAY_DPI as f64 => { pdf_raster::rasterize_page_display_at(&source, page, (side / inches).max(8.0) as f32)? } _ => pdf_raster::rasterize_page_display(&source, page)?, }; (source, raster) } // DWF sheets and DGN models are drawn as vector wires // (`underlay_vector::display_wires`). _ => return None, }; let pixels = pdf_raster::adjusted_pixels(&source, page, &raster, underlay_adjust(u, def, background)); // The page rectangle in drawing units (1 unit per PDF inch, or per // DWF/DGN sheet unit), entity scale applied, from its lower-left // corner. let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]); let w_du = page_w * u.x_scale; let h_du = page_h * u.y_scale; if w_du.abs() <= 0.0 || h_du.abs() <= 0.0 { return None; } let (c, s) = (u.rotation.cos(), u.rotation.sin()); let (uxv, uyv) = (c * w_du, s * w_du); let (vxv, vyv) = (-s * h_du, c * h_du); let [oxv, oyv, ozv] = crate::entities::underlay::local_to_world(u, [rect[0], rect[1]]); let ox = oxv as f32; let oy = oyv as f32; let oz = ozv as f32; let oxl = (oxv - ox as f64) as f32; let oyl = (oyv - oy as f64) as f32; let ozl = (ozv - oz as f64) as f32; let (ux, uy) = (uxv as f32, uyv as f32); let (vx, vy) = (vxv as f32, vyv as f32); let corners = [ [ox, oy, oz], [ox + ux, oy + uy, oz], [ox + ux + vx, oy + uy + vy, oz], [ox + vx, oy + vy, oz], ]; let corners_low = [[oxl, oyl, ozl]; 4]; let tris_uv = underlay_visible_uv(u, rect); let verts: Vec = tris_uv .iter() .map(|&[fu, fv]| { let (fu, fv) = (fu as f32, fv as f32); ImageQuadVertex { pos: [ox + ux * fu + vx * fv, oy + uy * fu + vy * fv, oz], uv: [fu, 1.0 - fv], pos_low: [oxl, oyl, ozl], } }) .collect(); Some(Self { render_instance: None, file_path: def.file_path.clone(), pixels, width: raster.width, height: raster.height, opacity: 1.0 - u.fade.min(100) as f32 / 100.0, corners, corners_low, draw_depth: 0.0, verts, pixelated: false, use_alpha: true, }) } } /// Visible region of an underlay page as triangles in page UV (0..1, y up): /// the whole page, the clip polygon, or — for an inverted clip — the page /// with the polygon cut out. fn underlay_visible_uv(u: &codec::entities::Underlay, rect: [f64; 4]) -> Vec<[f64; 2]> { let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]); use codec::entities::UnderlayDisplayFlags; let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; let whole = || vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; let clip = crate::entities::underlay::clip_polygon_local(u); if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 { return whole(); } let ring: Vec<[f64; 2]> = clip .iter() .map(|p| [(p[0] - rect[0]) / page_w, (p[1] - rect[1]) / page_h]) .collect(); let (points, triangles) = if u.clip_inverted { kernel::geom2d::triangulate(&page, &[ring]) } else { kernel::geom2d::triangulate(&ring, &[]) }; triangles .into_iter() .flat_map(|t| t.map(|i| points[i])) .collect() } impl ImageModel { /// Build an ImageModel from an OLE2FRAME's embedded presentation. /// The blob's compound file is parsed for the native raster or the cached /// metafile picture (rasterized by the `gdi` player); returns `None` when /// the frame is degenerate or nothing in the blob decodes, so the caller /// falls back to the frame placeholder. pub fn from_ole2frame(ole: &codec::entities::Ole2Frame) -> Option { let payload = ole.encoded_payload(); let (pixels, width, height) = super::ole_pres::decode(&payload)?; // Frame rectangle in WCS. `upper_left`/`lower_right` name the diagonal; // normalise to left/right/top/bottom so the bitmap sits upright. let left = ole.upper_left_corner.x.min(ole.lower_right_corner.x); let right = ole.upper_left_corner.x.max(ole.lower_right_corner.x); let bottom = ole.upper_left_corner.y.min(ole.lower_right_corner.y); let top = ole.upper_left_corner.y.max(ole.lower_right_corner.y); let z = ole.upper_left_corner.z; if (right - left).abs() < 1e-9 || (top - bottom).abs() < 1e-9 { return None; } // Double-single split per corner so the quad stays precise at UTM scale. let split = |x: f64, y: f64| -> ([f32; 3], [f32; 3]) { let (hx, hy, hz) = (x as f32, y as f32, z as f32); ( [hx, hy, hz], [ (x - hx as f64) as f32, (y - hy as f64) as f32, (z - hz as f64) as f32, ], ) }; // corners: [BL, BR, TR, TL] — the image pipeline maps texel (0,0) to TL. let (c0, l0) = split(left, bottom); let (c1, l1) = split(right, bottom); let (c2, l2) = split(right, top); let (c3, l3) = split(left, top); let corners = [c0, c1, c2, c3]; let corners_low = [l0, l1, l2, l3]; let verts = quad_verts(&corners, &corners_low); Some(Self { render_instance: None, file_path: "OLE2FRAME".to_string(), pixels: Arc::new(pixels), width, height, opacity: 1.0, corners, corners_low, draw_depth: 0.0, verts, pixelated: false, use_alpha: true, }) } } /// Decoded RGBA image shared between the raster pipeline and the /// unresolved-reference probe. Cheap to clone — the pixels are `Arc`-shared. #[derive(Clone, Debug)] pub struct DecodedImage { pub pixels: Arc>, pub width: u32, pub height: u32, } type ImageCacheEntry = Arc>>; fn image_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } /// Drop every memoised image so the next resolve re-reads / re-fetches. Called /// when a new document is opened — a fresh drawing shouldn't inherit a prior /// one's stale successes or offline failures. pub fn clear_image_cache() { if let Ok(mut cache) = image_cache().lock() { cache.clear(); } } /// Resolve an image reference to decoded pixels, memoised per path. Handles a /// local file and — on native builds — an `http`/`https` URL. Returns `None` /// for anything that can't be shown (missing file, offline, decode error, or a /// URL on the web build). The result — including a `None` — is cached so the /// raster loader and the unresolved-reference placeholder agree on one answer /// without fetching twice. pub fn resolve_image(path: &str) -> Option { let path = path.trim(); if path.is_empty() { return None; } let entry = { let mut cache = image_cache() .lock() .unwrap_or_else(|error| error.into_inner()); Arc::clone( cache .entry(path.to_string()) .or_insert_with(|| Arc::new(OnceLock::new())), ) }; entry.get_or_init(|| decode_reference(path)).clone() } /// Decode a reference (local path or remote URL) to RGBA pixels. fn decode_reference(path: &str) -> Option { let lower = path.to_ascii_lowercase(); let img = if lower.starts_with("http://") || lower.starts_with("https://") { let bytes = fetch_remote(path)?; image::load_from_memory(&bytes).ok()? } else { image::open(Path::new(path)).ok()? }; let rgba = img.into_rgba8(); let (width, height) = rgba.dimensions(); Some(DecodedImage { pixels: Arc::new(rgba.into_raw()), width, height, }) } /// Fetch a remote image reference. `http`/`https` only (the caller checks the /// scheme, so `file://` and other schemes are never followed); bounded by a /// request timeout and a response-size cap so a slow or hostile URL can't hang /// the load or exhaust memory. Never runs on the web build — a browser has no /// synchronous fetch and would hit CORS on a cross-origin image anyway. #[cfg(not(target_arch = "wasm32"))] fn fetch_remote(url: &str) -> Option> { let agent = crate::network::agent(std::time::Duration::from_secs(8)); let mut resp = agent .get(url) .header( "User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")), ) .call() .ok()?; const MAX_BYTES: u64 = 32 * 1024 * 1024; resp.body_mut() .with_config() .limit(MAX_BYTES) .read_to_vec() .ok() } #[cfg(target_arch = "wasm32")] fn fetch_remote(_url: &str) -> Option> { None }