feat(image): fetch remote rasters and clip them to their boundary

A raster image reference now resolves through one memoised path shared by
the loader and the unresolved-reference placeholder:
- http/https URLs are fetched natively (rustls, 8s timeout, 32MB cap); a
  local file is read as before. The web build never fetches. The cache is
  cleared when a new document opens.
- Online, the fetched image displays; offline or on a broken/missing
  reference, the frame + saved path text still shows. One resolver keeps the
  two in agreement without fetching twice.

The raster is also clipped to its boundary: the image draws as the
triangulated clip polygon (ear-clipped) with per-vertex UVs instead of a
full quad, so pixels appear only inside the clip. Clip-boundary Y is raster
space (top-down), so it is flipped to the image's Y-up space in both the
outline and the clip triangulation so they align.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-18 20:11:29 +03:00
1 parent 1b974df505
commit b3e5743808
5 files changed
+247 -36

No files matched your search

+19 -9
View File
@@ -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(),
+4
View File
@@ -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
+203 -13
View File
@@ -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<ImageQuadVertex> {
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<ImageQuadVertex>,
}
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<ImageQuadVertex> = 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<u8>, 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<u8>, 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<Vec<u8>>,
pub width: u32,
pub height: u32,
}
fn image_cache() -> &'static Mutex<HashMap<String, Option<DecodedImage>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<DecodedImage>>>> = 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<DecodedImage> {
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<DecodedImage> {
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<u8>, 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<Vec<u8>> {
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<Vec<u8>> {
None
}
+20 -13
View File
@@ -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<ImageVertex> = 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,
+1 -1
View File
@@ -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);