Render: dashes with round caps keep their length; square caps only at the line's ends
A round-capped dashed line (PowerPoint's "Round Dot" is sysDot with round caps) drew as a solid line: each dash grew by its caps and closed the one-width gaps. PowerPoint keeps the caps inside each dash, so dots stay separate circles. Square-capped dashes end flat in PowerPoint; only the line's own start (and its end, when it ends in a dash) is squared off. Wild synthetic set: 12 pt round-cap dashes 12.69 -> 7.05, 12 pt square-cap dashes 11.76 -> 6.12, custom dashes 2.12 -> 0.10. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e56823d3ec
commit
5bd0ae87de
|
|
@ -1167,10 +1167,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
|||
};
|
||||
// Hairlines stay visible at any zoom.
|
||||
let min_w = 0.5 / scale_of(m).max(1e-6);
|
||||
let mut sk = kurbo::Stroke::new(lw.max(min_w)).with_caps(cap).with_join(join);
|
||||
let sw = lw.max(min_w);
|
||||
let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join);
|
||||
// Square caps on a dashed line square off only the line's own two ends (where a dash is
|
||||
// drawn): each dash itself ends flat, as PowerPoint draws them.
|
||||
let mut square_ends = None;
|
||||
if let Some(d) = &line.dash {
|
||||
let pat: Vec<f64> = d.pattern().iter().map(|v| v * lw.max(0.75)).collect();
|
||||
let pat = dash_with_caps(pat, cap, sw);
|
||||
if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) {
|
||||
if cap == kurbo::Cap::Square {
|
||||
sk = sk.with_caps(kurbo::Cap::Butt);
|
||||
square_ends = Some(square_dash_ends(path, &pat, sw));
|
||||
}
|
||||
sk = sk.with_dashes(0.0, pat);
|
||||
}
|
||||
}
|
||||
|
|
@ -1200,6 +1209,103 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
|||
}
|
||||
ctx.set_stroke(sk);
|
||||
ctx.stroke_path(path);
|
||||
if let Some(ends) = square_ends {
|
||||
ctx.fill_path(&ends);
|
||||
}
|
||||
}
|
||||
|
||||
/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square
|
||||
/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end
|
||||
/// when the pattern is in a dash there.
|
||||
fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen};
|
||||
let mut out = BezPath::new();
|
||||
let period: f64 = pattern.iter().sum();
|
||||
let mut cap = |at: Point, dir: Vec2| {
|
||||
let n = Vec2::new(-dir.y, dir.x) * (width / 2.0);
|
||||
let back = dir * (width / 2.0);
|
||||
out.move_to(at + n);
|
||||
out.line_to(at + n + back);
|
||||
out.line_to(at - n + back);
|
||||
out.line_to(at - n);
|
||||
out.close_path();
|
||||
};
|
||||
let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| {
|
||||
let d = seg.eval(t1) - seg.eval(t0);
|
||||
let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() };
|
||||
if d.hypot() > 1e-9 { Some(d.normalize()) } else { None }
|
||||
};
|
||||
let mut subs: Vec<(Vec<kurbo::PathSeg>, bool)> = vec![];
|
||||
let mut cur: Vec<kurbo::PathSeg> = vec![];
|
||||
let mut closed = false;
|
||||
let mut last = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if !cur.is_empty() {
|
||||
subs.push((std::mem::take(&mut cur), closed));
|
||||
}
|
||||
closed = false;
|
||||
last = p;
|
||||
start = p;
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)),
|
||||
PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)),
|
||||
PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)),
|
||||
PathEl::ClosePath => {
|
||||
closed = true;
|
||||
last = start;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
last = seg.end();
|
||||
cur.push(seg);
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
subs.push((cur, closed));
|
||||
}
|
||||
for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) {
|
||||
let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue };
|
||||
if let Some(d) = dir(first, 1e-3, 0.0) {
|
||||
cap(first.start(), d);
|
||||
}
|
||||
let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum();
|
||||
if !(period > 0.0 && len.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let mut pos = len % period;
|
||||
// Ending exactly on a period boundary is the end of a gap.
|
||||
let mut on = pos > period * 1e-6;
|
||||
for v in pattern.iter().cycle().take(pattern.len()) {
|
||||
if pos <= *v {
|
||||
break;
|
||||
}
|
||||
pos -= v;
|
||||
on = !on;
|
||||
}
|
||||
if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) {
|
||||
cap(end.end(), d);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As
|
||||
/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a
|
||||
/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid
|
||||
/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a
|
||||
/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see
|
||||
/// `square_dash_ends`.)
|
||||
fn dash_with_caps(mut pat: Vec<f64>, cap: kurbo::Cap, width: f64) -> Vec<f64> {
|
||||
if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) {
|
||||
return pat;
|
||||
}
|
||||
for (i, v) in pat.iter_mut().enumerate() {
|
||||
*v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width };
|
||||
}
|
||||
pat
|
||||
}
|
||||
|
||||
fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
||||
|
|
|
|||
|
|
@ -48,3 +48,52 @@ fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() {
|
|||
assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled");
|
||||
assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200));
|
||||
}
|
||||
|
||||
fn dotted_line(cap: deckcraft_model::style::LineCap, len: f64) -> Shape {
|
||||
Shape {
|
||||
id: ShapeId(11),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, len, 0.0)),
|
||||
geom: Geom::preset("line"),
|
||||
line: Some(Line {
|
||||
fill: solid(RED),
|
||||
width: Some(10.0),
|
||||
dash: Some(deckcraft_model::style::Dash::Preset { name: "sysDot".into() }),
|
||||
cap: Some(cap),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_dots_are_separate_circles_with_the_caps_inside_each_dash() {
|
||||
use deckcraft_model::style::LineCap;
|
||||
// PowerPoint's "Round Dot": one width on, one off, round caps. The caps sit inside the dash, so
|
||||
// the dots stay apart (they used to grow into each other and draw a solid line).
|
||||
let img = render(vec![dotted_line(LineCap::Round, 200.0)]);
|
||||
assert!(close(img.pixel(100, 100), RED, 40), "a dot centred on the start {:?}", img.pixel(100, 100));
|
||||
assert!(close(img.pixel(120, 100), RED, 40), "the next dot one period on");
|
||||
assert!(close(img.pixel(110, 100), WHITE, 10), "a gap between dots {:?}", img.pixel(110, 100));
|
||||
// Flat caps: each dash is a width long, from the start.
|
||||
let img = render(vec![dotted_line(LineCap::Flat, 200.0)]);
|
||||
assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10));
|
||||
// Square caps: dashes end flat; only the line's own start is squared off, half a width back.
|
||||
let img = render(vec![dotted_line(LineCap::Square, 200.0)]);
|
||||
assert!(close(img.pixel(97, 100), RED, 40), "square cap before the start {:?}", img.pixel(97, 100));
|
||||
assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10), "dashes keep their length");
|
||||
assert!(close(img.pixel(122, 100), RED, 40) && close(img.pixel(118, 100), WHITE, 10), "no cap on the next dash");
|
||||
// The end: 200 pt is ten whole periods, so the line ends in a gap and gets no cap.
|
||||
assert!(close(img.pixel(302, 100), WHITE, 10), "{:?}", img.pixel(302, 100));
|
||||
// 205 pt ends half way through a dash: squared off past the end.
|
||||
let img = render(vec![dotted_line(LineCap::Square, 205.0)]);
|
||||
assert!(close(img.pixel(308, 100), RED, 40), "{:?}", img.pixel(308, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_lengths_make_room_for_round_and_square_caps() {
|
||||
assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Round, 1.0), vec![3.0, 4.0]);
|
||||
assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Round, 2.0), vec![0.002, 3.0]);
|
||||
assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Square, 2.0), vec![1.0, 1.0]);
|
||||
assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Butt, 1.0), vec![4.0, 3.0]);
|
||||
assert_eq!(dash_with_caps(vec![4.0, 3.0, 1.0], kurbo::Cap::Round, 1.0), vec![4.0, 3.0, 1.0]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue