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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-18 20:11:29 +03:00
commit b3e5743808
5 changed files with 247 additions and 36 deletions

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,

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);