forked from 1b-agent/opencadstudio-upstream
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:
1 parent
1b974df505
commit
b3e5743808
5 files changed
+247
-36
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user