diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 00990c1cd..9c44eac91 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -850,6 +850,8 @@ impl OpenCADStudio { self.tabs[i].scene.update(t - self.start); // Dynamic dimensions keep their screen size across zoom bands. self.tabs[i].scene.refresh_dynamic_dimension_scales(false); + // Underlays are rasterised again for the zoom once it settles. + self.tabs[i].scene.refresh_underlay_resolution(t); // If the camera moved since we last synced, write it back to // the document and mark the file dirty. diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index ca050c29e..c754abbfb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2544,7 +2544,10 @@ impl OpenCADStudio { // driven by input events, but once the cursor stops no event would fire // the one full-quality frame that re-renders hatches — this tick does, // then the scene-render cache holds it and the subscription auto-stops. - let nav_settle = if self.tabs[self.active_tab].scene.is_settling() { + // Underlay rasters follow the zoom once it settles; tick until then. + let nav_settle = if self.tabs[self.active_tab].scene.is_settling() + || self.tabs[self.active_tab].scene.underlay_resolution_stale() + { window::frames().map(Message::Tick) } else { Subscription::none() diff --git a/src/scene/entity.rs b/src/scene/entity.rs index f5c0fa3c4..46b8e5065 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2419,7 +2419,7 @@ impl Scene { } else { self.paper_bg_color }; - ImageModel::from_underlay(u, def, background) + ImageModel::from_underlay(u, def, background, self.underlay_world_per_pixel) } _ => None, }, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 6c1f1a012..d23f9850b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1275,7 +1275,7 @@ fn build_derived_caches_impl( } else { [1.0, 1.0, 1.0, 1.0] }; - ImageModel::from_underlay(u, def, background).map(|m| (handle, m)) + ImageModel::from_underlay(u, def, background, None).map(|m| (handle, m)) } _ => None, }, @@ -2325,6 +2325,11 @@ pub struct Scene { hidden_dynamic_dimensions: HashSet, /// The camera generation the dynamic dimensions were last scaled for. dynamic_dimension_camera_gen: Option, + /// The view scale (world units per pixel, in quarter-octave steps) the + /// underlay rasters are made for, and a new step waiting for the view + /// to settle. + underlay_world_per_pixel: Option, + underlay_scale_pending: Option<(i32, iced::time::Instant)>, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2687,6 +2692,8 @@ impl Scene { dynamic_constraint_display: true, hidden_dynamic_dimensions: HashSet::default(), dynamic_dimension_camera_gen: None, + underlay_world_per_pixel: None, + underlay_scale_pending: None, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), constraints_epoch: 0, @@ -2739,6 +2746,47 @@ impl Scene { /// model camera, so a cached value applied to mm-sheet entity AABBs would /// be a stale model-world wpp and cull every paper-space annotation. /// Paper-space callers use their own scale instead. + /// Rasterise the underlays again when the view's scale has moved to + /// another quarter-octave step and stayed there briefly (not on every + /// frame of a zoom). + pub fn refresh_underlay_resolution(&mut self, now: iced::time::Instant) { + let Some(wpp) = self.world_per_pixel() else { + return; + }; + let step = (f64::from(wpp).log2() * 4.0).floor() as i32; + let current = self.underlay_world_per_pixel.map(|w| (w.log2() * 4.0).round() as i32); + if current == Some(step) { + self.underlay_scale_pending = None; + return; + } + match self.underlay_scale_pending { + Some((pending, since)) if pending == step => { + if now.duration_since(since) < std::time::Duration::from_millis(150) { + return; + } + } + _ => { + self.underlay_scale_pending = Some((step, now)); + return; + } + } + self.underlay_scale_pending = None; + self.underlay_world_per_pixel = Some(2f64.powf(step as f64 / 4.0)); + if self.document.entities().any(|e| matches!(e, EntityType::Underlay(_))) { + self.reseed_underlays(); + } + } + + /// The view has moved to another scale step than the underlay rasters + /// were made for (frames are requested until they are made again). + pub fn underlay_resolution_stale(&self) -> bool { + let Some(wpp) = self.world_per_pixel() else { + return false; + }; + let step = (f64::from(wpp).log2() * 4.0).floor() as i32; + self.underlay_world_per_pixel.map(|w| (w.log2() * 4.0).round() as i32) != Some(step) + } + pub(super) fn world_per_pixel(&self) -> Option { if self.current_layout != "Model" { return None; diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index d52a05708..c00b98c9a 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -181,10 +181,14 @@ impl ImageModel { /// limits the drawn area to its inside, or to the page outside it when /// inverted. `None` when the underlay is off, non-PDF, or the page can't /// be rendered (caller keeps the outline placeholder). + /// `world_per_pixel` is the view's scale: the page is rasterised near + /// the size it shows on screen (full size when `None` or when larger), + /// so thin lines stay one crisp pixel wide when zoomed out. pub fn from_underlay( u: &codec::entities::Underlay, def: &codec::entities::UnderlayDefinition, background: [f32; 4], + world_per_pixel: Option, ) -> Option { use codec::entities::{UnderlayDisplayFlags, UnderlayType}; use super::pdf_raster::{self, PageAdjust}; @@ -196,16 +200,36 @@ impl ImageModel { } let page = crate::entities::underlay::page_of(def); let rect = crate::entities::underlay::definition_rect(def)?; + // The page's longest side on screen, pixels (the view scale is taken + // a step finer, so the raster is never enlarged on screen). + let screen_side = world_per_pixel + .filter(|wpp| *wpp > 0.0) + .map(|wpp| { + let w = (rect[2] - rect[0]) * u.x_scale.abs(); + let h = (rect[3] - rect[1]) * u.y_scale.abs(); + w.max(h) / wpp + }); let (source, raster) = match def.underlay_type { UnderlayType::Pdf => { // Hidden PDF layers come from the underlay's layer overrides. let source = super::pdf_layers::underlay_source(u, &def.file_path); - let raster = pdf_raster::rasterize_page_display(&source, page)?; + let inches = (rect[2] - rect[0]).max(rect[3] - rect[1]); + let raster = match screen_side { + Some(side) if inches > 0.0 && side / inches < pdf_raster::DISPLAY_DPI as f64 => { + pdf_raster::rasterize_page_display_at(&source, page, (side / inches).max(8.0) as f32)? + } + _ => pdf_raster::rasterize_page_display(&source, page)?, + }; (source, raster) } kind => ( def.file_path.clone(), - super::underlay_vector::display_raster(kind, &def.file_path, page)?, + super::underlay_vector::display_raster( + kind, + &def.file_path, + page, + screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), + )?, ), }; // Dark means an HSL lightness under one half: pure blue counts as diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index b6ba29397..0fb9a85d0 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -75,6 +75,15 @@ pub fn rasterize_page_display(path: &str, page: &str) -> Option> { rasterize_cached(path, page, RASTER_DPI, true) } +/// The display raster at a lower DPI for a page shown small on screen +/// (never above the on-screen 150 DPI). +pub fn rasterize_page_display_at(path: &str, page: &str, dpi: f32) -> Option> { + rasterize_cached(path, page, dpi.clamp(1.0, RASTER_DPI), true) +} + +/// The DPI of the full-size display raster. +pub const DISPLAY_DPI: f32 = RASTER_DPI; + fn rasterize_cached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option> { let key = (path.to_string(), page.to_string(), dpi.to_bits(), transparent); if let Some(hit) = page_cache() @@ -228,7 +237,8 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj if identity { return raster.pixels.clone(); } - let key = (path.to_string(), page.to_string(), adjust); + // One entry per raster size: a page is rasterised again for the zoom. + let key = (path.to_string(), format!("{page}@{}x{}", raster.width, raster.height), adjust); if let Some(hit) = adjusted_cache() .lock() .unwrap_or_else(|error| error.into_inner()) diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index 8e252b7b8..e38f30b95 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -198,9 +198,9 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } let symbology = uint16(e, 34).unwrap_or(0); let color = colors[(symbology >> 8) as usize]; - let weight = ((symbology & 0xf8) >> 3) as f64; - // Weights are screen pixels: a negative width asks for pixels. - let stroke = Some((color, -(weight * 2.0 + 1.0))); + // One pixel wide, as V8 models draw: the element weight is a + // lineweight, not shown while lineweights are off. + let stroke = Some((color, -1.0)); let fill = fill_index(e).map(|i| colors[i as usize]); let int_pt = |i: usize| -> Option<[f64; 2]> { Some([int32(e, i)? as f64, int32(e, i + 4)? as f64]) }; match kind { @@ -268,7 +268,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { // B-spline curve header: order, pole count. let order = ((e.get(40).copied().unwrap_or(0) & 0x0f) + 2) as usize; let poles = uint16(e, 42).unwrap_or(0) as usize; - spline = Some((order, poles, color, -(weight * 2.0 + 1.0))); + spline = Some((order, poles, color, -1.0)); knots.clear(); } 26 => { diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index e9ae86382..144d83a4a 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -108,28 +108,30 @@ fn outline_texts(sheet: &mut Sheet) { } } -/// Longest side of a sheet's display raster, pixels. -const RASTER_SIDE: f64 = 3072.0; +/// Longest side of a sheet's full-size display raster, pixels. +pub const RASTER_SIDE: f64 = 3072.0; -/// The sheet drawn on a transparent background, straight alpha, rows from -/// the top, memoised. -pub fn display_raster(kind: UnderlayType, path: &str, item: &str) -> Option> { - let key = (path.to_string(), item.to_string()); +/// 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, side: f64) -> Option> { + let side = side.clamp(16.0, RASTER_SIDE).round(); + let key = (path.to_string(), format!("{item}@{side}")); if let Some(hit) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return hit.clone(); } - let value = sheet(kind, path, item).and_then(|s| rasterize(&s)).map(Arc::new); + let value = sheet(kind, path, item).and_then(|s| rasterize(&s, side)).map(Arc::new); raster_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); value } -fn rasterize(sheet: &Sheet) -> Option { +fn rasterize(sheet: &Sheet, side: f64) -> Option { let [x0, y0, x1, y1] = sheet.rect; let (w, h) = (x1 - x0, y1 - y0); if !(w > 0.0 && h > 0.0) { return None; } - let scale = RASTER_SIDE / w.max(h); + 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);