Files
vectorcraft/crates/render/src/tests/widthzoom.rs
T
Taha-Mahmoodi f65d28378f Fix #995: a width-profile stroke keeps its shape when the view zooms out
The canvas keeps a hairline visible by raising a stroke's weight to half a device pixel. A width profile multiplies that weight, so a 1 pt stroke with a 6x width point swelled to twice its size at 25% zoom and shrank back on zooming in. The minimum now goes to the profile's widest point instead; plain strokes are unchanged.
2026-10-31 19:43:16 +07:00

64 lines
3.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A stroke with a width profile keeps its shape when the view zooms out (#995): only a stroke too
//! thin to see is thickened, and then by what its widest point needs, not all of it at once.
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeLayer, WidthProfile};
use vectorcraft_geom::PathData;
use crate::*;
const SIZE: u32 = 200;
/// A line across a 200×200 page, stroked black `weight` pt with `profile`, drawn at `scale`.
fn render(weight: f64, profile: Option<WidthProfile>, scale: f64) -> Rendered {
let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), weight);
st.profile = profile;
let mut p = BezPath::new();
p.move_to((20.0, 100.0));
p.line_to((180.0, 100.0));
let mut d = Document::new(SIZE as f64, SIZE as f64);
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, SIZE, SIZE, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// Device pixels of ink in the column through the middle of the line (coverage summed, so a
/// stroke narrower than a pixel still counts what it covers).
fn ink_at_middle(img: &Rendered, scale: f64) -> f64 {
let x = (100.0 * scale) as u32;
(0..SIZE).map(|y| 1.0 - img.pixel(x, y)[0] as f64 / 255.0).sum()
}
/// Thin at both ends, six times the weight in the middle.
fn swell() -> WidthProfile {
WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 6.0, 6.0), (1.0, 1.0, 1.0)] }
}
#[test]
fn a_thin_width_stroke_keeps_its_thickness_in_document_terms_when_zoomed_out() {
let whole = ink_at_middle(&render(1.0, Some(swell()), 1.0), 1.0);
assert!((whole - 6.0).abs() < 0.3, "six points at full size: {whole}");
for scale in [0.5, 0.25, 0.125] {
let ink = ink_at_middle(&render(1.0, Some(swell()), scale), scale);
let in_points = ink / scale;
assert!((in_points - whole).abs() < whole * 0.05, "at {scale}: {in_points} pt against {whole} pt");
}
}
#[test]
fn a_stroke_too_thin_to_see_is_kept_visible_by_its_widest_point() {
// 0.1 pt, twice that in the middle: at a quarter size even the middle is a twentieth of a
// pixel, so it is thickened to the half pixel a plain hairline gets, and the profile's ends
// stay half of it.
let thin = WidthProfile { points: vec![(0.0, 0.5, 0.5), (0.5, 1.0, 1.0), (1.0, 0.5, 0.5)] };
let plain = ink_at_middle(&render(0.1, None, 0.25), 0.25);
let img = render(0.1, Some(thin), 0.25);
let shaped = ink_at_middle(&img, 0.25);
assert!((plain - 0.5).abs() < 0.1, "a plain hairline gets half a pixel: {plain}");
assert!((shaped - 0.5).abs() < 0.1, "the widest point of a profile gets it too: {shaped}");
// Seven pixels in, 28 pt along the page: just past the line's start at 20 pt.
let end: f64 = (0..SIZE).map(|y| 1.0 - img.pixel(7, y)[0] as f64 / 255.0).sum();
assert!(end < shaped * 0.75, "the thin end stays thinner than the middle: {end} against {shaped}");
}