feat(underlay): render PDF underlays end-to-end

PDF underlays only drew their clip outline / insertion cross; the page
itself never appeared. The chain now runs: the definition's file path
resolves with the raster-image fallbacks (as stored, relative to the
drawing, basename next to the drawing — attach-time relative paths go
stale), the page rasterises through the system pdftoppm (poppler) at
150 dpi — no new crate; missing tool degrades to the outline — cached
per (path, page) including negative results, and the pixels feed the
existing image pipeline as an ImageModel: 1 underlay unit = 1 PDF inch
(px/dpi × entity scale), rotated about the insertion, fade as opacity,
the clip polygon (non-inverted) as the textured region. Draw-order,
layer visibility and per-viewport freezes ride the generic image path;
in-place edits rebuild through the shared image seed.

When the page renders, the wire path draws its frame with an interior
pick surface instead of the lone cross, so selection covers the visible
page. wasm keeps the outline placeholder (no external process).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-23 03:41:41 +03:00
1 parent ac0a6ac150
commit 1e913e9e78
7 files changed
+322 -16

No files matched your search

+45
View File
@@ -37,6 +37,38 @@ fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> {
// ── TruckConvertible ──────────────────────────────────────────────────────────
/// The underlay's page rectangle in world space (CCW from the insertion),
/// when its definition resolves and the page rasterises: page inches × the
/// entity scale, rotated about the insertion. `None` keeps the cross
/// placeholder.
fn page_quad(
u: &Underlay,
document: &acadrust::CadDocument,
) -> Option<[[f64; 3]; 4]> {
let def = match document.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => d,
_ => return None,
};
let page = if def.page_name.trim().is_empty() {
"1"
} else {
def.page_name.trim()
};
let raster = crate::scene::model::pdf_raster::rasterize_page(&def.file_path, page)?;
let w = raster.width as f64 / raster.dpi as f64 * u.x_scale;
let h = raster.height as f64 / raster.dpi as f64 * u.y_scale;
let (c, s) = (u.rotation.cos(), u.rotation.sin());
let o = &u.insertion_point;
let ux = (c * w, s * w);
let vx = (-s * h, c * h);
Some([
[o.x, o.y, o.z],
[o.x + ux.0, o.y + ux.1, o.z],
[o.x + ux.0 + vx.0, o.y + ux.1 + vx.1, o.z],
[o.x + vx.0, o.y + vx.1, o.z],
])
}
impl TruckConvertible for Underlay {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
let origin = v3(&self.insertion_point);
@@ -64,6 +96,19 @@ impl TruckConvertible for Underlay {
key_vertices: key,
fill_tris: vec![],
})
} else if let Some(q) = page_quad(self, _document) {
// Rendered page: draw its frame so selection/pick cover the
// visible extent instead of a lone cross under the raster.
let pts = vec![q[0], q[1], q[2], q[3], q[0]];
let pick_tris = crate::entities::mesh::triangulate_planar(&q.to_vec());
Some(TruckEntity {
pick_tris,
object: TruckObject::Lines(pts),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
tangent_geoms: vec![],
key_vertices: q.to_vec(),
fill_tris: vec![],
})
} else {
// No clip boundary: draw a cross at insertion point.
let pts = cross_wire(origin, 1.0);
+14
View File
@@ -246,6 +246,20 @@ fn resolve_raster_image_paths(doc: &mut CadDocument, base_dir: Option<&Path>) {
}
}
}
// Underlay definitions (PDF/DWF/DGN) store their file the same way raster
// images do — resolve them with the same fallbacks so a drawing shipped
// next to its PDF finds it even when the stored relative path is stale.
for o in doc.objects.values_mut() {
if let ObjectType::UnderlayDefinition(def) = o {
if def.file_path.trim().is_empty() {
continue;
}
if let Some(resolved) = resolve_image_file(&def.file_path, base_dir) {
def.file_path = resolved;
}
}
}
}
/// Resolve a (possibly foreign / absolute) image path to an existing file:
+18 -15
View File
@@ -129,11 +129,7 @@ impl Scene {
} else {
None
};
let image_seed = if let EntityType::RasterImage(img) = &entity {
ImageModel::from_raster_image(img)
} else {
None
};
let image_seed = self.image_seed_for(&entity);
let facet_res = self.document.header.facet_resolution;
let isolines = self.document.header.isolines.max(0) as usize;
let mesh_seed = if matches!(
@@ -321,11 +317,7 @@ impl Scene {
} else {
None
};
let image_seed = if let EntityType::RasterImage(img) = &entity {
ImageModel::from_raster_image(img)
} else {
None
};
let image_seed = self.image_seed_for(&entity);
let facet_res = self.document.header.facet_resolution;
let isolines = self.document.header.isolines.max(0) as usize;
let mesh_seed = if matches!(
@@ -402,11 +394,7 @@ impl Scene {
} else {
None
};
let image_seed = if let EntityType::RasterImage(img) = entity {
ImageModel::from_raster_image(img)
} else {
None
};
let image_seed = self.image_seed_for(entity);
let facet_res = self.document.header.facet_resolution;
let isolines = self.document.header.isolines.max(0) as usize;
let mesh_seed = if matches!(
@@ -1716,6 +1704,21 @@ impl Scene {
///
/// Called after loading a document or after undo/redo so that every
/// `Solid3D` entity is represented in the mesh cache.
/// ImageModel for an edited image-bearing entity (RasterImage, or a PDF
/// UNDERLAY resolved through its definition object). `None` for others.
fn image_seed_for(&self, entity: &acadrust::entities::EntityType) -> Option<ImageModel> {
match entity {
EntityType::RasterImage(img) => ImageModel::from_raster_image(img),
EntityType::Underlay(u) => match self.document.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
ImageModel::from_underlay(u, def)
}
_ => None,
},
_ => None,
}
}
pub fn populate_meshes_from_document(&mut self) {
self.populate_meshes_impl(false);
}
+9 -1
View File
@@ -308,7 +308,9 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
let h = e.common().handle;
match e {
EntityType::Hatch(_) | EntityType::Solid(_) => hatch_handles.push(h),
EntityType::RasterImage(_) | EntityType::Ole2Frame(_) => image_handles.push(h),
EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) => {
image_handles.push(h)
}
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => {
mesh_handles.push(h)
}
@@ -352,6 +354,12 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
EntityType::Ole2Frame(ole) => {
ImageModel::from_ole2frame(ole).map(|m| (handle, m))
}
EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
ImageModel::from_underlay(u, def).map(|m| (handle, m))
}
_ => None,
},
_ => None,
})
.collect();
+117
View File
@@ -181,6 +181,123 @@ impl ImageModel {
}
}
impl ImageModel {
/// Build an ImageModel from a PDF UNDERLAY + its definition object.
///
/// The page rasterises through `pdf_raster` (system pdftoppm, cached);
/// the world quad sizes from the page's physical size — 1 underlay unit =
/// 1 PDF inch — times the entity's scale, placed at the insertion with
/// the entity's rotation. `None` when the underlay is off, non-PDF, or
/// the page can't be rendered (caller keeps the outline placeholder).
pub fn from_underlay(
u: &acadrust::entities::Underlay,
def: &acadrust::entities::UnderlayDefinition,
) -> Option<Self> {
use acadrust::entities::{UnderlayDisplayFlags, UnderlayType};
if !u.flags.contains(UnderlayDisplayFlags::ON) {
return None;
}
if !matches!(def.underlay_type, UnderlayType::Pdf) {
return None;
}
let page = if def.page_name.trim().is_empty() {
"1"
} else {
def.page_name.trim()
};
let raster = super::pdf_raster::rasterize_page(&def.file_path, page)?;
// Page size in drawing units (1 unit per PDF inch), entity scale applied.
let w_du = raster.width as f64 / raster.dpi as f64 * u.x_scale;
let h_du = raster.height as f64 / raster.dpi as f64 * u.y_scale;
if w_du <= 0.0 || h_du <= 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 = u.insertion_point.x;
let oyv = u.insertion_point.y;
let ozv = u.insertion_point.z;
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];
// Visible region: the clip polygon (vertices in underlay units,
// scaled like the quad) fan-triangulated, or the full page. Inverted
// clips fall back to the full page (rare; better whole than hidden).
let clipping = u.flags.contains(UnderlayDisplayFlags::CLIPPING)
&& !u.clip_inverted
&& u.clip_boundary_vertices.len() >= 3;
let tris_uv: Vec<[f64; 2]> = if clipping {
let pts: Vec<[f64; 2]> = u
.clip_boundary_vertices
.iter()
.map(|p| {
(
[
p.x * u.x_scale / w_du,
p.y * u.y_scale / h_du,
]
)
})
.collect();
let mut tris = Vec::with_capacity((pts.len().saturating_sub(2)) * 3);
for i in 1..pts.len() - 1 {
tris.push(pts[0]);
tris.push(pts[i]);
tris.push(pts[i + 1]);
}
tris
} else {
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 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 {
file_path: def.file_path.clone(),
pixels: raster.pixels.clone(),
width: raster.width,
height: raster.height,
opacity: 1.0 - u.fade as f32 / 100.0,
corners,
corners_low,
draw_depth: 0.0,
verts,
})
}
}
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
+1
View File
@@ -4,6 +4,7 @@ pub mod hatch_model;
pub mod hatch_patterns;
pub mod image_model;
pub mod ole_pres;
pub mod pdf_raster;
pub mod mesh_model;
pub mod solid_model;
pub mod object;
+118
View File
@@ -0,0 +1,118 @@
// PDF underlay rasterisation.
//
// No PDF crate: the page renders through the system `pdftoppm` (poppler-utils
// — present on virtually every Linux desktop, installable on Windows/macOS)
// into a temp PNG, which decodes through the existing `image` dependency.
// When the tool is missing the caller falls back to the underlay's outline
// placeholder, so the feature degrades instead of failing the load.
//
// Pages are cached per (resolved path, page name): underlays re-tessellate on
// every geometry bump, but the expensive external render runs once.
use std::sync::Arc;
/// One rasterised PDF page. `dpi` ties the pixel size back to the page's
/// physical size: `inches = px / dpi`, which is what the underlay's world
/// quad is sized from (1 underlay unit = 1 PDF inch, AutoCAD's convention).
pub struct PdfPage {
pub pixels: Arc<Vec<u8>>,
pub width: u32,
pub height: u32,
pub dpi: f32,
}
/// Raster resolution. 150 dpi puts an A4 page at ~1240×1754 px — crisp for
/// normal zooms without ballooning texture memory.
#[cfg(not(target_arch = "wasm32"))]
const RASTER_DPI: u32 = 150;
/// Rasterise `page` (a 1-based page name, e.g. "1") of the PDF at `path`.
/// `None` when the file/page can't be rendered (missing file, no pdftoppm,
/// bad page) — negative results are cached too, so a missing tool doesn't
/// re-spawn a process per tessellation.
#[cfg(not(target_arch = "wasm32"))]
pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<(String, String), Option<Arc<PdfPage>>>>> =
OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let key = (path.to_string(), page.to_string());
if let Ok(c) = cache.lock() {
if let Some(hit) = c.get(&key) {
return hit.clone();
}
}
let built = rasterize_uncached(path, page);
if let Ok(mut c) = cache.lock() {
c.insert(key, built.clone());
}
built
}
#[cfg(not(target_arch = "wasm32"))]
fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
use std::process::Command;
if !std::path::Path::new(path).is_file() {
return None;
}
let page_no: u32 = page.trim().parse().unwrap_or(1).max(1);
// Unique output prefix in the system temp dir; pdftoppm appends
// `-<page>` (with version-dependent zero padding) and `.png`.
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
(path, page_no).hash(&mut hasher);
let prefix = std::env::temp_dir().join(format!("ocs_pdf_{:016x}", hasher.finish()));
let prefix_str = prefix.to_string_lossy().into_owned();
let status = Command::new("pdftoppm")
.args([
"-png",
"-r",
&RASTER_DPI.to_string(),
"-f",
&page_no.to_string(),
"-l",
&page_no.to_string(),
path,
&prefix_str,
])
.status()
.ok()?;
if !status.success() {
return None;
}
// Find the produced file: `<prefix>-1.png`, `-01.png`, … depending on the
// poppler version's padding.
let dir = prefix.parent()?;
let stem = prefix.file_name()?.to_string_lossy().into_owned();
let mut produced: Option<std::path::PathBuf> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(&stem) && name.ends_with(".png") {
produced = Some(entry.path());
break;
}
}
let png = produced?;
let decoded = image::open(&png).ok()?.to_rgba8();
let _ = std::fs::remove_file(&png);
let (width, height) = decoded.dimensions();
Some(Arc::new(PdfPage {
pixels: Arc::new(decoded.into_raw()),
width,
height,
dpi: RASTER_DPI as f32,
}))
}
/// wasm: no external process — underlays keep their outline placeholder.
#[cfg(target_arch = "wasm32")]
pub fn rasterize_page(_path: &str, _page: &str) -> Option<Arc<PdfPage>> {
None
}