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.
This commit is contained in:
Taha-Mahmoodi
2026-10-31 19:43:16 +07:00
committed by bflatastic
parent 59eb658fd7
commit f65d28378f
5 changed files with 81 additions and 3 deletions
+5 -2
View File
@@ -109,8 +109,11 @@ impl Renderer {
}
ctx.set_transform(f.view * xf);
let closed = effects::stroke::is_closed(bp);
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9));
// Keep hairlines visible when zoomed far out (at least ~1 device pixel). A width profile
// multiplies the weight, so it is the profile's widest point that is kept visible: raising
// the weight further would swell the whole stroke while the view zooms out (#995).
let widest = st.profile.as_ref().map_or(1.0, |p| p.widest().max(1e-3));
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9) / widest);
let inside = st.align == StrokeAlign::Inside && closed;
if inside {
ctx.set_fill_rule(fill_rule(rule));
+1
View File
@@ -29,6 +29,7 @@ mod setup;
mod strokegradient;
mod tileedge;
mod viewraster;
mod widthzoom;
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
let id = d.alloc_id();
+63
View File
@@ -0,0 +1,63 @@
//! 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}");
}