Files
OneCAD-Draft/src/scene/model/image_model.rs
T
ramox81andClaude Opus 5.5 507a6ab43c DWF/DGN underlays: draw content as vector wires
A DWF sheet or DGN model is drawn as world-space wires (one line wire per
colour and width, one fill wire per colour) instead of a raster rescaled
to the view, so it stays sharp while zooming instead of showing pixels
until the raster is made again. Colours go through the colour table
mapping, contrast, fade and monochrome; clip boundaries clip the wires.
The wires are drawn and plotted but neither picked nor snapped; the
underlay's own snap geometry still serves object snaps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 12:43:20 +03:00

532 lines
21 KiB
Rust

// 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<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, or the image with the polygon cut out when the boundary
/// hides its inside.
fn clip_triangles_px(img: &codec::entities::RasterImage) -> Vec<[f64; 2]> {
use codec::entities::ClipMode;
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],
]
};
let Some(poly) = crate::entities::raster_image::image_clip_polygon(img) else {
return quad();
};
// Inside mode shows the image with the boundary cut out.
let (points, triangles) = if img.clip_boundary.clip_mode == ClipMode::Inside {
kernel::geom2d::triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &[poly])
} else {
kernel::geom2d::triangulate(&poly, &[])
};
let tris: Vec<[f64; 2]> = triangles.into_iter().flat_map(|t| t.map(|i| points[i])).collect();
if tris.is_empty() {
quad()
} else {
tris
}
}
#[derive(Clone, Debug)]
pub struct ImageModel {
pub render_instance: Option<super::instance_model::RenderInstance>,
/// 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<Vec<u8>>,
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<ImageQuadVertex>,
/// Magnified pixels stay square (raster images); otherwise they blend
/// (PDF pages, OLE pictures).
pub pixelated: bool,
/// Pixel alpha applies. A raster image whose transparency is off draws
/// every pixel opaque in its stored colour.
pub use_alpha: bool,
}
/// The colour adjustments an underlay's content is drawn with over
/// the background: contrast, monochrome, the background adjustment and (DGN)
/// the fade.
pub(crate) fn underlay_adjust(
u: &codec::entities::Underlay,
def: &codec::entities::UnderlayDefinition,
background: [f32; 4],
) -> super::pdf_raster::PageAdjust {
use codec::entities::{UnderlayDisplayFlags, UnderlayType};
use super::pdf_raster::PageAdjust;
let page = crate::entities::underlay::page_of(def);
// Dark means an HSL lightness under one half: pure blue counts as
// light, (0, 128, 0) as dark.
let bg_max = background[0].max(background[1]).max(background[2]);
let bg_min = background[0].min(background[1]).min(background[2]);
let bg_lum = (bg_max + bg_min) / 2.0;
let dgn = def.underlay_type == UnderlayType::Dgn;
// A DGN model is faded into the background before its contrast is
// applied (the reference's order), so it is drawn opaque.
let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100)));
PageAdjust {
contrast: u.contrast.min(100),
// Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are
// turned over for a dark background; a DGN model's from its
// own colours, over any background.
contrast_pivot: match def.underlay_type {
UnderlayType::Pdf => 500,
UnderlayType::Dwf
if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 =>
{
730
}
UnderlayType::Dwf => 243,
UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot(
&def.file_path,
page,
&super::pdf_layers::hidden_layers(u),
super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]),
fade_before_contrast,
)
.unwrap_or(404),
},
monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
// A DGN model's colours follow the background through its
// colour table, whatever this setting.
adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND)
&& !dgn,
dark_background: bg_lum < 0.5,
fade_before_contrast,
}
}
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: &codec::entities::RasterImage,
) -> Option<Self> {
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<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 {
render_instance: None,
file_path: img.file_path.clone(),
pixels: decoded.pixels,
width: decoded.width,
height: decoded.height,
opacity,
corners,
corners_low,
draw_depth: 0.0,
verts,
pixelated: true,
use_alpha: img
.flags
.contains(codec::entities::ImageDisplayFlags::TRANSPARENCY_ON),
})
}
}
impl ImageModel {
/// Build an ImageModel from a PDF UNDERLAY + its definition object.
///
/// The page rasterises through `pdf_raster` with no page background, so
/// only the drawn content covers the drawing; contrast, monochrome and
/// the background adjustment recolour that raster and fade lowers its
/// opacity. The world quad is the page size in inches (1 drawing unit per
/// page inch) times the entity scale, placed at the insertion with the
/// entity rotation. A clip boundary (on, 2+ vertices, in underlay units)
/// limits the drawn area to its inside, or to the page outside it when
/// inverted. `None` when the underlay is off, non-PDF, or the page can't
/// be rendered (caller keeps the outline placeholder).
/// `world_per_pixel` is the view's scale: the page is rasterised near
/// the size it shows on screen (full size when `None` or when larger),
/// so thin lines stay one crisp pixel wide when zoomed out.
pub fn from_underlay(
u: &codec::entities::Underlay,
def: &codec::entities::UnderlayDefinition,
background: [f32; 4],
world_per_pixel: Option<f64>,
) -> Option<Self> {
use codec::entities::{UnderlayDisplayFlags, UnderlayType};
use super::pdf_raster;
if !u.flags.contains(UnderlayDisplayFlags::ON) {
return None;
}
if def.unloaded {
return None;
}
let page = crate::entities::underlay::page_of(def);
let rect = crate::entities::underlay::definition_rect(def)?;
// The page's longest side on screen, pixels (the view scale is taken
// a step coarser, so the raster is never reduced on screen and its
// one-pixel lines stay unbroken).
let screen_side = world_per_pixel
.filter(|wpp| *wpp > 0.0)
.map(|wpp| {
let w = (rect[2] - rect[0]) * u.x_scale.abs();
let h = (rect[3] - rect[1]) * u.y_scale.abs();
w.max(h) / wpp
});
let (source, raster) = match def.underlay_type {
UnderlayType::Pdf => {
// Hidden PDF layers come from the underlay's layer overrides.
let source = super::pdf_layers::underlay_source(u, &def.file_path);
let inches = (rect[2] - rect[0]).max(rect[3] - rect[1]);
let raster = match screen_side {
Some(side) if inches > 0.0 && side / inches < pdf_raster::DISPLAY_DPI as f64 => {
pdf_raster::rasterize_page_display_at(&source, page, (side / inches).max(8.0) as f32)?
}
_ => pdf_raster::rasterize_page_display(&source, page)?,
};
(source, raster)
}
// DWF sheets and DGN models are drawn as vector wires
// (`underlay_vector::display_wires`).
_ => return None,
};
let pixels = pdf_raster::adjusted_pixels(&source, page, &raster, underlay_adjust(u, def, background));
// The page rectangle in drawing units (1 unit per PDF inch, or per
// DWF/DGN sheet unit), entity scale applied, from its lower-left
// corner.
let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]);
let w_du = page_w * u.x_scale;
let h_du = page_h * u.y_scale;
if w_du.abs() <= 0.0 || h_du.abs() <= 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, oyv, ozv] = crate::entities::underlay::local_to_world(u, [rect[0], rect[1]]);
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];
let tris_uv = underlay_visible_uv(u, rect);
let verts: Vec<ImageQuadVertex> = 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 {
render_instance: None,
file_path: def.file_path.clone(),
pixels,
width: raster.width,
height: raster.height,
opacity: 1.0 - u.fade.min(100) as f32 / 100.0,
corners,
corners_low,
draw_depth: 0.0,
verts,
pixelated: false,
use_alpha: true,
})
}
}
/// Visible region of an underlay page as triangles in page UV (0..1, y up):
/// the whole page, the clip polygon, or — for an inverted clip — the page
/// with the polygon cut out.
fn underlay_visible_uv(u: &codec::entities::Underlay, rect: [f64; 4]) -> Vec<[f64; 2]> {
let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]);
use codec::entities::UnderlayDisplayFlags;
let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
let whole = || 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 clip = crate::entities::underlay::clip_polygon_local(u);
if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 {
return whole();
}
let ring: Vec<[f64; 2]> = clip
.iter()
.map(|p| [(p[0] - rect[0]) / page_w, (p[1] - rect[1]) / page_h])
.collect();
let (points, triangles) = if u.clip_inverted {
kernel::geom2d::triangulate(&page, &[ring])
} else {
kernel::geom2d::triangulate(&ring, &[])
};
triangles
.into_iter()
.flat_map(|t| t.map(|i| points[i]))
.collect()
}
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: &codec::entities::Ole2Frame) -> Option<Self> {
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 {
render_instance: None,
file_path: "OLE2FRAME".to_string(),
pixels: Arc::new(pixels),
width,
height,
opacity: 1.0,
corners,
corners_low,
draw_depth: 0.0,
verts,
pixelated: false,
use_alpha: true,
})
}
}
/// Decoded RGBA image shared between the raster pipeline and the
/// unresolved-reference probe. Cheap to clone — the pixels are `Arc`-shared.
#[derive(Clone, Debug)]
pub struct DecodedImage {
pub pixels: Arc<Vec<u8>>,
pub width: u32,
pub height: u32,
}
type ImageCacheEntry = Arc<OnceLock<Option<DecodedImage>>>;
fn image_cache() -> &'static Mutex<HashMap<String, ImageCacheEntry>> {
static CACHE: OnceLock<Mutex<HashMap<String, ImageCacheEntry>>> = 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;
}
let entry = {
let mut cache = image_cache()
.lock()
.unwrap_or_else(|error| error.into_inner());
Arc::clone(
cache
.entry(path.to_string())
.or_insert_with(|| Arc::new(OnceLock::new())),
)
};
entry.get_or_init(|| decode_reference(path)).clone()
}
/// Decode a reference (local path or remote URL) to 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()?
};
let rgba = img.into_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<Vec<u8>> {
let agent = crate::network::agent(std::time::Duration::from_secs(8));
let mut resp = agent
.get(url)
.header(
"User-Agent",
concat!("OpenCADStudio/", env!("OCS_APP_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
}