mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 14:41:19 +00:00
Preserve raster coverage of thin curved strokes
Squashed from #3273 onto main.
This commit is contained in:
committed by
bflatastic
parent
884a330fae
commit
e0ad7ccf16
@@ -501,3 +501,45 @@ fn a_stroke_narrower_than_the_shape_keeps_its_hole() {
|
||||
assert_eq!(moves(&outline), 3, "{join:?}: two contours for the ring, one for the blob");
|
||||
}
|
||||
}
|
||||
|
||||
/// A subpixel stroke must retain its geometric thickness even when the normal display tolerance
|
||||
/// is close to its weight. Original uneven circular arcs, independent of any user fixture.
|
||||
#[test]
|
||||
fn thin_curved_strokes_keep_their_width_between_notches() {
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Shape};
|
||||
for (radius, degrees) in [(450.0, 47.0_f64), (438.0, 77.0), (450.0, 54.0)] {
|
||||
let a = degrees.to_radians();
|
||||
let k = 4.0 / 3.0 * (a / 4.0).tan();
|
||||
let curve = CubicBez::new(
|
||||
(radius, 0.0),
|
||||
(radius, k * radius),
|
||||
(radius * a.cos() + k * radius * a.sin(), radius * a.sin() - k * radius * a.cos()),
|
||||
(radius * a.cos(), radius * a.sin()),
|
||||
);
|
||||
let mut line = BezPath::new();
|
||||
line.move_to(curve.p0);
|
||||
line.curve_to(curve.p1, curve.p2, curve.p3);
|
||||
for width in [0.25, 1.0 / 3.0, 0.5, 1.0] {
|
||||
let st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||||
let outline = line_outline(&line, &st, width, 0.25);
|
||||
for step in 1..40 {
|
||||
let t = step as f64 / 40.0;
|
||||
let p = curve.eval(t);
|
||||
let tangent = curve.deriv().eval(t).to_vec2().normalize();
|
||||
let normal = kurbo::Vec2::new(-tangent.y, tangent.x);
|
||||
for side in [-1.0, 1.0] {
|
||||
assert_ne!(
|
||||
outline.winding(p + normal * (side * width * 0.45)),
|
||||
0,
|
||||
"missing inner band at radius {radius}, {degrees} degrees, width {width}, t {t}"
|
||||
);
|
||||
assert_eq!(
|
||||
outline.winding(p + normal * (side * width * 0.55)),
|
||||
0,
|
||||
"outside intended band at radius {radius}, {degrees} degrees, width {width}, t {t}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ impl Renderer {
|
||||
AppearanceItem::Stroke(st) => {
|
||||
let st = &*f.stroke(st);
|
||||
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(ig));
|
||||
effects::stroke::line_outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25)
|
||||
crate::stroke::outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25)
|
||||
}
|
||||
};
|
||||
let outset = n.appearance.outset();
|
||||
|
||||
@@ -24,6 +24,7 @@ pub use paint::{decode_failures, image_decodes, reduced_size};
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
mod stroke;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -136,7 +136,7 @@ impl Renderer {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
|
||||
Some(o) => ctx.fill_path(&o),
|
||||
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
|
||||
None => ctx.fill_path(&crate::stroke::outline(&pieces.line, st, width, f.px * 0.25)),
|
||||
}
|
||||
for head in &pieces.heads {
|
||||
ctx.fill_path(&head.outline);
|
||||
@@ -231,7 +231,7 @@ impl Renderer {
|
||||
};
|
||||
let line = match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
|
||||
Some(o) => o,
|
||||
None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
|
||||
None => Arc::new(crate::stroke::outline(&pieces.line, st, width, f.px * 0.25)),
|
||||
};
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
for clip in std::iter::once(&*line).chain(pieces.heads.iter().map(|h| &h.outline)) {
|
||||
@@ -277,7 +277,7 @@ impl Renderer {
|
||||
width: f64,
|
||||
) -> Option<Arc<BezPath>> {
|
||||
let level = (f.px * 0.25).log2().floor() as i32;
|
||||
let expand = || Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level)));
|
||||
let expand = || Arc::new(crate::stroke::outline(line, st, width, 2f64.powi(level)));
|
||||
if let Some((g, run)) = chars {
|
||||
let key = (self.stroke_owner(Arc::as_ptr(g) as usize), run, width.to_bits(), level);
|
||||
if let Some((kept, outline)) = self.char_strokes.get(&key, self.stamp)
|
||||
@@ -332,7 +332,7 @@ impl Renderer {
|
||||
.then(|| crop_line(line, region, crop_reach(st, width, f.px, tol), width))
|
||||
.flatten();
|
||||
let (outline, crop) = match cropped {
|
||||
Some(part) => (Arc::new(effects::stroke::line_outline(&part, st, width, tol)), Some(region)),
|
||||
Some(part) => (Arc::new(crate::stroke::outline(&part, st, width, tol)), Some(region)),
|
||||
None => (expand(), None),
|
||||
};
|
||||
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, own }, moved);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Raster-only stroke fitting: preserve thin curved bands through fill flattening.
|
||||
|
||||
use crate::effects;
|
||||
use vectorcraft_doc::StrokeLayer;
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
/// Vello flattens the two sides of a filled stroke independently at 0.25 device pixels.
|
||||
/// For strokes narrower than four times the raster tolerance, fit and preflatten the
|
||||
/// band more tightly. Wider strokes and the document/export geometry are unchanged.
|
||||
pub(crate) fn outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
|
||||
if width >= tol * 4.0 {
|
||||
return effects::stroke::line_outline(line, st, width, tol);
|
||||
}
|
||||
// Closed endpoint rounding must not consume a substantial part of a subpixel band.
|
||||
let fine = (tol / 8.0).min(width / 32.0).max(1e-4);
|
||||
let outline = effects::stroke::line_outline(line, st, width, fine);
|
||||
let mut flat = BezPath::new();
|
||||
// Keep upstream's per-curve work cap for untrusted or extreme coordinates.
|
||||
vectorcraft_geom::flatten(outline.iter(), fine, |el| flat.push(el));
|
||||
flat
|
||||
}
|
||||
@@ -30,6 +30,7 @@ mod placed_document;
|
||||
mod rasterize;
|
||||
mod setup;
|
||||
mod strokegradient;
|
||||
mod thincoverage;
|
||||
mod tileedge;
|
||||
mod viewraster;
|
||||
mod widthzoom;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use super::*;
|
||||
use crate::stroke::outline as raster_stroke_outline;
|
||||
use vectorcraft_doc::StrokeLayer;
|
||||
|
||||
/// Independently integrate coverage in radial/angle bins of an original notched ring. Checking
|
||||
/// only the outline geometry misses raster flattening that cancels its two nearby contours.
|
||||
#[test]
|
||||
fn fractional_curved_strokes_retain_coverage_between_notches() {
|
||||
for rounded in [false, true] {
|
||||
for (weight, rotation, phase, opacity) in
|
||||
[(1.0 / 3.0, 0.0, 0.0, 1.0), (0.5, 30.0, 0.25, 1.0), (0.75, 75.0, 0.5, 0.5), (1.0, 135.0, 0.75, 1.0)]
|
||||
{
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let mut path = BezPath::new();
|
||||
let mut radius = 450.0;
|
||||
let mut previous = 0.0;
|
||||
path.move_to((950.0, 500.0));
|
||||
let mut arcs = Vec::new();
|
||||
let point = |r: f64, a: f64| vectorcraft_geom::Point::new(500.0 + r * a.cos(), 500.0 + r * a.sin());
|
||||
for degrees in [17.0_f64, 64.0, 119.0, 147.0, 201.0, 242.0, 313.0, 360.0] {
|
||||
let a = degrees.to_radians();
|
||||
let k = 4.0 / 3.0 * ((a - previous) / 4.0).tan();
|
||||
let start = point(radius, previous);
|
||||
let end = point(radius, a);
|
||||
path.curve_to(
|
||||
start + kurbo::Vec2::new(-previous.sin(), previous.cos()) * (k * radius),
|
||||
end + kurbo::Vec2::new(a.sin(), -a.cos()) * (k * radius),
|
||||
end,
|
||||
);
|
||||
arcs.push((radius, previous + 0.06, a - 0.06));
|
||||
radius = if radius == 450.0 { 438.0 } else { 450.0 };
|
||||
path.line_to(point(radius, a));
|
||||
previous = a;
|
||||
}
|
||||
path.close_path();
|
||||
// Match a parser's f32 coordinate round-trip and exact closed endpoint as well as the
|
||||
// original f64 construction. A near-coincident final anchor must not hide this case.
|
||||
let q = |p: kurbo::Point| kurbo::Point::new(f64::from(p.x as f32), f64::from(p.y as f32));
|
||||
let path = if rounded {
|
||||
BezPath::from_vec(
|
||||
path.iter()
|
||||
.map(|el| match el {
|
||||
kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(q(p)),
|
||||
kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(q(p)),
|
||||
kurbo::PathEl::QuadTo(a, b) => kurbo::PathEl::QuadTo(q(a), q(b)),
|
||||
kurbo::PathEl::CurveTo(a, b, c) => kurbo::PathEl::CurveTo(q(a), q(b), q(c)),
|
||||
kurbo::PathEl::ClosePath => kurbo::PathEl::ClosePath,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
{
|
||||
use kurbo::{ParamCurve, ParamCurveDeriv};
|
||||
let st = StrokeLayer::new(Paint::solid(Color::BLACK), weight);
|
||||
let outline = raster_stroke_outline(&path, &st, weight.max(0.5), 0.25);
|
||||
for segment in path.segments() {
|
||||
if let kurbo::PathSeg::Cubic(c) = segment {
|
||||
for step in 1..20 {
|
||||
let t = step as f64 / 20.0;
|
||||
let p = c.eval(t);
|
||||
let v = c.deriv().eval(t).to_vec2().normalize();
|
||||
let n = kurbo::Vec2::new(-v.y, v.x);
|
||||
for side in [-1.0, 1.0] {
|
||||
assert_ne!(outline.winding(p + n * (side * weight.max(0.5) * 0.4)), 0, "missing geometric band, rounded={rounded}");
|
||||
assert_eq!(outline.winding(p + n * (side * weight.max(0.5) * 0.6)), 0, "outside geometric band, rounded={rounded}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut node = Node::path(
|
||||
d.alloc_id(),
|
||||
vectorcraft_geom::PathData::from_bezpath(&path),
|
||||
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight),
|
||||
);
|
||||
node.opacity = opacity;
|
||||
d.insert(Some(d.layers[0].id), 0, node).unwrap();
|
||||
let view =
|
||||
Affine::translate((500.0 + phase, 500.0 + phase)) * Affine::rotate(f64::to_radians(rotation)) * Affine::translate((-500.0, -500.0));
|
||||
let image =
|
||||
Renderer::new().render(&d, 1000, 1000, view, &RenderOptions { background: Some([255; 4]), artboards: false, ..Default::default() });
|
||||
// Collect only the measured radial bands once, in the original row-major order.
|
||||
// Each bin then adds the same pixels in the same order without rescanning the image.
|
||||
let mut ring = Vec::new();
|
||||
for y in 0..1000 {
|
||||
for x in 0..1000 {
|
||||
let dx = x as f64 + 0.5 - 500.0 - phase;
|
||||
let dy = y as f64 + 0.5 - 500.0 - phase;
|
||||
let distance = dx.hypot(dy);
|
||||
if !arcs.iter().any(|(r, _, _)| (distance - r).abs() <= 3.0) {
|
||||
continue;
|
||||
}
|
||||
let angle = (dy.atan2(dx) - f64::to_radians(rotation)).rem_euclid(std::f64::consts::TAU);
|
||||
ring.push((distance, angle, f64::from(255 - image.pixel(x, y)[0]) / 255.0));
|
||||
}
|
||||
}
|
||||
for (r, begin, end) in arcs {
|
||||
// 3 bins per arc avoid averaging away a washed-out section. The expected ink area
|
||||
// is independently r * angle * displayed weight, with the existing half-pixel floor.
|
||||
for bin in 0..3 {
|
||||
let lo = begin + (end - begin) * bin as f64 / 3.0;
|
||||
let hi = begin + (end - begin) * (bin + 1) as f64 / 3.0;
|
||||
let mut ink = 0.0;
|
||||
for &(distance, angle, value) in &ring {
|
||||
if (distance - r).abs() > 3.0 {
|
||||
continue;
|
||||
}
|
||||
if angle >= lo && angle < hi {
|
||||
ink += value;
|
||||
}
|
||||
}
|
||||
let expected = r * (hi - lo) * weight.max(0.5) * f64::from(opacity);
|
||||
assert!(
|
||||
(ink / expected - 1.0).abs() < 0.15,
|
||||
"weight {weight}, rotation {rotation}, phase {phase}, opacity {opacity}, arc {r} {lo}..{hi}: coverage {}",
|
||||
ink / expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
|
||||
|
||||
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
|
||||
|
||||
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
|
||||
|
||||
**PR #3272 revision — 2026-11-04:** Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
|
||||
|
||||
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
|
||||
|
||||
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
|
||||
|
||||
**PR #3272 revision — 2026-11-04:** Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
|
||||
|
||||
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
|
||||
|
||||
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
|
||||
|
||||
**PR #3272 revision — 2026-11-04:** Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
|
||||
|
||||
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
|
||||
|
||||
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
|
||||
|
||||
**PR #3272 revision — 2026-11-04:** Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged.
|
||||
|
||||
Reference in New Issue
Block a user