mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
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:
@@ -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
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user