diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 2247fe1c7..7bed040dc 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1321,6 +1321,10 @@ fn tessellate_entity_inner( set_wire_aabb(&mut wire, WireModel::UNBOUNDED_AABB); bases.push(wire); } + // DWF / DGN content: vector wires, sharp at every zoom. + if let Some(def) = crate::entities::underlay::definition(underlay, document) { + bases.extend(crate::scene::model::underlay_vector::display_wires(underlay, def, &h.value().to_string(), bg_color)); + } } // A hidden mask frame remains selectable and appears while selected, but diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index dfd6d1ea9..d486f146a 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -99,6 +99,59 @@ pub struct ImageModel { 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. @@ -197,7 +250,7 @@ impl ImageModel { world_per_pixel: Option, ) -> Option { use codec::entities::{UnderlayDisplayFlags, UnderlayType}; - use super::pdf_raster::{self, PageAdjust}; + use super::pdf_raster; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } @@ -229,56 +282,11 @@ impl ImageModel { }; (source, raster) } - kind => { - let hidden = super::pdf_layers::hidden_layers(u); - // The adjusted-pixel memo keys on the source: one per set of - // hidden layers. - let source = if hidden.is_empty() { - def.file_path.clone() - } else { - format!("{}#{}", def.file_path, hidden.join("|")) - }; - let raster = super::underlay_vector::display_raster( - kind, - &def.file_path, - page, - &hidden, - screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), - )?; - (source, raster) - } + // DWF sheets and DGN models are drawn as vector wires + // (`underlay_vector::display_wires`). + _ => return None, }; - // 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 pixels = pdf_raster::adjusted_pixels( - &source, - page, - &raster, - 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; DGN 0.65. - 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 => 650, - }, - monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), - adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND), - // A DGN model is drawn for a black background, so its - // colours turn over on a light one instead (white text stays - // light on a dark background, as the reference shows it). - dark_background: (bg_lum < 0.5) != (def.underlay_type == UnderlayType::Dgn), - }, - ); + 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 diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index eab0bdd63..b613984d0 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -217,13 +217,18 @@ pub struct PageAdjust { /// towards `contrast_pivot` (hue and saturation kept). pub contrast: u8, /// The lightness contrast 0 leaves every colour at, in thousandths: - /// measured 500 for PDF, 243 for DWF and 650 for DGN underlays. + /// measured 500 for PDF and 243 for DWF; a DGN model's follows its + /// colours. pub contrast_pivot: u16, pub monochrome: bool, /// Lighten dark, unsaturated content on a dark background (black lines /// and text become white), keeping coloured content. pub adjust_for_background: bool, pub dark_background: bool, + /// A fade blended into the colours before the contrast (a DGN + /// model's): the background and the share, 0..=100, of the way each + /// colour is taken to it. + pub fade_before_contrast: Option<([u8; 3], u8)>, } type AdjustKey = (String, String, PageAdjust); @@ -233,10 +238,43 @@ fn adjusted_cache() -> &'static Mutex>>> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// One colour (0..=1 channels) with the underlay's contrast / monochrome / +/// background adjustments applied. +pub fn adjust_rgb(rgb: [f32; 3], adjust: &PageAdjust) -> [f32; 3] { + let mut rgb = rgb; + if adjust.monochrome { + let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + rgb = [l; 3]; + } + if adjust.adjust_for_background && adjust.dark_background { + // On a dark background every colour's lightness is turned over + // (plus 20/255, capped at full), hue and saturation kept: black + // becomes white, dark red light pink. On a light background the + // colours stay as drawn. + let (h, s, l) = rgb_to_hsl(rgb); + rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); + } + if let Some((bg, fade)) = adjust.fade_before_contrast { + let f = fade.min(100) as f32 / 100.0; + for (c, b) in rgb.iter_mut().zip(bg) { + *c += (b as f32 / 255.0 - *c) * f; + } + } + if adjust.contrast < 100 { + // Lightness is pulled towards the pivot, hue and saturation kept: + // contrast 0 leaves every colour at the pivot's lightness. + let (h, s, l) = rgb_to_hsl(rgb); + let k = adjust.contrast as f32 / 100.0; + let pivot = adjust.contrast_pivot as f32 / 1000.0; + rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); + } + rgb +} + /// The display raster with the underlay's contrast / monochrome / background /// adjustments applied, memoised per source, page and adjustment. pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc> { - let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background; + let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background && adjust.fade_before_contrast.is_none(); if identity { return raster.pixels.clone(); } @@ -255,27 +293,7 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj if px[3] == 0 { continue; } - let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0]; - if adjust.monochrome { - let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; - rgb = [l; 3]; - } - if adjust.adjust_for_background && adjust.dark_background { - // On a dark background every colour's lightness is turned over - // (plus 20/255, capped at full), hue and saturation kept: black - // becomes white, dark red light pink. On a light background the - // colours stay as drawn. - let (h, s, l) = rgb_to_hsl(rgb); - rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); - } - if adjust.contrast < 100 { - // Lightness is pulled towards the pivot, hue and saturation kept: - // contrast 0 leaves every colour at the pivot's lightness. - let (h, s, l) = rgb_to_hsl(rgb); - let k = adjust.contrast as f32 / 100.0; - let pivot = adjust.contrast_pivot as f32 / 1000.0; - rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); - } + let rgb = adjust_rgb([px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &adjust); for (dst, c) in px[..3].iter_mut().zip(rgb) { *dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index 8664c5eca..86e0f9fb7 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; -use super::model::{arc_cubics, bspline_points, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; +use super::model::{arc_cubics, bspline_points, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS}; /// A 32-bit integer stored as two little-endian words, high word first. fn int32(b: &[u8], at: usize) -> Option { @@ -76,36 +76,13 @@ fn elements(b: &[u8]) -> Vec<&[u8]> { out } -/// The file's colour table (element type 5 on level 1): the colour of -/// index i is at 41 + 3i. +/// The file's colour table (element type 5 on level 1): index i < 255 at +/// 41 + 3i, the background (255) at 38. fn color_table(elements: &[&[u8]]) -> Option> { - elements.iter().find_map(|e| { - (e[1] & 0x7f == 5 && e[0] & 0x3f == 1 && e.len() >= 41 + 765).then(|| { - (0..256) - .map(|i| { - let at = 41 + i * 3; - e.get(at..at + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([255, 255, 255]) - }) - .collect() - }) - }) -} - -/// A usable default: the first eight DGN colours, then greys. -// ponytail: The full DGN default table is not reproduced; the files -// the reference writes carry their own colour table. -fn default_colors() -> Vec<[u8; 3]> { - let base: [[u8; 3]; 8] = [ - [255, 255, 255], - [0, 0, 255], - [0, 255, 0], - [255, 0, 0], - [255, 255, 0], - [255, 0, 255], - [255, 127, 0], - [0, 255, 255], - ]; - (0..256).map(|i| if i < 8 { base[i] } else { [i as u8; 3] }).collect() + elements + .iter() + .find(|e| e[1] == 5 && e[0] & 0x3f == 1) + .and_then(|e| dgn_color_table(e, 41)) } /// Model names of a V7 file (it has one). @@ -432,7 +409,9 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { } let u = units(bytes)?; let all = elements(bytes); - let colors = color_table(&all).unwrap_or_else(default_colors); + let table = color_table(&all); + let table_colors = table.is_some(); + let colors = table.unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); let names = level_names(&all); let off = |e: &[u8]| { let l = level(e); @@ -463,7 +442,7 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { let raw = read_run(&top, &colors); let per_unit = u.uor_per_sub * u.sub_per_master; let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -u.origin[0] / per_unit, -u.origin[1] / per_unit]; - let mut sheet = Sheet { sub_per_master: u.sub_per_master, ..Default::default() }; + let mut sheet = Sheet { sub_per_master: u.sub_per_master, table_colors, ..Default::default() }; place(&raw, &m, &defs, 0, &mut sheet); sheet.rect = paths_bounds(&sheet.paths).or_else(|| { let t = sheet.texts.first()?; diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index e1cc34d91..96e81e286 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::io::Read; -use super::model::{arc_cubics, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; +use super::model::{arc_cubics, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS}; // ── Compound file ─────────────────────────────────────────────────────────── @@ -188,7 +188,9 @@ fn elements(d: &[u8]) -> Vec<&[u8]> { // The stream can end inside its last element (its trailing words are // not stored); what is there is still read. let e = &d[at..(at + len).min(d.len())]; - out.push(e); + if !is_deleted(e) { + out.push(e); + } at += len; } out @@ -202,6 +204,12 @@ fn flags(e: &[u8]) -> u16 { u16::from_le_bytes([e[2], e[3]]) } +/// A deleted element (flag 0x80 of its second byte), kept in the stream but +/// no longer part of the file. +fn is_deleted(e: &[u8]) -> bool { + e[1] & 0x80 != 0 +} + /// A component of a complex element, cell or shared cell definition. fn is_component(e: &[u8]) -> bool { flags(e) & 0x4000 != 0 @@ -290,7 +298,7 @@ impl Styles { } } -/// Geometry in raw UORs, placed through a transform. +/// Plan geometry in UORs, and the shared cells it places. #[derive(Default, Clone)] struct Raw { paths: Vec, @@ -301,8 +309,31 @@ struct Raw { #[derive(Clone)] struct Instance { name: String, - m: [f64; 4], - origin: [f64; 2], + /// The definition's placement, with every enclosing one applied. + xf: Xf, +} + +/// A placement in UORs: x' = r[0]·(x, y, z, 1), y' = r[1]·…, z' = r[2]·…. +type Xf = [[f64; 4]; 3]; + +const IDENTITY: Xf = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + +fn xf_vector(m: &Xf, v: [f64; 3]) -> [f64; 3] { + m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2]) +} + +fn xf_point(m: &Xf, p: [f64; 3]) -> [f64; 3] { + m.map(|r| r[0] * p[0] + r[1] * p[1] + r[2] * p[2] + r[3]) +} + +fn xf_compose(outer: &Xf, inner: &Xf) -> Xf { + let mut out = [[0.0; 4]; 3]; + for (i, row) in out.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| outer[i][k] * inner[k][j]).sum::() + if j == 3 { outer[i][3] } else { 0.0 }; + } + } + out } /// A 3D element (flag 0x0800 of its properties word): three coordinates a @@ -311,17 +342,22 @@ fn is_3d(e: &[u8]) -> bool { u32_at(e, 40).is_some_and(|v| v & 0x0800 != 0) } -/// The in-plane x and y axes (projected onto the plan) of a 3D element's -/// orientation quaternion w, x, y, z stored at `at` (the rows of its -/// rotation matrix). -fn quat_axes(e: &[u8], at: usize) -> Option<([f64; 2], [f64; 2])> { +/// The in-plane x and y axes of a 3D element's orientation quaternion w, x, +/// y, z stored at `at` (the rows of its rotation matrix). +fn quat_axes(e: &[u8], at: usize) -> Option<([f64; 3], [f64; 3])> { let (w, x, y, z) = (f64_at(e, at)?, f64_at(e, at + 8)?, f64_at(e, at + 16)?, f64_at(e, at + 24)?); Some(( - [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z)], - [2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z)], + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)], + [2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)], )) } +/// The in-plane axes of a 2D element turned by `rotation` (radians). +fn turned_axes(rotation: f64) -> ([f64; 3], [f64; 3]) { + let (c, s) = (rotation.cos(), rotation.sin()); + ([c, s, 0.0], [-s, c, 0.0]) +} + /// An elliptical arc on axes `u` and `v` (plan projections) as cubics. fn projected_arc(c: [f64; 2], a: f64, b: f64, u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> Vec { let map = |p: [f64; 2]| [c[0] + a * p[0] * u[0] + b * p[1] * v[0], c[1] + a * p[0] * u[1] + b * p[1] * v[1]]; @@ -331,7 +367,8 @@ fn projected_arc(c: [f64; 2], a: f64, b: f64, u: [f64; 2], v: [f64; 2], start: f .collect() } -fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { +/// Reads a run of elements placed through `xf` into plan geometry. +fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { let mut raw = Raw::default(); // A complex shape or chain collects its components into one path. let mut complex: Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)> = None; @@ -363,14 +400,25 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { raw.paths.push(path); } } + let plan = |p: [f64; 3]| { + let q = xf_point(xf, p); + [q[0], q[1]] + }; + let axis = |v: [f64; 3]| { + let q = xf_vector(xf, v); + [q[0], q[1]] + }; for e in run { if e.len() < 104 || styles.hidden.contains(&u32_at(e, 12).unwrap_or(0)) { continue; } let color = styles.color(e); let stroke = Some((color, -1.0)); - let p2 = |at: usize| -> Option<[f64; 2]> { Some([f64_at(e, at)?, f64_at(e, at + 8)?]) }; let d3 = is_3d(e); + // A point: x, y (and z in a 3D element). + let p3 = |at: usize| -> Option<[f64; 3]> { + Some([f64_at(e, at)?, f64_at(e, at + 8)?, if d3 { f64_at(e, at + 16)? } else { 0.0 }]) + }; match kind(e) { 12 | 14 => { let n = u32_at(e, 104).unwrap_or(0) as usize; @@ -380,14 +428,14 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { } } 3 => { - if let (Some(a), Some(b)) = (p2(104), p2(if d3 { 128 } else { 120 })) { - push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(a, b)], closed: false }], stroke, fill: None }); + if let (Some(a), Some(b)) = (p3(104), p3(if d3 { 128 } else { 120 })) { + push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(plan(a), plan(b))], closed: false }], stroke, fill: None }); } } 4 | 6 | 11 => { let n = u32_at(e, 104).unwrap_or(0) as usize; let stride = if d3 { 24 } else { 16 }; - let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p2(112 + k * stride)).collect(); + let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p3(112 + k * stride)).map(plan).collect(); if pts.len() >= 2 { let mut pb = PathBuilder::default(); pb.move_to(pts[0]); @@ -400,14 +448,16 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { push(&mut raw, &mut complex, Path { subpaths: pb.finish(), stroke, fill: None }); } } - 15 | 16 if d3 => { - // Axes, orientation and centre: ellipse a 104, b 112, + 15 | 16 => { + // Radii, orientation and centre. 3D: ellipse a 104, b 112, // quaternion 120, centre 152; arc start 104, sweep 112, a 120, - // b 128, quaternion 136, centre 168. - let arc = if kind(e) == 15 { - (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, quat_axes(e, 120)?, p2(152)?, 0.0, std::f64::consts::TAU)))() - } else { - (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, quat_axes(e, 136)?, p2(168)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + // b 128, quaternion 136, centre 168. 2D: the orientation is an + // angle, ellipse centre 128, arc centre 144. + let arc = match (kind(e), d3) { + (15, true) => (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, quat_axes(e, 120)?, p3(152)?, 0.0, 0.0)))(), + (_, true) => (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, quat_axes(e, 136)?, p3(168)?, f64_at(e, 104)?, f64_at(e, 112)?)))(), + (15, false) => (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, turned_axes(f64_at(e, 120)?), p3(128)?, 0.0, 0.0)))(), + _ => (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, turned_axes(f64_at(e, 136)?), p3(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))(), }; let Some((a, b, (u, v), c, start, sweep)) = arc else { continue }; let sweep = if sweep == 0.0 { std::f64::consts::TAU } else { sweep }; @@ -415,60 +465,52 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { push( &mut raw, &mut complex, - Path { subpaths: vec![SubPath { segments: projected_arc(c, a, b, u, v, start, sweep), closed }], stroke, fill: None }, + Path { subpaths: vec![SubPath { segments: projected_arc(plan(c), a, b, axis(u), axis(v), start, sweep), closed }], stroke, fill: None }, ); } - 15 | 16 => { - let arc = if kind(e) == 15 { - (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, f64_at(e, 120)?, p2(128)?, 0.0, std::f64::consts::TAU)))() + 17 => { + // 3D: width 112, height 120, quaternion 144, origin 176, the + // characters after the string marker. 2D: angle 144, origin + // 152, a byte count (with its 4-byte marker) at 110 and the + // characters from 174. + let placed = if d3 { + (|| Some((f64_at(e, 112)?, f64_at(e, 120)?, quat_axes(e, 144)?, p3(176)?, marked_string(e, 200)?)))() } else { - (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, f64_at(e, 136)?, p2(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + (|| { + let count = u16::from_le_bytes([e[110], e[111]]) as usize; + let chars = e.get(174..170 + count.max(4))?; + let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); + Some((f64_at(e, 112)?, f64_at(e, 120)?, turned_axes(f64_at(e, 144)?), p3(152)?, text)) + })() }; - let Some((a, b, rot, c, start, sweep)) = arc else { continue }; - let pieces = arc_cubics(c, a, b, rot, start, if sweep == 0.0 { std::f64::consts::TAU } else { sweep }); - let closed = sweep.abs() >= std::f64::consts::TAU - 1e-9 || sweep == 0.0; - push( - &mut raw, - &mut complex, - Path { subpaths: vec![SubPath { segments: pieces.into_iter().map(|[a, b, c, d]| Segment::Cubic(a, b, c, d)).collect(), closed }], stroke, fill: None }, - ); - } - 17 if d3 => { - // Width 112, height 120, quaternion 144, origin 176; the - // characters follow the string marker. - let (Some(width), Some(height), Some((u, _)), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), quat_axes(e, 144), p2(176)) else { + let Some((width, height, (u, v), origin, text)) = placed else { continue }; + // The text's axes as the plan shows them: its baseline sets the + // angle, and each axis's plan length scales its size. + let (pu, pv) = (axis(u), axis(v)); + let (su, sv) = (pu[0].hypot(pu[1]), pv[0].hypot(pv[1])); + if su < 1e-9 || sv < 1e-9 { continue; - }; - let Some(text) = marked_string(e, 200) else { continue }; - let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); + } + let (height, width) = (height * 6.0 / 1000.0 * sv, width * 6.0 / 1000.0 * su); raw.texts.push(Text { text, - origin, + origin: plan(origin), height, width_factor: if height > 0.0 { width / height } else { 1.0 }, - rotation: u[1].atan2(u[0]), + rotation: pu[1].atan2(pu[0]), color, font: "txt".to_string(), }); } - 17 => { - let (Some(width), Some(height), Some(rotation), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), f64_at(e, 144), p2(152)) else { - continue; - }; - // The text: a byte count (with its 4-byte marker) and the - // characters after the marker. - let count = u16::from_le_bytes([e[110], e[111]]) as usize; - let Some(chars) = e.get(174..170 + count.max(4)) else { continue }; - let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); - let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); - raw.texts.push(Text { text, origin, height, width_factor: if height > 0.0 { width / height } else { 1.0 }, rotation, color, font: "txt".to_string() }); - } 35 => { - let (Some(m0), Some(m1), Some(m3), Some(m4), Some(origin)) = (f64_at(e, 160), f64_at(e, 168), f64_at(e, 184), f64_at(e, 192), p2(232)) else { - continue; - }; + // Shared cell instance: a 3×3 matrix by rows from 160, the + // origin at 232 (z at 248 in 3D), the definition's name after + // the string marker. + let m: Option> = (0..9).map(|k| f64_at(e, 160 + k * 8)).collect(); + let (Some(m), Some(o)) = (m, p3(232)) else { continue }; let Some(name) = marked_string(e, 248) else { continue }; - raw.instances.push(Instance { name: name.to_ascii_uppercase(), m: [m0, m1, m3, m4], origin }); + let local: Xf = [[m[0], m[1], m[2], o[0]], [m[3], m[4], m[5], o[1]], [m[6], m[7], m[8], o[2]]]; + raw.instances.push(Instance { name: name.to_ascii_uppercase(), xf: xf_compose(xf, &local) }); } _ => {} } @@ -489,18 +531,9 @@ fn apply(m: &Affine, p: [f64; 2]) -> [f64; 2] { [m[0] * p[0] + m[1] * p[1] + m[4], m[2] * p[0] + m[3] * p[1] + m[5]] } -fn compose(o: &Affine, i: &Affine) -> Affine { - [ - o[0] * i[0] + o[1] * i[2], - o[0] * i[1] + o[1] * i[3], - o[2] * i[0] + o[3] * i[2], - o[2] * i[1] + o[3] * i[3], - o[0] * i[4] + o[1] * i[5] + o[4], - o[2] * i[4] + o[3] * i[5] + o[5], - ] -} - -fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: &mut Sheet) { +/// Places plan geometry through the sheet mapping `m`, and the shared cells +/// it instances (each definition read through its own placement). +fn place(raw: &Raw, m: &Affine, defs: &HashMap>, styles: &Styles, depth: usize, out: &mut Sheet) { let map = |p: [f64; 2]| apply(m, p); for path in &raw.paths { out.paths.push(Path { @@ -533,8 +566,7 @@ fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: } for inst in &raw.instances { let Some(def) = defs.get(&inst.name) else { continue }; - let local: Affine = [inst.m[0], inst.m[1], inst.m[2], inst.m[3], inst.origin[0], inst.origin[1]]; - place(def, &compose(m, &local), defs, depth + 1, out); + place(&read_run(def, styles, &inst.xf), m, defs, styles, depth + 1, out); } } @@ -627,11 +659,10 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { let (_, graphics, header) = all.iter().find(|m| m.0.eq_ignore_ascii_case(name)).or_else(|| all.first())?; let non_model = non_model(&cfb); let nm = elements(&non_model); - let colors = nm - .iter() - .find(|e| kind(e) == 5 && e.len() >= 37 + 768) - .map(|e| (0..256).map(|i| [e[37 + i * 3], e[38 + i * 3], e[39 + i * 3]]).collect()) - .unwrap_or_else(|| vec![[255, 255, 255]; 256]); + // Colours 0-254 from byte 37, the background (255) opening the table. + let color_table = nm.iter().find(|e| kind(e) == 5).and_then(|e| dgn_color_table(e, 37)); + let table_colors = color_table.is_some(); + let colors = color_table.unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); let table = level_table(&nm); let levels = table.iter().map(|l| (l.0, l.2)).collect(); let hidden = table @@ -641,7 +672,7 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { .collect(); let styles = Styles { colors, levels, hidden, extended: extended_colors(&cfb) }; // Shared cell definitions: a type-34 element and its components. - let mut defs = HashMap::new(); + let mut defs: HashMap> = HashMap::new(); let mut i = 0; while i < nm.len() { if kind(nm[i]) == 34 && !is_component(nm[i]) { @@ -650,17 +681,46 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { while j < nm.len() && is_component(nm[j]) { j += 1; } - defs.insert(name, read_run(&nm[i + 1..j], &styles)); + defs.insert(name, nm[i + 1..j].to_vec()); i = j; } else { i += 1; } } - let raw = read_run(&elements(graphics), &styles); + let graphics = elements(graphics); + let raw = read_run(&graphics, &styles, &IDENTITY); let (per_unit, sub_uor) = units(header); - let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, 0.0, 0.0]; - let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, ..Default::default() }; - place(&raw, &m, &defs, 0, &mut sheet); - sheet.rect = paths_bounds(&sheet.paths)?; + let origin = global_origin(header); + let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -origin[0] / per_unit, -origin[1] / per_unit]; + let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, table_colors, ..Default::default() }; + place(&raw, &m, &defs, &styles, 0, &mut sheet); + // The model's extent is its elements' stored ranges (text by its full + // box), as the reference sizes a model. + sheet.rect = match stored_range(&graphics) { + Some([x0, y0, x1, y1]) => { + let (lo, hi) = (apply(&m, [x0, y0]), apply(&m, [x1, y1])); + [lo[0], lo[1], hi[0], hi[1]] + } + None => paths_bounds(&sheet.paths)?, + }; Some(sheet) } + +/// The model's global origin (UORs; the design plane point drawn at the +/// model's 0,0). +fn global_origin(header: &[u8]) -> [f64; 2] { + let get = |at: usize| f64_at(header, at).filter(|v| v.is_finite()).unwrap_or(0.0); + [get(4212), get(4220)] +} + +/// The union of the top-level elements' ranges (UORs): each graphic element +/// stores its low corner at 56 and its size at 80, as 64-bit integers. +fn stored_range(graphics: &[&[u8]]) -> Option<[f64; 4]> { + let i64_at = |e: &[u8], at: usize| Some(i64::from_le_bytes(e.get(at..at + 8)?.try_into().ok()?) as f64); + let mut r = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for e in graphics.iter().filter(|e| e.len() >= 104 && !is_component(e)) { + let (Some(x), Some(y), Some(w), Some(h)) = (i64_at(e, 56), i64_at(e, 64), i64_at(e, 80), i64_at(e, 88)) else { continue }; + r = [r[0].min(x), r[1].min(y), r[2].max(x + w), r[3].max(y + h)]; + } + (r[0] <= r[2]).then_some(r) +} diff --git a/src/scene/model/underlay_vector/dwf.rs b/src/scene/model/underlay_vector/dwf.rs index 52e26b16d..e654c0721 100644 --- a/src/scene/model/underlay_vector/dwf.rs +++ b/src/scene/model/underlay_vector/dwf.rs @@ -180,7 +180,7 @@ pub fn sheet(bytes: &[u8], name: &str) -> Option { xps::read(data, w2x.map(|v| v.as_slice()), entry, &family)? }; let rect = view.or_else(|| paths_bounds(&paths))?; - Some(Sheet { rect, paths, texts, sub_per_master: 1.0 }) + Some(Sheet { rect, paths, texts, sub_per_master: 1.0, table_colors: false, element_colors: Vec::new() }) } /// An XPS obfuscated font (.odttf): its first 32 bytes are XORed with the diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index 09809956f..87daea5bf 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -11,6 +11,9 @@ mod dgn7; mod dgn8; mod dwf; mod model; +mod wires; + +pub(crate) use wires::display_wires; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -93,10 +96,11 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin UnderlayType::Dgn => dgn8::model(&bytes, item, hidden).unwrap_or_default(), UnderlayType::Pdf => return None, }; - // A DGN model's extent includes its text: each text's box runs its - // length along the baseline and 1.5 × its height above it (as the - // reference measures a model). A DWF sheet keeps its plotted view. - if kind == UnderlayType::Dgn { + // A V7 model's extent includes its text: each text's box runs its + // length along the baseline and its height above it (as the + // reference measures a model). A V8 model has its stored element + // ranges; a DWF sheet keeps its plotted view. + if kind == UnderlayType::Dgn && dgn7::is_v7(&bytes) { let mut r = sheet.rect; for t in &sheet.texts { let (strokes, _) = crate::scene::text::lff::tessellate_text_ex( @@ -110,13 +114,14 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin ); let length = strokes.iter().flatten().map(|p| p[0] as f64).fold(0.0, f64::max); let (c, s) = (t.rotation.cos(), t.rotation.sin()); - for [x, y] in [[0.0, 0.0], [length, 0.0], [length, 1.5 * t.height], [0.0, 1.5 * t.height]] { + for [x, y] in [[0.0, 0.0], [length, 0.0], [length, t.height], [0.0, t.height]] { let p = [t.origin[0] + x * c - y * s, t.origin[1] + x * s + y * c]; r = [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]; } } sheet.rect = r; } + sheet.element_colors = element_colors(&sheet); outline_texts(&mut sheet); if !hidden.is_empty() { sheet.rect = sheet_without(kind, path, item, &[])?.rect; @@ -128,6 +133,45 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin value } +/// The colour of each path and text of the sheet (a path's fill before +/// its stroke). +fn element_colors(sheet: &Sheet) -> Vec<[u8; 3]> { + sheet + .paths + .iter() + .filter_map(|p| p.fill.or(p.stroke.map(|s| s.0))) + .chain(sheet.texts.iter().map(|t| t.color)) + .collect() +} + +/// The lightness a DGN model's contrast pulls its colours towards, in +/// thousandths. The reference takes it from the model's own colours as +/// shown (over `backdrop`, faded by `fade` into the background): about +/// 0.788 × their mean lightness, each element counted once (0.788 for an +/// all-white model, 0.416 for mid grey, 0.09 for dark red). +pub fn dgn_contrast_pivot( + path: &str, + item: &str, + hidden: &[String], + backdrop: Backdrop, + fade: Option<([u8; 3], u8)>, +) -> Option { + let sheet = sheet_without(UnderlayType::Dgn, path, item, hidden)?; + if sheet.element_colors.is_empty() { + return None; + } + let lightness = |c: [u8; 3]| { + let c = if sheet.table_colors { model::dgn_table_color(c, backdrop.max_channel, backdrop.light) } else { c }; + let c = match fade { + Some((bg, f)) => [0, 1, 2].map(|i| c[i] as f64 + (bg[i] as f64 - c[i] as f64) * f.min(100) as f64 / 100.0), + None => c.map(|v| v as f64), + }; + (c[0].max(c[1]).max(c[2]) + c[0].min(c[1]).min(c[2])) / 510.0 + }; + let mean = sheet.element_colors.iter().map(|&c| lightness(c)).sum::() / sheet.element_colors.len() as f64; + Some((0.788 * mean * 1000.0).round() as u16) +} + /// Text as strokes (and fills for outline fonts), in the text's colour. fn outline_texts(sheet: &mut Sheet) { for t in std::mem::take(&mut sheet.texts) { @@ -165,21 +209,64 @@ fn outline_texts(sheet: &mut Sheet) { /// Longest side of a sheet's full-size display raster, pixels. pub const RASTER_SIDE: f64 = 3072.0; +/// The background a sheet is shown over, as far as its colours depend on +/// it: the largest channel (in the steps a DGN colour table changes at) and +/// whether the background is light. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Backdrop { + pub max_channel: u8, + pub light: bool, +} + +impl Backdrop { + pub fn of(rgb: [f32; 3]) -> Self { + let max = rgb[0].max(rgb[1]).max(rgb[2]); + let min = rgb[0].min(rgb[1]).min(rgb[2]); + let top = (max.clamp(0.0, 1.0) * 255.0).round() as u8; + Backdrop { + max_channel: match top { + 0..=29 => 0, + 30..=31 => 30, + _ => 32, + }, + // Light means an HSL lightness of one half or more. + light: (max + min) / 2.0 >= 0.5, + } + } +} + /// The sheet drawn on a transparent background with `side` pixels on its /// longest side (at most [`RASTER_SIDE`]), straight alpha, rows from the -/// top, memoised per size. -pub fn display_raster(kind: UnderlayType, path: &str, item: &str, hidden: &[String], side: f64) -> Option> { +/// top, memoised per size and backdrop. +pub fn display_raster( + kind: UnderlayType, + path: &str, + item: &str, + hidden: &[String], + side: f64, + backdrop: Backdrop, +) -> Option> { let side = side.clamp(16.0, RASTER_SIDE).round(); - let key = (path.to_string(), format!("{}@{side}", layered_item(item, hidden))); + let key = ( + path.to_string(), + format!("{}@{side}~{}{}", layered_item(item, hidden), backdrop.max_channel, backdrop.light), + ); if let Some(hit) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return hit.clone(); } - let value = sheet_without(kind, path, item, hidden).and_then(|s| rasterize(&s, side)).map(Arc::new); + let value = sheet_without(kind, path, item, hidden).and_then(|s| rasterize(&s, side, backdrop)).map(Arc::new); raster_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); value } -fn rasterize(sheet: &Sheet, side: f64) -> Option { +fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option { + let shown = |c: [u8; 3]| { + if sheet.table_colors { + model::dgn_table_color(c, backdrop.max_channel, backdrop.light) + } else { + c + } + }; let [x0, y0, x1, y1] = sheet.rect; let (w, h) = (x1 - x0, y1 - y0); if !(w > 0.0 && h > 0.0) { @@ -188,7 +275,10 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { let scale = side / w.max(h); let (pw, ph) = ((w * scale).ceil().max(1.0) as u32, (h * scale).ceil().max(1.0) as u32); let mut pixmap = tiny_skia::Pixmap::new(pw, ph)?; - let to_px = |p: [f64; 2]| (((p[0] - x0) * scale) as f32, ((y1 - p[1]) * scale) as f32); + // The rectangle's edges fall on the centres of the outer pixels, so a + // line along the edge of the extent (every extent has some) is drawn. + let (sx, sy) = ((pw.max(2) - 1) as f64 / w, (ph.max(2) - 1) as f64 / h); + let to_px = |p: [f64; 2]| (((p[0] - x0) * sx + 0.5) as f32, ((y1 - p[1]) * sy + 0.5) as f32); for path in &sheet.paths { let mut pb = tiny_skia::PathBuilder::new(); for sp in &path.subpaths { @@ -212,28 +302,43 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { } } let Some(p) = pb.finish() else { continue }; - if let Some(c) = path.fill { + if let Some(c) = path.fill.map(shown) { let mut paint = tiny_skia::Paint::default(); paint.set_color_rgba8(c[0], c[1], c[2], 255); // Fills that share an edge (triangle strips) would show a seam. paint.anti_alias = false; pixmap.fill_path(&p, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), None); } - if let Some((c, width)) = path.stroke { + if let Some((c, width)) = path.stroke.map(|(c, w)| (shown(c), w)) { let mut paint = tiny_skia::Paint::default(); paint.set_color_rgba8(c[0], c[1], c[2], 255); // A negative width is in pixels (DGN line weights). let px = if width < 0.0 { (-width) as f32 } else { ((width * scale) as f32).max(1.0) }; - // Hairlines are drawn solid, one pixel wide, as the reference - // draws them; anti-aliasing would spread them to half intensity. - paint.anti_alias = px > 1.5; let stroke = tiny_skia::Stroke { width: px, line_cap: tiny_skia::LineCap::Round, line_join: tiny_skia::LineJoin::Round, ..Default::default() }; - pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + if px > 1.5 { + paint.anti_alias = true; + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } else { + // Hairlines: horizontal and vertical pieces solid, one pixel + // wide, as the reference draws them (anti-aliasing would + // spread them to half intensity); slanted pieces and curves + // anti-aliased, since their pixel steps break up once the + // raster is scaled to the screen. + let (straight, curved) = hairline_parts(path, &to_px); + paint.anti_alias = false; + if let Some(p) = straight { + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + paint.anti_alias = true; + if let Some(p) = curved { + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + } } } let mut pixels = pixmap.take(); @@ -248,6 +353,57 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { Some(PdfPage { pixels: Arc::new(pixels), width: pw, height: ph, dpi: 0.0 }) } +/// A path's horizontal and vertical segments, and its slanted and curved +/// ones, each as a pixel path. +fn hairline_parts( + path: &Path, + to_px: &dyn Fn([f64; 2]) -> (f32, f32), +) -> (Option, Option) { + let (mut straight, mut curved) = (tiny_skia::PathBuilder::new(), tiny_skia::PathBuilder::new()); + // Consecutive segments of one kind stay one run, so their joins are + // drawn once. + let (mut straight_at, mut curved_at) = (None, None); + for sp in &path.subpaths { + for seg in &sp.segments { + let a = to_px(seg.start()); + match seg { + Segment::Line(_, b) if along_axis(a, to_px(*b)) => { + let b = to_px(*b); + if straight_at != Some(a) { + straight.move_to(a.0, a.1); + } + straight.line_to(b.0, b.1); + straight_at = Some(b); + } + Segment::Line(_, b) => { + let b = to_px(*b); + if curved_at != Some(a) { + curved.move_to(a.0, a.1); + } + curved.line_to(b.0, b.1); + curved_at = Some(b); + } + Segment::Cubic(_, c1, c2, b) => { + let (c1, c2, b) = (to_px(*c1), to_px(*c2), to_px(*b)); + if curved_at != Some(a) { + curved.move_to(a.0, a.1); + } + curved.cubic_to(c1.0, c1.1, c2.0, c2.1, b.0, b.1); + curved_at = Some(b); + } + } + } + straight_at = None; + curved_at = None; + } + (straight.finish(), curved.finish()) +} + +/// A pixel segment that runs horizontally or vertically. +fn along_axis(a: (f32, f32), b: (f32, f32)) -> bool { + (b.0 - a.0).abs() < 0.01 || (b.1 - a.1).abs() < 0.01 +} + fn vector_cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); CACHE.get_or_init(Default::default) diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index 90e7dd28a..a4bcdae1a 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -39,6 +39,47 @@ pub struct Sheet { pub texts: Vec, /// DGN: sub units per master unit (the scale a Sub conversion offers). pub sub_per_master: f64, + /// DGN: the colours come from the file's own colour table, which the + /// reference shows adapted to the background ([`dgn_table_color`]). + pub table_colors: bool, + /// The colour of each path and text the sheet draws, one per element. + pub element_colors: Vec<[u8; 3]>, +} + +/// How a colour from a DGN file's colour table shows over a background +/// (its largest channel and whether it is light), as the reference draws +/// it: a near-white colour (61 or less from white, channels summed) is white, +/// or black on a light background; over a background with a channel of 32 or +/// more, colours whose largest channel is 76, 127, 153 or 204 drop a shade +/// (to 38, 76, 127 or 165, hue kept) and the greys 91, 132, 173 and 214 drop +/// to 45, 91, 137 and 183 — grey 51 to black from a channel of 30. +pub fn dgn_table_color(rgb: [u8; 3], bg_max: u8, light: bool) -> [u8; 3] { + if rgb.iter().map(|&c| 255 - c as u32).sum::() <= 61 { + return if light { [0; 3] } else { [255; 3] }; + } + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + let v = match rgb[0] { + 51 if bg_max >= 30 => 0, + 91 if bg_max >= 32 => 45, + 132 if bg_max >= 32 => 91, + 173 if bg_max >= 32 => 137, + 214 if bg_max >= 32 => 183, + v => v, + }; + return [v; 3]; + } + let top = rgb.into_iter().max().unwrap_or(0); + let to = match top { + 76 => 38, + 127 => 76, + 153 => 127, + 204 => 165, + _ => return rgb, + }; + if bg_max < 32 { + return rgb; + } + rgb.map(|c| ((c as u32 * to + top as u32 / 2) / top as u32) as u8) } /// Builds paths from pen moves. @@ -131,7 +172,8 @@ pub fn arc_cubics( out } -/// Bounds of the paths, when there are any. +/// Tight bounds of the paths (curves by their extreme points, not their +/// control points), when there are any. pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; let mut add = |p: [f64; 2]| { @@ -147,9 +189,10 @@ pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { } Segment::Cubic(a, c1, c2, c) => { add(*a); - add(*c1); - add(*c2); add(*c); + for t in cubic_extrema(*a, *c1, *c2, *c) { + add(cubic_at(*a, *c1, *c2, *c, t)); + } } } } @@ -158,6 +201,85 @@ pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { (b[0] <= b[2]).then_some(b) } +fn cubic_at(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] +} + +/// Parameters in (0, 1) where a cubic turns in x or in y. +fn cubic_extrema(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2]) -> Vec { + let mut out = Vec::new(); + for i in 0..2 { + // The derivative's quadratic a·t² + b·t + c. + let a = 3.0 * (-p0[i] + 3.0 * p1[i] - 3.0 * p2[i] + p3[i]); + let b = 6.0 * (p0[i] - 2.0 * p1[i] + p2[i]); + let c = 3.0 * (p1[i] - p0[i]); + if a.abs() < 1e-12 { + if b.abs() > 1e-12 { + out.push(-c / b); + } + continue; + } + let disc = b * b - 4.0 * a * c; + if disc >= 0.0 { + let r = disc.sqrt(); + out.push((-b + r) / (2.0 * a)); + out.push((-b - r) / (2.0 * a)); + } + } + out.retain(|t| *t > 0.0 && *t < 1.0); + out +} + +/// The colour table a DGN file without one is drawn with (index 255, the +/// background entry, black). +pub const DGN_DEFAULT_COLORS: [[u8; 3]; 256] = [ + [255, 255, 255], [0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0], [255, 0, 255], [255, 127, 0], [0, 255, 255], + [64, 64, 64], [192, 192, 192], [254, 0, 96], [160, 224, 0], [0, 254, 160], [128, 0, 160], [176, 176, 176], [0, 240, 240], + [240, 240, 240], [0, 0, 240], [0, 240, 0], [240, 0, 0], [240, 240, 0], [240, 0, 240], [240, 122, 0], [0, 240, 240], + [240, 240, 240], [0, 0, 240], [0, 240, 0], [240, 0, 0], [240, 240, 0], [240, 0, 240], [240, 122, 0], [0, 225, 225], + [225, 225, 225], [0, 0, 225], [0, 225, 0], [225, 0, 0], [225, 225, 0], [225, 0, 225], [225, 117, 0], [0, 225, 225], + [225, 225, 225], [0, 0, 225], [0, 225, 0], [225, 0, 0], [225, 225, 0], [225, 0, 225], [225, 117, 0], [0, 210, 210], + [210, 210, 210], [0, 0, 210], [0, 210, 0], [210, 0, 0], [210, 210, 0], [210, 0, 210], [210, 112, 0], [0, 210, 210], + [210, 210, 210], [0, 0, 210], [0, 210, 0], [210, 0, 0], [210, 210, 0], [210, 0, 210], [210, 112, 0], [0, 195, 195], + [195, 195, 195], [0, 0, 195], [0, 195, 0], [195, 0, 0], [195, 195, 0], [195, 0, 195], [195, 107, 0], [0, 195, 195], + [195, 195, 195], [0, 0, 195], [0, 195, 0], [195, 0, 0], [195, 195, 0], [195, 0, 195], [195, 107, 0], [0, 180, 180], + [180, 180, 180], [0, 0, 180], [0, 180, 0], [180, 0, 0], [180, 180, 0], [180, 0, 180], [180, 102, 0], [0, 180, 180], + [180, 180, 180], [0, 0, 180], [0, 180, 0], [180, 0, 0], [180, 180, 0], [180, 0, 180], [180, 102, 0], [0, 165, 165], + [165, 165, 165], [0, 0, 165], [0, 165, 0], [165, 0, 0], [165, 165, 0], [165, 0, 165], [165, 97, 0], [0, 165, 165], + [165, 165, 165], [0, 0, 165], [0, 165, 0], [165, 0, 0], [165, 165, 0], [165, 0, 165], [165, 97, 0], [0, 150, 150], + [150, 150, 150], [0, 0, 150], [0, 150, 0], [150, 0, 0], [150, 150, 0], [150, 0, 150], [150, 92, 0], [0, 150, 150], + [150, 150, 150], [0, 0, 150], [0, 150, 0], [150, 0, 0], [150, 150, 0], [150, 0, 150], [150, 92, 0], [0, 135, 135], + [135, 135, 135], [0, 0, 135], [0, 135, 0], [135, 0, 0], [135, 135, 0], [135, 0, 135], [135, 87, 0], [0, 135, 135], + [135, 135, 135], [0, 0, 135], [0, 135, 0], [135, 0, 0], [135, 135, 0], [135, 0, 135], [135, 87, 0], [0, 120, 120], + [120, 120, 120], [0, 0, 120], [0, 120, 0], [120, 0, 0], [120, 120, 0], [120, 0, 120], [120, 82, 0], [0, 120, 120], + [120, 120, 120], [0, 0, 120], [0, 120, 0], [120, 0, 0], [120, 120, 0], [120, 0, 120], [120, 82, 0], [0, 105, 105], + [105, 105, 105], [0, 0, 105], [0, 105, 0], [105, 0, 0], [105, 105, 0], [105, 0, 105], [105, 77, 0], [0, 105, 105], + [105, 105, 105], [0, 0, 105], [0, 105, 0], [105, 0, 0], [105, 105, 0], [105, 0, 105], [105, 77, 0], [0, 90, 90], + [90, 90, 90], [0, 0, 90], [0, 90, 0], [90, 0, 0], [90, 90, 0], [90, 0, 90], [90, 72, 0], [0, 90, 90], + [90, 90, 90], [0, 0, 90], [0, 90, 0], [90, 0, 0], [90, 90, 0], [90, 0, 90], [90, 72, 0], [0, 75, 75], + [75, 75, 75], [0, 0, 75], [0, 75, 0], [75, 0, 0], [75, 75, 0], [75, 0, 75], [75, 67, 0], [0, 75, 75], + [75, 75, 75], [0, 0, 75], [0, 75, 0], [75, 0, 0], [75, 75, 0], [75, 0, 75], [75, 67, 0], [0, 60, 60], + [60, 60, 60], [0, 0, 60], [0, 60, 0], [60, 0, 0], [60, 60, 0], [60, 0, 60], [60, 62, 0], [0, 60, 60], + [60, 60, 60], [0, 0, 60], [0, 60, 0], [60, 0, 0], [60, 60, 0], [60, 0, 60], [60, 62, 0], [0, 45, 45], + [45, 45, 45], [0, 0, 45], [0, 45, 0], [45, 0, 0], [45, 45, 0], [45, 0, 45], [45, 57, 0], [0, 45, 45], + [45, 45, 45], [0, 0, 45], [0, 45, 0], [45, 0, 0], [45, 45, 0], [45, 0, 45], [45, 57, 0], [0, 30, 30], + [30, 30, 30], [0, 0, 30], [0, 30, 0], [30, 0, 0], [30, 30, 0], [30, 0, 30], [30, 52, 0], [0, 30, 30], + [30, 30, 30], [0, 0, 30], [0, 30, 0], [30, 0, 0], [30, 30, 0], [30, 0, 30], [192, 192, 192], [0, 0, 0], +]; + +/// A colour table element's 256 colours: entries 0-254 from `first`, and +/// entry 255 (the background) from the three bytes that open the table at +/// `first - 3`. +pub fn dgn_color_table(e: &[u8], first: usize) -> Option> { + let bg = e.get(first - 3..first)?; + let body = e.get(first..first + 255 * 3)?; + let mut out: Vec<[u8; 3]> = body.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + out.push([bg[0], bg[1], bg[2]]); + Some(out) +} + /// A line of text the host draws with its own font. #[derive(Clone, Debug, PartialEq)] pub struct Text { diff --git a/src/scene/model/underlay_vector/wires.rs b/src/scene/model/underlay_vector/wires.rs new file mode 100644 index 000000000..eade84736 --- /dev/null +++ b/src/scene/model/underlay_vector/wires.rs @@ -0,0 +1,197 @@ +//! A DWF sheet or DGN model drawn as vector wires (lines and flat fills) +//! in world space, so it stays sharp at every zoom as the reference draws +//! it, instead of as a raster rescaled to the view. + +use std::collections::HashMap; + +use codec::entities::{Underlay, UnderlayDefinition, UnderlayDisplayFlags, UnderlayType}; + +use super::{model, sheet_without, Backdrop, Segment, Sheet}; +use crate::scene::model::wire_model::WireModel; + +/// Wires for the underlay's content, one per colour and kind (lines, fills), +/// named after the underlay so they draw in its place. They are drawn and +/// plotted but never picked or snapped (the frame selects the underlay; the +/// underlay's own snap geometry serves object snaps). +pub(crate) fn display_wires( + u: &Underlay, + def: &UnderlayDefinition, + name: &str, + background: [f32; 4], +) -> Vec { + if def.underlay_type == UnderlayType::Pdf || def.unloaded || !u.flags.contains(UnderlayDisplayFlags::ON) { + return Vec::new(); + } + let page = crate::entities::underlay::page_of(def); + let hidden = crate::scene::model::pdf_layers::hidden_layers(u); + let Some(sheet) = sheet_without(def.underlay_type, &def.file_path, page, &hidden) else { + return Vec::new(); + }; + let adjust = crate::scene::model::image_model::underlay_adjust(u, def, background); + let backdrop = Backdrop::of([background[0], background[1], background[2]]); + // A DGN model's fade is in its colours; a DWF sheet's blends it with + // what is under it. + let alpha = if def.underlay_type == UnderlayType::Dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }; + let shown = |c: [u8; 3]| -> [f32; 4] { + let c = if sheet.table_colors { model::dgn_table_color(c, backdrop.max_channel, backdrop.light) } else { c }; + let rgb = crate::scene::model::pdf_raster::adjust_rgb([c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0], &adjust); + [rgb[0], rgb[1], rgb[2], alpha] + }; + let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(u, p); + let tolerance = curve_tolerance(&sheet); + let scale = u.x_scale.abs().max(u.y_scale.abs()); + + // Lines per (colour, width), fills per colour, each in first-drawn order. + let key = |c: [f32; 4]| c.map(f32::to_bits); + let mut lines: Vec<([f32; 4], f64, Vec<[f64; 3]>)> = Vec::new(); + let mut line_at: HashMap<([u32; 4], u64), usize> = HashMap::new(); + let mut fills: Vec<([f32; 4], Vec<[f64; 3]>)> = Vec::new(); + let mut fill_at: HashMap<[u32; 4], usize> = HashMap::new(); + for path in &sheet.paths { + if let Some(c) = path.fill { + let color = shown(c); + let rings: Vec> = path + .subpaths + .iter() + .map(|sp| polyline(&sp.segments, tolerance)) + .filter(|r| r.len() >= 3) + .collect(); + if !rings.is_empty() { + let (points, triangles) = kernel::geom2d::triangulate_rings(&rings); + let at = *fill_at.entry(key(color)).or_insert_with(|| { + fills.push((color, Vec::new())); + fills.len() - 1 + }); + for triangle in triangles { + for index in triangle { + if let Some(&p) = points.get(index) { + fills[at].1.push(world(p)); + } + } + } + } + } + if let Some((c, width)) = path.stroke { + let color = shown(c); + let at = *line_at.entry((key(color), width.to_bits())).or_insert_with(|| { + lines.push((color, width, Vec::new())); + lines.len() - 1 + }); + for sp in &path.subpaths { + let mut pts = polyline(&sp.segments, tolerance); + if sp.closed && pts.len() >= 2 && pts.first() != pts.last() { + pts.push(pts[0]); + } + if pts.len() < 2 { + continue; + } + let run = &mut lines[at].2; + if !run.is_empty() { + run.push([f64::NAN; 3]); + } + run.extend(pts.into_iter().map(world)); + } + } + } + + let mut wires = Vec::new(); + for (color, tris) in fills { + let (fp, fp_low) = crate::scene::convert::tessellate::points_to_ds(tris); + let mut wire = content_wire(name, Vec::new(), color); + wire.fill_tris = fp; + wire.fill_tris_low = fp_low; + wires.push(wire); + } + for (color, width, points) in lines { + let (lp, lp_low) = crate::scene::convert::tessellate::points_to_ds(points); + let mut wire = content_wire(name, lp, color); + wire.points_low = lp_low; + if width < 0.0 { + // A width in pixels (DGN line weights are not shown). + wire.set_fixed_screen_width((-width) as f32); + } else if width > 0.0 { + wire.world_width = (width * scale) as f32; + } else { + wire.set_fixed_screen_width(1.0); + } + wires.push(wire); + } + if crate::entities::underlay::is_clipped(u) { + let clip: Vec<[f64; 2]> = crate::entities::underlay::clip_polygon_local(u) + .into_iter() + .map(|p| { + let w = world(p); + [w[0], w[1]] + }) + .collect(); + let ring = if u.clip_inverted { + let [x0, y0, x1, y1] = sheet.rect; + let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(|p| { + let w = world(p); + [w[0], w[1]] + }); + let lo = [corners.iter().map(|p| p[0]).fold(f64::MAX, f64::min), corners.iter().map(|p| p[1]).fold(f64::MAX, f64::min)]; + let hi = [corners.iter().map(|p| p[0]).fold(f64::MIN, f64::max), corners.iter().map(|p| p[1]).fold(f64::MIN, f64::max)]; + crate::scene::pick::xclip::inverted_ring(&clip, (lo, hi)) + } else { + clip + }; + crate::scene::pick::xclip::clip_wires(&mut wires, &ring); + } + for wire in &mut wires { + crate::scene::convert::tess::set_wire_aabb(wire, WireModel::UNBOUNDED_AABB); + } + wires +} + +fn content_wire(name: &str, points: Vec<[f32; 3]>, color: [f32; 4]) -> WireModel { + let mut wire = WireModel::solid(name.to_string(), points, color, false); + wire.aci = 0; + wire.bg_adapt = None; + wire.display_visible = true; + wire.plot_visible = true; + // Drawn, but neither picked nor snapped (see `WireModel::snap_only`). + wire.snap_only = true; + wire +} + +/// How far a curve's polyline may stray from it: a small share of the +/// sheet, so curves stay smooth when zoomed far in. +fn curve_tolerance(sheet: &Sheet) -> f64 { + let [x0, y0, x1, y1] = sheet.rect; + ((x1 - x0).hypot(y1 - y0) * 2e-5).max(1e-9) +} + +/// A subpath's points, curves divided within `tolerance`. +fn polyline(segments: &[Segment], tolerance: f64) -> Vec<[f64; 2]> { + let mut out: Vec<[f64; 2]> = Vec::new(); + for seg in segments { + let start = seg.start(); + if out.last() != Some(&start) { + out.push(start); + } + match seg { + Segment::Line(_, b) => out.push(*b), + Segment::Cubic(a, c1, c2, b) => { + // The control polygon's length bounds the curve's; the + // flattening error of n pieces falls as 1/n². + let hull = dist(*a, *c1) + dist(*c1, *c2) + dist(*c2, *b); + let n = ((hull / tolerance).sqrt() * 0.5).ceil().clamp(2.0, 256.0) as usize; + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (k0, k1, k2, k3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + out.push([ + k0 * a[0] + k1 * c1[0] + k2 * c2[0] + k3 * b[0], + k0 * a[1] + k1 * c1[1] + k2 * c2[1] + k3 * b[1], + ]); + } + } + } + } + out +} + +fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index dad7fef4d..c872803a6 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -506,8 +506,9 @@ pub struct WireModel { pub depth_override: Option, /// Whether this wire is drawn in the normal viewport pass. pub display_visible: bool, - /// Geometry that only object snaps use (the content of an underlay): - /// never drawn, plotted or picked. + /// Geometry that is never picked: the content of an underlay. Hidden + /// (`display_visible` false) it is what object snaps find; drawn, it is + /// display only and snaps skip it too ([`Self::is_display_only`]). pub snap_only: bool, /// Whether this wire is included in plotted output. pub plot_visible: bool, @@ -531,6 +532,11 @@ impl WireModel { pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0]; /// Rollover (hover) highlight — orange, distinct from the blue selection. pub const HOVER: [f32; 4] = [0.95, 0.55, 0.10, 1.0]; + + /// Drawn underlay content: shown and plotted, never picked or snapped. + pub fn is_display_only(&self) -> bool { + self.snap_only && self.display_visible + } /// Sentinel AABB that never rejects any snap query. pub const UNBOUNDED_AABB: [f32; 4] = [ f32::NEG_INFINITY, diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 0fb4988ae..efc62a2cd 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -673,6 +673,10 @@ impl WireIndexBatch { } fn append_wire_index_entries(wire_idx: u32, wire: &WireModel, batch: &mut WireIndexBatch) { + // Drawn underlay content: neither picked nor snapped. + if wire.is_display_only() { + return; + } if let Some(aabb) = finite_wire_aabb3(wire) { batch.wire_entries.push(Entry3 { aabb, diff --git a/src/snap.rs b/src/snap.rs index dad82e13e..d29883ae2 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1349,7 +1349,7 @@ impl Snapper { let unindexed = wires.segments().is_none(); if unindexed { for w in wires.iter() { - if wire_in_range(w) { + if !w.is_display_only() && wire_in_range(w) { in_range_pts += w.points.len(); in_range_wires.push(w); } diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index cee3e71f4..8946be613 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -146,7 +146,8 @@ pub fn item_thumbs(kind: codec::entities::UnderlayType, path: &str) -> Vec