diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1164dcc..b3db3ea 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1167,10 +1167,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, 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 = 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, 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, bool)> = vec![]; + let mut cur: Vec = 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, cap: kurbo::Cap, width: f64) -> Vec { + 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))> { diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 6da19b5..b75ac4c 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -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]); +}