Underlays are rasterised for the view's scale; DGN V7 strokes one pixel wide

A page shown small was drawn from its full-size raster and thin lines broke
up into dots. Once a zoom settles on another quarter-octave step, PDF, DWF
and DGN underlays are rasterised again near their size on screen (never
above the full-size raster), frames are requested until that happens, and
the adjusted-pixel memo is kept per raster size. V7 element weights no longer
widen strokes, matching V8 models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-28 13:49:33 +03:00
1 parent cb7860e50e
commit 1cbe89ef62
8 files changed
+108 -19

No files matched your search

+2
View File
@@ -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.
+4 -1
View File
@@ -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()
+1 -1
View File
@@ -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,
},
+49 -1
View File
@@ -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<Handle>,
/// The camera generation the dynamic dimensions were last scaled for.
dynamic_dimension_camera_gen: Option<u64>,
/// 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<f64>,
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<f32> {
if self.current_layout != "Model" {
return None;
+26 -2
View File
@@ -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<f64>,
) -> Option<Self> {
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
+11 -1
View File
@@ -75,6 +75,15 @@ pub fn rasterize_page_display(path: &str, page: &str) -> Option<Arc<PdfPage>> {
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<Arc<PdfPage>> {
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<Arc<PdfPage>> {
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())
+4 -4
View File
@@ -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 => {
+11 -9
View File
@@ -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<Arc<PdfPage>> {
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<Arc<PdfPage>> {
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<PdfPage> {
fn rasterize(sheet: &Sheet, side: f64) -> Option<PdfPage> {
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);