diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index 249080f0b..d12a7e1c7 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -88,12 +88,17 @@ impl TruckConvertible for RasterImage { ] }; + // Clip-boundary Y is in image raster space (row 0 = top, Y down); the + // image's v-vector points up, so flip each vertex's Y (`ih - y`) to + // place the boundary where AutoCAD draws it. Must match the raster's + // own clip triangulation in `ImageModel` so outline and pixels align. + let ih = self.size.y; let pts = if self.clipping_enabled { let cb = &self.clip_boundary; match cb.clip_type { acadrust::entities::ClipType::Polygonal if cb.vertices.len() >= 3 => { let mut poly: Vec<[f64; 3]> = - cb.vertices.iter().map(|v| px_to_world(v.x, v.y)).collect(); + cb.vertices.iter().map(|v| px_to_world(v.x, ih - v.y)).collect(); if let Some(&first) = poly.first() { poly.push(first); } @@ -103,7 +108,8 @@ impl TruckConvertible for RasterImage { let v0 = &cb.vertices[0]; let v1 = &cb.vertices[1]; let (xa, xb) = (v0.x.min(v1.x), v0.x.max(v1.x)); - let (ya, yb) = (v0.y.min(v1.y), v0.y.max(v1.y)); + let (y0, y1) = (ih - v0.y, ih - v1.y); + let (ya, yb) = (y0.min(y1), y0.max(y1)); let c0 = px_to_world(xa, ya); let c1 = px_to_world(xb, ya); let c2 = px_to_world(xb, yb); @@ -116,14 +122,18 @@ impl TruckConvertible for RasterImage { image_wire(corners, true) }; - // A raster that OCS can actually open renders its pixels (built - // separately) inside this frame — just draw the frame/clip outline. - // A reference OCS cannot resolve (a URL, or a missing/renamed file) - // gets AutoCAD's broken-reference treatment: the frame plus the saved - // path drawn as text, so the user sees WHICH reference is unresolved - // instead of an empty box. OCS never fetches a remote path. + // A raster OCS can display renders its pixels (built separately) inside + // this frame — just draw the frame/clip outline. A reference it cannot + // resolve (an offline/broken URL, a missing or renamed file) gets + // AutoCAD's broken-reference treatment: the frame plus the saved path + // drawn as text, so the user sees WHICH reference is unresolved instead + // of an empty box. `resolve_image` is memoised and shared with the + // raster loader, so a URL that fetches online is treated as resolvable + // (no placeholder — the image shows) while an offline one falls back to + // the path text, and neither is fetched twice. let path = self.file_path.trim(); - let resolvable = path.is_empty() || std::path::Path::new(path).is_file(); + let resolvable = + path.is_empty() || crate::scene::model::image_model::resolve_image(path).is_some(); if resolvable { return Some(TruckEntity { pick_tris: Vec::new(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 91b1153b5..780de775c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -235,6 +235,10 @@ pub struct OpenTimings { /// Build hatch / image / mesh caches from a document without needing `&mut Scene`. /// Intended to run on a background thread during file load. pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { + // A new drawing must not inherit the previous one's resolved images — drop + // the memoised set so each reference re-reads / re-fetches once here (and + // stays cached across this document's later cache rebuilds). + crate::scene::model::image_model::clear_image_cache(); // model-space block handle (same logic as Scene::model_space_block_handle) let model_block = doc .objects diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index f0678be39..ee42fa8a7 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -3,8 +3,82 @@ // 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; +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 { @@ -34,6 +108,10 @@ pub struct ImageModel { /// 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 { @@ -75,17 +153,35 @@ impl ImageModel { let corners_low = [[oxl, oyl, ozl]; 4]; let opacity = 1.0 - img.fade as f32 / 100.0; - let (pixels, width, height) = load_pixels(&img.file_path)?; + // 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: Arc::new(pixels), - width, - height, + pixels: decoded.pixels, + width: decoded.width, + height: decoded.height, opacity, corners, corners_low, vp_scissor: None, draw_depth: 0.0, + verts, }) } } @@ -127,16 +223,20 @@ impl ImageModel { 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: [c0, c1, c2, c3], - corners_low: [l0, l1, l2, l3], + corners, + corners_low, vp_scissor: None, draw_depth: 0.0, + verts, }) } } @@ -177,10 +277,64 @@ fn decode_ole_bitmap(data: &[u8]) -> Option<(Vec, u32, u32)> { None } -/// Decode a raster image file into RGBA8 pixels. -/// Returns `None` if the file does not exist or cannot be decoded. -pub fn load_pixels(path_str: &str) -> Option<(Vec, u32, u32)> { - let img = image::open(Path::new(path_str)).ok()?; +/// 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. @@ -195,6 +349,42 @@ pub fn load_pixels(path_str: &str) -> Option<(Vec, u32, u32)> { img }; let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - Some((rgba.into_raw(), w, h)) + 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: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(8))) + .build() + .into(); + 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 } diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index a5a0f9f49..d78c3dc43 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -62,6 +62,9 @@ struct ImageParams { pub struct ImageGpu { pub vertex_buffer: wgpu::Buffer, + /// Number of triangle vertices in `vertex_buffer` — 6 for a plain quad, or + /// more when the raster is clipped to a triangulated polygon. + pub vertex_count: u32, pub bind_group: wgpu::BindGroup, /// Mirrors `ImageModel.vp_scissor` — see `HatchGpu.vp_scissor`. pub vp_scissor: Option<[f32; 4]>, @@ -157,19 +160,22 @@ impl ImageGpu { ], }); - // ── Vertex buffer — two triangles ───────────────────────────────── - // corners: [p0=BL, p1=BR, p2=TR, p3=TL] - // UV: BL=(0,1), BR=(1,1), TR=(1,0), TL=(0,0) - let [p0, p1, p2, p3] = model.corners; - let [l0, l1, l2, l3] = model.corners_low; - let verts = [ - ImageVertex { pos: p0, uv: [0.0, 1.0], pos_low: l0 }, - ImageVertex { pos: p1, uv: [1.0, 1.0], pos_low: l1 }, - ImageVertex { pos: p2, uv: [1.0, 0.0], pos_low: l2 }, - ImageVertex { pos: p0, uv: [0.0, 1.0], pos_low: l0 }, - ImageVertex { pos: p2, uv: [1.0, 0.0], pos_low: l2 }, - ImageVertex { pos: p3, uv: [0.0, 0.0], pos_low: l3 }, - ]; + // ── Vertex buffer — the image's visible triangles ───────────────── + // `model.verts` already holds either the full quad or the triangulated + // clip polygon, each vertex carrying an RTE-split position and its UV. + let verts: Vec = model + .verts + .iter() + .map(|v| ImageVertex { + pos: v.pos, + uv: v.uv, + pos_low: v.pos_low, + }) + .collect(); + if verts.is_empty() { + return None; + } + let vertex_count = verts.len() as u32; let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("image.vbuf"), contents: bytemuck::cast_slice(&verts), @@ -178,6 +184,7 @@ impl ImageGpu { Some(Self { vertex_buffer, + vertex_count, bind_group, vp_scissor: model.vp_scissor, _texture: texture, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 5149406ef..92a6bcf9f 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2177,7 +2177,7 @@ impl Pipeline { } pass.set_bind_group(1, &img.bind_group, &[]); pass.set_vertex_buffer(0, img.vertex_buffer.slice(..)); - pass.draw(0..6, 0..1); + pass.draw(0..img.vertex_count, 0..1); } if scissor_active { pass.set_scissor_rect(0, 0, vp.width, vp.height);