M4: a dashed line starts and ends with a half-length dash (#1718)

This commit is contained in:
BFlat
2026-10-31 05:28:15 +07:00
committed by GitHub
parent 71323ee780
commit 87e42a14f2
10 changed files with 251 additions and 28 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+114
View File
@@ -0,0 +1,114 @@
//! A dashed line starts and ends with a half-length dash, whatever its pattern: a whole number of
//! patterns fits the line, dashes and gaps scaled together. A closed path has no ends and runs its
//! pattern as it is. The numbers in [`the_public_example_comes_out_the_same`] are from a public
//! explanation of how the fit works.
use figcraft_doc::{StrokeAlign, StrokeProps, WidthProfile};
use figcraft_geom::Point;
use figcraft_geom::kurbo::{BezPath, Rect, Shape};
use crate::width::{dash_pattern, line_dashes, subpath_dashes};
use crate::{stroke_center, stroke_outline};
fn line(len: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((len, 0.0));
p
}
fn dashed(dashes: &[f64]) -> StrokeProps {
StrokeProps { weight: 2.0, dashes: dashes.to_vec(), align: StrokeAlign::Center, ..Default::default() }
}
fn covers(p: &BezPath, x: f64) -> bool {
p.winding(Point::new(x, 0.0)) != 0
}
/// Which of `xs` are on a dash.
fn on(p: &BezPath, xs: &[f64]) -> Vec<bool> {
xs.iter().map(|x| covers(p, *x)).collect()
}
#[test]
fn a_line_starts_and_ends_with_half_a_dash() {
// 10 on, 10 off along 100: 5 on, 10 off, 10 on, … 10 off, 5 on.
let s = stroke_center(&line(100.0), 2.0, &dashed(&[10.0, 10.0]));
assert_eq!(
on(&s, &[2.0, 7.0, 13.0, 16.0, 24.0, 26.0, 84.0, 86.0, 94.0, 98.0]),
[true, false, false, true, true, false, true, false, false, true]
);
assert!((s.area().abs() - 100.0).abs() < 0.01, "half of it is dashes: {}", s.area());
}
#[test]
fn a_whole_number_of_patterns_fits_the_line() {
// 107 is 5 patterns and a bit: scaled to 21.4 each, so it still ends on half a dash.
let (pattern, offset) = line_dashes(107.0, false, &[10.0, 10.0]);
assert!((pattern[0] - 10.7).abs() < 1e-9 && (pattern[1] - 10.7).abs() < 1e-9, "{pattern:?}");
assert!((offset - 5.35).abs() < 1e-9);
let s = stroke_center(&line(107.0), 2.0, &dashed(&[10.0, 10.0]));
assert_eq!(on(&s, &[5.0, 6.0, 101.0, 102.0]), [true, false, false, true]);
// A line shorter than one pattern takes one, scaled down.
assert_eq!(line_dashes(5.0, false, &[10.0, 10.0]), (vec![2.5, 2.5], 1.25));
// A pattern that already fits isn't lost to rounding.
assert_eq!(line_dashes(0.3, false, &[0.1, 0.05]).0.len(), 2);
let (fitted, _) = line_dashes(3.0 * 0.15, false, &[0.1, 0.05]);
assert!((fitted[0] - 0.1).abs() < 1e-9, "three patterns, not two: {fitted:?}");
}
#[test]
fn the_public_example_comes_out_the_same() {
// A 19 px line of [1, 2] draws six patterns of about 3.167.
let (pattern, offset) = line_dashes(19.0, false, &[1.0, 2.0]);
assert!((pattern[0] - 1.056).abs() < 1e-3 && (pattern[1] - 2.111).abs() < 1e-3, "{pattern:?}");
assert!((offset - 0.528).abs() < 1e-3);
}
/// Custom patterns too: the half is of the first dash, which the pattern comes round to at the end.
#[test]
fn a_custom_pattern_ends_on_half_its_first_dash() {
let s = stroke_center(&line(100.0), 2.0, &dashed(&[12.0, 4.0, 2.0, 2.0]));
// 6 on, 4 off, 2 on, 2 off, 12 on, …, and the last 6 on.
assert_eq!(on(&s, &[3.0, 8.0, 11.0, 13.0, 20.0, 97.0, 93.0]), [true, false, true, false, true, true, false]);
// An odd pattern draws repeated, so it fits as the doubled one.
let doubled = dash_pattern(&dashed(&[4.0, 2.0, 1.0])).unwrap();
assert_eq!(doubled, [4.0, 2.0, 1.0, 4.0, 2.0, 1.0]);
let s = stroke_center(&line(70.0), 2.0, &dashed(&[4.0, 2.0, 1.0]));
assert_eq!(on(&s, &[1.0, 3.0, 67.5, 69.0]), [true, false, false, true], "half of 4 at each end");
}
#[test]
fn a_closed_path_runs_its_pattern_as_it_is() {
let rect = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
assert_eq!(subpath_dashes(&rect, &[10.0, 7.0]), (vec![10.0, 7.0], 0.0));
// The open one measures the same and is fitted.
let (open, offset) = line_dashes(300.0, false, &[10.0, 7.0]);
assert!(open[0] != 10.0 && offset > 0.0);
}
#[test]
fn each_subpath_fits_the_pattern_to_its_own_length() {
let mut two = line(100.0);
two.move_to((0.0, 50.0));
two.line_to((107.0, 50.0));
let s = stroke_center(&two, 2.0, &dashed(&[10.0, 10.0]));
assert!(s.winding(Point::new(98.0, 0.0)) != 0 && s.winding(Point::new(102.0, 50.0)) != 0, "both end on half a dash");
assert!(s.winding(Point::new(92.0, 0.0)) == 0 && s.winding(Point::new(99.0, 50.0)) == 0);
}
/// A variable-width stroke is drawn by its own code: its dashes land in the same places.
#[test]
fn a_variable_width_stroke_dashes_the_same_way() {
let props = StrokeProps { width_profile: Some(WidthProfile::uniform()), ..dashed(&[10.0, 10.0]) };
let s = stroke_outline(&line(100.0), &props, false);
assert_eq!(on(&s, &[2.0, 7.0, 13.0, 16.0, 98.0]), [true, false, false, true, true]);
}
/// Nothing to fit: an empty line or a pattern of nothing stays as it is.
#[test]
fn nothing_to_fit_stays_as_it_is() {
assert_eq!(line_dashes(0.0, false, &[10.0, 10.0]), (vec![10.0, 10.0], 0.0));
assert_eq!(line_dashes(100.0, false, &[0.0, 0.0]), (vec![0.0, 0.0], 0.0));
assert!(stroke_center(&BezPath::new(), 2.0, &dashed(&[10.0, 10.0])).elements().is_empty());
}
+13 -4
View File
@@ -16,6 +16,8 @@ mod budget;
#[cfg(test)]
mod budget_tests;
#[cfg(test)]
mod dash_ends_tests;
#[cfg(test)]
mod dot_dash_tests;
#[cfg(test)]
mod ops_tests;
@@ -337,7 +339,7 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
return BezPath::new();
}
let width = width.min(MAX_STROKE_WIDTH);
let mut style = kurbo::Stroke::new(width)
let style = kurbo::Stroke::new(width)
.with_join(kjoin(props.join))
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
.with_start_cap(kcap(props.cap_start))
@@ -346,10 +348,17 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;
let round = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round));
let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if round && d == 0.0 { 1e-3 } else { d }).collect::<Vec<_>>());
if let Some(pat) = pattern.filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) {
style = style.with_dashes(0.0, pat);
let opts = kurbo::StrokeOpts::default();
let Some(pat) = pattern.filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) else {
return kurbo::stroke(path.iter(), &style, &opts, 0.05);
};
// Each subpath fits the pattern to its own length.
let mut out = BezPath::new();
for sub in figcraft_geom::split_subpaths(path) {
let (dashes, offset) = width::subpath_dashes(&sub, &pat);
out.extend(kurbo::stroke(sub.iter(), &style.clone().with_dashes(offset, dashes), &opts, 0.05));
}
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
out
}
/// The painted stroke area of a shape as a fill path (local coordinates). `closed` says whether the
+33 -5
View File
@@ -8,7 +8,7 @@
//! fill it with the non-zero rule.
use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile};
use figcraft_geom::kurbo::{self, BezPath, Point, Vec2};
use figcraft_geom::kurbo::{self, BezPath, PathEl, Point, Shape as _, Vec2};
use figcraft_geom::measure::FLATTEN_TOL;
/// The most points the outline works with; longer paths fall back to a uniform stroke.
pub use figcraft_geom::measure::MAX_SAMPLES as MAX_WIDTH_SAMPLES;
@@ -193,11 +193,12 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, s
push_contour(path, ring);
}
/// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths).
fn dashes(pts: &[Point], hw: &[Half], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<Half>)> {
/// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths), `offset` into
/// the pattern at its start.
fn dashes(pts: &[Point], hw: &[Half], pattern: &[f64], offset: f64) -> Vec<(Vec<Point>, Vec<Half>)> {
let mut out = Vec::new();
let Some(&first) = pattern.first() else { return out };
let (mut idx, mut left, mut on) = (0usize, first, true);
let (mut idx, mut left, mut on) = (0usize, (first - offset).max(0.0), true);
let mut cur: (Vec<Point>, Vec<Half>) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect());
for (p, h) in pts.windows(2).zip(hw.windows(2)) {
let (a, b, ha, hb) = (p[0], p[1], h[0], h[1]);
@@ -242,6 +243,32 @@ pub fn dash_pattern(props: &StrokeProps) -> Option<Vec<f64>> {
Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes })
}
/// How a line `len` long draws `pattern` (from [`dash_pattern`]): the pattern and how far into it
/// the line starts. An open line starts and ends with a half-length dash, so a whole number of
/// patterns fits it exactly, dashes and gaps scaled together, and it starts halfway through the
/// first dash. A closed line has no ends: it runs the pattern as it is.
pub fn line_dashes(len: f64, closed: bool, pattern: &[f64]) -> (Vec<f64>, f64) {
let total: f64 = pattern.iter().sum();
if closed || !(len > 1e-9 && total > 0.0) {
return (pattern.to_vec(), 0.0);
}
// A hair under a whole number is that number: a pattern already fitted stays as it is, also
// after a trip through a file that rounds it.
let scale = len / ((len / total + 0.01).floor().max(1.0) * total);
let fitted: Vec<f64> = pattern.iter().map(|d| d * scale).collect();
let offset = fitted.first().map_or(0.0, |d| d / 2.0);
(fitted, offset)
}
/// [`line_dashes`] for a subpath.
pub fn subpath_dashes(sub: &BezPath, pattern: &[f64]) -> (Vec<f64>, f64) {
let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath));
line_dashes(sub.perimeter(DASH_ACCURACY), closed, pattern)
}
/// How exactly a subpath is measured to fit its dashes (the stroke's own tolerance).
const DASH_ACCURACY: f64 = 0.05;
/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`;
/// past that a stroke draws solid instead of one outline per dash (#1060).
pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool {
@@ -301,7 +328,8 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
.collect();
match &pattern {
Some(pat) if dashes_fit(len, pat) => {
for (p, h) in dashes(&pts, &hw, pat) {
let (pat, offset) = line_dashes(len, line.closed, pat);
for (p, h) in dashes(&pts, &hw, &pat, offset) {
outline_piece(&mut out, &p, &h, false, style);
}
}
+2 -2
View File
@@ -104,11 +104,11 @@ fn joins_and_dashes() {
let dashed = StrokeProps { dashes: vec![10.0, 10.0], ..props(2.0, Some(WidthProfile::uniform())) };
let s = stroke_outline(&line(100.0), &dashed, false);
assert!((s.area().abs() - 100.0).abs() < 1.0, "{}", s.area());
assert!(covers(&s, 5.0, 0.0) && !covers(&s, 15.0, 0.0));
assert!(covers(&s, 2.0, 0.0) && !covers(&s, 10.0, 0.0) && covers(&s, 20.0, 0.0));
// Dashes keep the width of where they are on the path.
let tapered = StrokeProps { dashes: vec![10.0, 10.0], ..props(10.0, WidthPreset::Wedge.profile()) };
let s = stroke_outline(&line(100.0), &tapered, false);
assert!(covers(&s, 5.0, 4.0) && !covers(&s, 85.0, 4.0));
assert!(covers(&s, 2.0, 4.0) && !covers(&s, 80.0, 4.0));
// Zero-length dashes with round caps are dots.
let dots =
StrokeProps { dashes: vec![0.0, 10.0], cap_start: StrokeCap::Round, cap_end: StrokeCap::Round, ..props(4.0, Some(WidthProfile::uniform())) };
+5 -1
View File
@@ -529,7 +529,11 @@ pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64) -> figcraft_doc::StrokeProp
};
let mut dashes: Vec<f64> = s.dasharray().unwrap_or(&[]).iter().map(|v| f64::from(*v) * k).collect();
if !dashes.is_empty() {
dashes = dash_offset(&dashes, f64::from(s.dashoffset()) * k, cap == StrokeCap::None).unwrap_or(dashes);
let offset = f64::from(s.dashoffset()) * k;
// Half a dash in is how a line starts by itself (and how export writes one).
if dashes.first().is_none_or(|d| (d / 2.0 - offset).abs() > 1e-3) {
dashes = dash_offset(&dashes, offset, cap == StrokeCap::None).unwrap_or(dashes);
}
}
figcraft_doc::StrokeProps {
weight: f64::from(s.width().get()) * k,
+19 -12
View File
@@ -138,6 +138,20 @@ fn path_d(p: &BezPath) -> String {
s
}
/// A stroke's path as `d` values, each with its dash attributes. A dashed one takes a `<path>` for
/// each subpath, as each fits the pattern to its own length (see
/// [`figcraft_pathops::width::line_dashes`]), so a viewer draws what the canvas does.
fn dashed_pieces(path: BezPath, k: &figcraft_doc::StrokeProps) -> Vec<(String, String)> {
let list = |v: &[f64]| v.iter().map(|d| num(*d)).collect::<Vec<_>>().join(" ");
let Some(pattern) = figcraft_pathops::width::dash_pattern(k) else { return vec![(path_d(&path), String::new())] };
let piece = |sub: BezPath| {
let (dashes, offset) = figcraft_pathops::width::subpath_dashes(&sub, &pattern);
let start = if offset > 0.0 { format!(" stroke-dashoffset=\"{}\"", num(offset)) } else { String::new() };
(path_d(&sub), format!(" stroke-dasharray=\"{}\"{start}", list(&dashes)))
};
figcraft_geom::split_subpaths(&path).into_iter().map(piece).collect()
}
/// A matrix scale/shear coefficient to 6 significant digits (unlike [`num`], small values survive).
fn coeff(v: f64) -> String {
if !v.is_finite() || v == 0.0 {
@@ -816,22 +830,15 @@ impl W<'_> {
StrokeJoin::Round => " stroke-linejoin=\"round\"".into(),
StrokeJoin::Bevel => " stroke-linejoin=\"bevel\"".into(),
};
let dash = if k.dashes.is_empty() {
String::new()
} else {
format!(" stroke-dasharray=\"{}\"", k.dashes.iter().map(|d| num(*d)).collect::<Vec<_>>().join(" "))
};
let pieces = dashed_pieces(path, k);
let mut out = String::new();
for p in &n.strokes {
if let Some(a) = self.paint_attr(p, n.width, n.height) {
let a = a.replace("fill-opacity=", "stroke-opacity=").replacen("fill=", "stroke=", 1);
let _ = write!(
out,
"<path transform=\"{}\" d=\"{}\" fill=\"none\" {a} stroke-width=\"{}\"{cap}{join}{dash}/>",
matrix(xf),
path_d(&path),
num(w)
);
for (d, dash) in &pieces {
let _ =
write!(out, "<path transform=\"{}\" d=\"{d}\" fill=\"none\" {a} stroke-width=\"{}\"{cap}{join}{dash}/>", matrix(xf), num(w));
}
}
}
Some(out)
+58
View File
@@ -324,3 +324,61 @@ fn matrix_snaps_rotation_noise_and_tiny_values() {
assert_eq!(matrix(r), "matrix(0 1 -1 0 0 0)");
assert_eq!(matrix(Affine::scale(1e-320)), "matrix(0 0 0 0 0 0)");
}
/// A dashed stroke's `<path>`s in `svg`, each as (dash pattern, dash offset).
fn dash_attrs(svg: &str) -> Vec<(String, Option<String>)> {
let attr = |e: &str, name: &str| e.split(&format!(" {name}=\"")).nth(1).and_then(|r| r.split('"').next()).map(str::to_string);
svg.split("<path").filter_map(|e| Some((attr(e, "stroke-dasharray")?, attr(e, "stroke-dashoffset")))).collect()
}
/// A page with one centred dashed stroke along `network`'s path or around a rectangle.
fn dashed(kind: NodeKind, dashes: Vec<f64>) -> String {
let mut d = Document::new("dashes");
let p = d.first_page().unwrap();
let n = d.create(p, kind, "Dashed", 0.0, 0.0, 100.0, 50.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills.clear();
node.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())];
node.stroke.align = figcraft_doc::StrokeAlign::Center;
node.stroke.dashes = dashes;
export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap()
}
/// A viewer draws what the canvas does: an open line's dashes fitted and started half a dash in.
#[test]
fn a_dashed_line_exports_as_the_canvas_draws_it() {
let line = Segment::line(0, 1);
let open = VectorNetwork { vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(107.0, 0.0)], segments: vec![line], ..Default::default() };
let svg = dashed(NodeKind::Vector { network: open }, vec![10.0, 10.0]);
assert_eq!(dash_attrs(&svg), [("10.7 10.7".to_string(), Some("5.35".to_string()))], "{svg}");
}
#[test]
fn a_closed_dashed_path_exports_its_pattern_as_it_is() {
let svg = dashed(NodeKind::Rectangle, vec![10.0, 7.0]);
assert_eq!(dash_attrs(&svg), [("10 7".to_string(), None)], "{svg}");
}
/// Each subpath fits the pattern to its own length, so each is a `<path>` of its own.
#[test]
fn each_subpath_of_a_dashed_vector_exports_its_own_fit() {
let two = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0), Vertex::new(0.0, 20.0), Vertex::new(107.0, 20.0)],
segments: vec![Segment::line(0, 1), Segment::line(2, 3)],
..Default::default()
};
let svg = dashed(NodeKind::Vector { network: two }, vec![10.0, 10.0]);
assert_eq!(dash_attrs(&svg).len(), 2, "{svg}");
}
/// What export writes opens as the same line, the offset not counted twice.
#[test]
fn an_exported_dashed_line_opens_as_the_same_line() {
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="20"><line x2="107" stroke="#000" stroke-dasharray="10.7 10.7" stroke-dashoffset="5.35"/></svg>"##;
let mut d = Document::new("back");
let p = d.first_page().unwrap();
let frame = import_svg(&mut d, src.as_bytes(), p, 0.0, 0.0, "Back").unwrap();
let dashes: Vec<Vec<f64>> =
d.subtree(frame).iter().filter_map(|i| d.node(*i)).filter(|n| !n.stroke.dashes.is_empty()).map(|n| n.stroke.dashes.clone()).collect();
assert!(matches!(dashes.as_slice(), [d] if d.len() == 2 && d.iter().all(|v| (v - 10.7).abs() < 1e-4)), "{dashes:?}");
}
+3 -2
View File
@@ -7,9 +7,10 @@
<polyline id="miter" points="20,100 50,70 80,100" fill="none" stroke="#000000" stroke-width="10" stroke-miterlimit="4"/>
<polyline id="round-join" points="90,100 120,70 150,100" fill="none" stroke="#000000" stroke-width="10" stroke-linejoin="round"/>
<polyline id="bevel" points="160,100 190,70 220,100" fill="none" stroke="#000000" stroke-width="10" stroke-linejoin="bevel"/>
<!-- A dashed line starts and ends with half a dash: 10 on, 10 off, 20 on, 10 off, … -->
<!-- probe 15 10 #000000ff -->
<!-- probe 35 10 #00000000 -->
<!-- probe 45 10 #000000ff -->
<!-- probe 25 10 #00000000 -->
<!-- probe 40 10 #000000ff -->
<!-- probe 16 40 #ff0000ff -->
<!-- probe 63 40 #ff0000ff -->
<!-- probe 78 40 #00000000 -->

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+2 -1
View File
File diff suppressed because one or more lines are too long