diff --git a/src/entities/underlay.rs b/src/entities/underlay.rs index 0346b55af..247b24453 100644 --- a/src/entities/underlay.rs +++ b/src/entities/underlay.rs @@ -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 { 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); diff --git a/src/io/mod.rs b/src/io/mod.rs index 10375bb80..95672a9f3 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -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: diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a4299de02..2ae94fc7b 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -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 { + 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); } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 163c4c8da..8b43d8b0a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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(); diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 566df4225..c8cab5c44 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -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 { + 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 = 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 diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index bb17c9732..d4014f70a 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -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; diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs new file mode 100644 index 000000000..029f565c9 --- /dev/null +++ b/src/scene/model/pdf_raster.rs @@ -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>, + 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> { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + static CACHE: OnceLock>>>> = + 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> { + 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 + // `-` (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: `-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 = 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> { + None +}