From f7106c7d3009703f5ef462e9833d0a2b354c4736 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:58:47 +0300 Subject: [PATCH 1/3] DGN underlays: global origin, stored extents, colour table display, 3D cells - V8 models are placed relative to their global origin (model header). - A V8 model's extent is the union of its elements' stored ranges; a V7 model's text box is one text height tall. - Files without a colour table use the standard DGN default table; colour 255 is read from the table's background entry. - Colours from a file's colour table follow the background: near-white colours swap with it, and over non-black backgrounds some shades drop a step. - DGN contrast pulls towards a pivot taken from the model's own colours, after the fade, which is blended into the background first. - Shared cells are read through their full 3D placement, so tilted instances and 3D definitions are drawn in plan. - Deleted V8 elements are skipped; lines along the model's extent stay in the raster. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/image_model.rs | 46 +++-- src/scene/model/pdf_raster.rs | 16 +- src/scene/model/underlay_vector/dgn7.rs | 43 ++--- src/scene/model/underlay_vector/dgn8.rs | 236 ++++++++++++++--------- src/scene/model/underlay_vector/dwf.rs | 2 +- src/scene/model/underlay_vector/mod.rs | 113 +++++++++-- src/scene/model/underlay_vector/model.rs | 128 +++++++++++- src/ui/window/pdf_dialogs.rs | 3 +- 8 files changed, 433 insertions(+), 154 deletions(-) diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index dfd6d1ea9..96b994f8e 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -231,19 +231,23 @@ impl ImageModel { } kind => { let hidden = super::pdf_layers::hidden_layers(u); + let backdrop = super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]); // 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("|")) - }; + // hidden layers and backdrop. + let source = format!( + "{}#{}~{}{}", + def.file_path, + hidden.join("|"), + backdrop.max_channel, + backdrop.light + ); let raster = super::underlay_vector::display_raster( kind, &def.file_path, page, &hidden, screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), + backdrop, )?; (source, raster) } @@ -253,6 +257,11 @@ impl ImageModel { 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))); let pixels = pdf_raster::adjusted_pixels( &source, page, @@ -260,7 +269,8 @@ impl ImageModel { 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. + // 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 @@ -269,14 +279,22 @@ impl ImageModel { 730 } UnderlayType::Dwf => 243, - UnderlayType::Dgn => 650, + 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), - 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), + // A DGN model's colours follow the background through its + // colour table (the raster above), whatever this setting. + adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) + && !dgn, + dark_background: bg_lum < 0.5, + fade_before_contrast, }, ); @@ -329,7 +347,7 @@ impl ImageModel { pixels, width: raster.width, height: raster.height, - opacity: 1.0 - u.fade.min(100) as f32 / 100.0, + opacity: if dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }, corners, corners_low, draw_depth: 0.0, diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index eab0bdd63..d5f1ccf63 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); @@ -236,7 +241,7 @@ fn adjusted_cache() -> &'static Mutex>>> { /// 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(); } @@ -268,6 +273,12 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj 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. @@ -276,6 +287,7 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj let pivot = adjust.contrast_pivot as f32 / 1000.0; rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); } + 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..ceda1d088 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -93,10 +93,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 +111,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 +130,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 +206,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 +272,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,14 +299,14 @@ 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). 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/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 Date: Tue, 29 Sep 2026 12:20:27 +0300 Subject: [PATCH 2/3] DWF/DGN underlays: anti-alias slanted and curved hairlines One-pixel hairlines keep horizontal and vertical pieces solid, but slanted pieces and curves are anti-aliased: their pixel steps broke up into dashes once the raster was scaled to the screen. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/underlay_vector/mod.rs | 74 ++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index ceda1d088..7e68a881d 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -311,16 +311,31 @@ fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option { 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(); @@ -335,6 +350,57 @@ fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> 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) From 507a6ab43c11d46fca4486b65da03a846de8e5c6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:43:20 +0300 Subject: [PATCH 3/3] 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 --- src/scene/convert/tess.rs | 4 + src/scene/model/image_model.rs | 128 +++++++-------- src/scene/model/pdf_raster.rs | 62 +++---- src/scene/model/underlay_vector/mod.rs | 3 + src/scene/model/underlay_vector/wires.rs | 197 +++++++++++++++++++++++ src/scene/model/wire_model.rs | 10 +- src/scene/pick/interaction_index.rs | 4 + src/snap.rs | 2 +- 8 files changed, 310 insertions(+), 100 deletions(-) create mode 100644 src/scene/model/underlay_vector/wires.rs 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 96b994f8e..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,74 +282,11 @@ impl ImageModel { }; (source, raster) } - kind => { - let hidden = super::pdf_layers::hidden_layers(u); - let backdrop = super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]); - // The adjusted-pixel memo keys on the source: one per set of - // hidden layers and backdrop. - let source = format!( - "{}#{}~{}{}", - def.file_path, - hidden.join("|"), - backdrop.max_channel, - backdrop.light - ); - let raster = super::underlay_vector::display_raster( - kind, - &def.file_path, - page, - &hidden, - screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), - backdrop, - )?; - (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 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))); - 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; 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 (the raster above), whatever this setting. - adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) - && !dgn, - dark_background: bg_lum < 0.5, - fade_before_contrast, - }, - ); + 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 @@ -347,7 +337,7 @@ impl ImageModel { pixels, width: raster.width, height: raster.height, - opacity: if dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }, + opacity: 1.0 - u.fade.min(100) as f32 / 100.0, corners, corners_low, draw_depth: 0.0, diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index d5f1ccf63..b613984d0 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -238,6 +238,39 @@ 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> { @@ -260,34 +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 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); - } - + 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/mod.rs b/src/scene/model/underlay_vector/mod.rs index 7e68a881d..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}; 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); }