// 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. An inverted (show-outside) boundary isn't a simple filled /// region, so it falls back to the whole image rather than mis-clip. fn clip_triangles_px(img: &acadrust::entities::RasterImage) -> Vec<[f64; 2]> { use acadrust::entities::{ClipMode, ClipType}; 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], ] }; if !img.clipping_enabled { return quad(); } let cb = &img.clip_boundary; if cb.clip_mode == ClipMode::Inside { return quad(); } // Clip-boundary Y is in image raster space (row 0 = top, Y increasing // downward), whereas this pixel space matches the image's v-vector (Y up // from the insertion corner). Flip each vertex's Y (`h - y`) so the clip // lands where AutoCAD draws it and samples the matching texels. let tris: Vec<[f64; 2]> = match cb.clip_type { ClipType::Rectangular if cb.vertices.len() >= 2 => { let (v0, v1) = (cb.vertices[0], cb.vertices[1]); let (xa, xb) = (v0.x.min(v1.x), v0.x.max(v1.x)); let (y0, y1) = (h - v0.y, h - v1.y); let (ya, yb) = (y0.min(y1), y0.max(y1)); vec![[xa, ya], [xb, ya], [xb, yb], [xa, ya], [xb, yb], [xa, yb]] } ClipType::Polygonal if cb.vertices.len() >= 3 => { let poly: Vec<[f64; 3]> = cb.vertices.iter().map(|v| [v.x, h - v.y, 0.0]).collect(); crate::entities::mesh::triangulate_planar(&poly) .into_iter() .map(|p| [p[0], p[1]]) .collect() } _ => quad(), }; if tris.is_empty() { quad() } else { tris } } #[derive(Clone, Debug)] pub struct ImageModel { /// 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, } 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: &acadrust::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 { file_path: img.file_path.clone(), pixels: decoded.pixels, width: decoded.width, height: decoded.height, opacity, corners, corners_low, draw_depth: 0.0, verts, }) } } impl ImageModel { /// Build an ImageModel from a PDF UNDERLAY + its definition object. /// /// The page rasterises through `pdf_raster` (system pdftoppm, cached); /// the world quad sizes from the page's physical size — 1 underlay unit = /// 1 PDF inch — times the entity's scale, placed at the insertion with /// the entity's rotation. `None` when the underlay is off, non-PDF, or /// the page can't be rendered (caller keeps the outline placeholder). pub fn from_underlay( u: &acadrust::entities::Underlay, def: &acadrust::entities::UnderlayDefinition, ) -> Option { use acadrust::entities::{UnderlayDisplayFlags, UnderlayType}; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } if !matches!(def.underlay_type, UnderlayType::Pdf) { return None; } let page = if def.page_name.trim().is_empty() { "1" } else { def.page_name.trim() }; let raster = super::pdf_raster::rasterize_page(&def.file_path, page)?; // Page size in drawing units (1 unit per PDF inch), entity scale applied. let w_du = raster.width as f64 / raster.dpi as f64 * u.x_scale; let h_du = raster.height as f64 / raster.dpi as f64 * u.y_scale; if w_du <= 0.0 || h_du <= 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 = u.insertion_point.x; let oyv = u.insertion_point.y; let ozv = u.insertion_point.z; 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]; // Visible region: the clip polygon (vertices in underlay units, // scaled like the quad) fan-triangulated, or the full page. Inverted // clips fall back to the full page (rare; better whole than hidden). let clipping = u.flags.contains(UnderlayDisplayFlags::CLIPPING) && !u.clip_inverted && u.clip_boundary_vertices.len() >= 3; let tris_uv: Vec<[f64; 2]> = if clipping { let pts: Vec<[f64; 2]> = u .clip_boundary_vertices .iter() .map(|p| [p.x * u.x_scale / w_du, p.y * u.y_scale / h_du]) .collect(); let mut tris = Vec::with_capacity((pts.len().saturating_sub(2)) * 3); for i in 1..pts.len() - 1 { tris.push(pts[0]); tris.push(pts[i]); tris.push(pts[i + 1]); } tris } else { 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 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 { file_path: def.file_path.clone(), pixels: raster.pixels.clone(), width: raster.width, height: raster.height, opacity: 1.0 - u.fade as f32 / 100.0, corners, corners_low, draw_depth: 0.0, verts, }) } } 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: &acadrust::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 { file_path: "OLE2FRAME".to_string(), pixels: Arc::new(pixels), width, height, opacity: 1.0, corners, corners_low, draw_depth: 0.0, verts, }) } } /// Decoded RGBA image shared between the raster pipeline and the /// unresolved-reference probe. Cheap to clone — the pixels are `Arc`-shared. #[derive(Clone)] pub struct DecodedImage { pub pixels: Arc>, pub width: u32, pub height: u32, } 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; } // Cache probe in its own scope so the lock is released before any fetch. { if let Ok(cache) = image_cache().lock() { if let Some(cached) = cache.get(path) { return cached.clone(); } } } let decoded = decode_reference(path); if let Ok(mut cache) = image_cache().lock() { cache.insert(path.to_string(), decoded.clone()); } decoded } /// Decode a reference (local path or remote URL) to downscaled 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()? }; // GPUs cap 2-D texture dimensions (8192 with wgpu's default limits). // Downscale oversized images to fit, preserving aspect ratio, so texture // creation can't fail — they're displayed scaled-down anyway. const MAX_DIM: u32 = 8192; let img = if img.width() > MAX_DIM || img.height() > MAX_DIM { let longest = img.width().max(img.height()) as f32; let scale = MAX_DIM as f32 / longest; let nw = ((img.width() as f32 * scale) as u32).clamp(1, MAX_DIM); let nh = ((img.height() as f32 * scale) as u32).clamp(1, MAX_DIM); img.resize(nw, nh, image::imageops::FilterType::Triangle) } else { img }; let rgba = img.to_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!("CARGO_PKG_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 }