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); }