Merge pull request #1849 from samhyun/fix/stroke-corner-reach

M8: a stroke's square dash ends and brush shapes paint within its bounds
This commit is contained in:
BFlat
2026-11-07 12:21:35 +08:00
committed by GitHub
5 changed files with 88 additions and 10 deletions
+5 -1
View File
@@ -949,7 +949,11 @@ impl Node {
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
// box; an arc's corners don't.
let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
// A dash's square end stands out along the path as far as the band does across it:
// its corners reach √2 times as far from the path, past the box round a curve.
let square_dashes = !self.stroke.dashes.is_empty() && self.stroke.band_caps().contains(&StrokeCap::Square);
let corners = if square_dashes { miter.max(std::f64::consts::SQRT_2) } else { miter };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * corners
} else {
0.0
};
+11 -4
View File
@@ -146,10 +146,16 @@ impl Brush {
}
}
/// How much wider than the stroke weight the brush can paint (larger copies, strays).
pub fn reach_factor(&self) -> f64 {
/// The farthest a built-in brush shape reaches from the path, in stroke weights: the ink
/// brush's ragged edges stand a little past half the weight.
pub const SHAPE_REACH: f64 = 0.54;
/// How much wider than the stroke weight the brush can paint where a width profile scales it
/// at most `scale`: its shapes' edges and larger copies, which the profile scales, and strays,
/// which it doesn't.
pub fn reach_factor(&self, scale: f64) -> f64 {
let b = self.sanitized();
1.0 + b.size_jitter + 2.0 * b.scatter
2.0 * Self::SHAPE_REACH * (1.0 + b.size_jitter) * scale + 2.0 * b.scatter
}
}
@@ -188,7 +194,8 @@ impl StrokeProps {
/// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile
/// and widened by a brush's reach).
pub fn max_weight(&self) -> f64 {
self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) * self.brush.as_ref().map_or(1.0, Brush::reach_factor)
let scale = self.profile().map_or(1.0, WidthProfile::max_scale);
self.weight * self.brush.as_ref().map_or(scale, |b| b.reach_factor(scale))
}
/// The miter limit as the angle (degrees) below which joins bevel: limit = 1 / sin(angle / 2),
+16 -5
View File
@@ -21,14 +21,25 @@ fn props(weight: f64, b: &Brush) -> StrokeProps {
StrokeProps { weight, align: StrokeAlign::Center, brush: Some(b.clone()), ..Default::default() }
}
/// What a brush paints stays within half [`StrokeProps::max_weight`] of the path, which the
/// stroke's bounds go by: plain, with copies grown, turned and strayed all they can, and with a
/// width profile thinning or widening it (strays keep their distance, whatever the profile).
#[test]
fn every_preset_draws_inside_its_reach() {
for p in PRESETS {
let b = brush(p.id);
let out = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap();
let bb = out.bounding_box();
assert!(!out.elements().is_empty(), "{}", p.id);
assert!(bb.y0 >= -5.5 && bb.y1 <= 5.5 && bb.x0 >= -5.5 && bb.x1 <= 205.5, "{}: {bb:?}", p.id);
let wild = Brush { size_jitter: 1.0, rotation_jitter: 1.0, scatter: 4.0, ..brush(p.id) };
for (b, profile) in [(brush(p.id), None), (wild.clone(), None), (wild.clone(), Some(0.2)), (wild, Some(2.0))] {
let k = StrokeProps { width_profile: profile.map(|s| WidthProfile::new([(0.0, s), (1.0, s)])), ..props(10.0, &b) };
let out = outline(&line(200.0), 10.0, &k, &b).unwrap();
let bb = out.bounding_box();
let reach = k.max_weight() / 2.0;
assert!(!out.elements().is_empty(), "{}", p.id);
assert!(
bb.y0 >= -reach && bb.y1 <= reach && bb.x0 >= -reach && bb.x1 <= 200.0 + reach,
"{} {b:?} {profile:?}: {bb:?}, reach {reach}",
p.id
);
}
}
assert!(outline(&line(10.0), 1.0, &props(1.0, &brush("nope")), &brush("nope")).is_none());
}
+2
View File
@@ -570,6 +570,8 @@ mod solo_tests;
#[cfg(test)]
mod spread_tests;
#[cfg(test)]
mod stroke_reach_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod width_tests;
+54
View File
@@ -0,0 +1,54 @@
//! What a stroke paints stays within the bounds an export and a redraw go by: square dash ends
//! that stand out round a curve, brush shapes past half the weight and brush copies that stray
//! further than a width profile thins them included.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Brush, Document, Node, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, WidthProfile};
use super::*;
/// How many pixels `kind` (at (200, 200), `size` square) with a 40-unit stroke styled by `style`
/// paints more than a pixel outside its export bounds, on a 600 × 600 view.
fn painted_outside(kind: &NodeKind, size: f64, align: StrokeAlign, miter_limit: f64, style: fn(&mut Node)) -> usize {
let mut d = Document::new("reach");
let p = d.first_page().unwrap();
let n = d.create(p, kind.clone(), "S", 200.0, 200.0, size, size).unwrap();
let node = d.get_mut(n).unwrap();
node.fills.clear();
node.corners.radii = [30.0; 4];
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (40.0, align, StrokeJoin::Miter, miter_limit);
style(node);
let b = crate::export_bounds(&d, n).unwrap();
let mut pix = Pixmap::new(600, 600).unwrap();
render_nodes_into(&d, &[n], &View { origin: (0.0, 0.0), scale: 1.0, width: 600, height: 600 }, &mut pix);
let (xs, ys) = (b.x0 - 1.0..=b.x1 + 1.0, b.y0 - 1.0..=b.y1 + 1.0);
let inside = |i: usize| xs.contains(&((i % 600) as f64 + 0.5)) && ys.contains(&((i / 600) as f64 + 0.5));
pix.data().chunks(4).enumerate().filter(|(i, p)| p[3] > 0 && !inside(*i)).count()
}
#[test]
fn a_stroke_with_corners_paints_within_its_bounds() {
type Style = fn(&mut Node);
let strokes: [(&str, Style); 4] = [
("square dash ends", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![30.0, 30.0], Some(StrokeCap::Square))),
("short square dashes", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![1.0, 7.0], Some(StrokeCap::Square))),
("the ink brush", |n| n.stroke.brush = Some(Brush::new("ink"))),
("a thinned scatter brush", |n| {
n.stroke.brush = Some(Brush { scatter: 1.0, ..Brush::new("dots") });
n.stroke.width_profile = Some(WidthProfile::new([(0.0, 0.5), (1.0, 0.5)]));
}),
];
let circle = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 };
let shapes = [(&circle, 200.0), (&circle, 30.0), (&NodeKind::Rectangle, 200.0), (&NodeKind::Star { count: 5, inner_ratio: 0.4 }, 200.0)];
for (kind, size) in shapes {
for (name, style) in strokes {
for align in [StrokeAlign::Center, StrokeAlign::Outside] {
for miter_limit in [4.0, 1.0] {
let outside = painted_outside(kind, size, align, miter_limit, style);
assert_eq!(outside, 0, "{kind:?} {size}, {name}, {align:?}, mitre limit {miter_limit}");
}
}
}
}
}