From 85335e9e1405afc9e54b4ccee5488ca5291854ae Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:35:22 +0200 Subject: [PATCH] Scribble: one zig-zag per region, no lines across holes and notches (#1648) The rows' spans were all chained into one polyline, so where a row had more than one span the line ran across the hole between them, outside the shape. A span now continues the previous row's chain whose connecting line stays inside the shape (the nearest one); otherwise it starts a chain of its own. In a convex shape the connector always stays inside, so the result there is unchanged (render goldens pass). Fixes #1648 --- crates/effects/src/stylize.rs | 71 +++++++++++++++++++++++++++-------- crates/effects/src/tests.rs | 17 +++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index 1de19312..2a82d1de 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -94,8 +94,25 @@ fn flatten(path: &PathData) -> Vec> { polys } -/// Scribble (simplified): a single zig-zag hatching across the filled area at `angle`, outlined -/// to `strokeWidth` so the fill paints it. +/// Whether segment `p`–`q` properly crosses an edge of `polys`: its own ends, which lie on the +/// outline, don't count, nor does running along an edge. +fn crosses_outline(p: Point, q: Point, polys: &[Vec]) -> bool { + let (p, q) = (p.lerp(q, 1e-3), q.lerp(p, 1e-3)); + let side = |a: Point, b: Point, c: Point| (b - a).cross(c - a); + // Strictly on opposite sides, beyond rounding noise (the sides are areas: length × length). + let apart = |s1: f64, s2: f64, eps: f64| (s1 > eps && s2 < -eps) || (s1 < -eps && s2 > eps); + let pq = (q - p).hypot(); + polys.iter().any(|pl| { + pl.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + let eps = 1e-9 * pq * (b - a).hypot(); + apart(side(p, q, a), side(p, q, b), eps) && apart(side(a, b, p), side(a, b, q), eps) + }) + }) +} + +/// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region +/// of the shape, outlined to `strokeWidth` so the fill paints it. pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { let angle = num(p, "angle", 30.0).to_radians(); let overlap = num(p, "overlap", 0.0); @@ -117,7 +134,16 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { return PathData::default(); } let step = spacing.max((y1 - y0) / 2000.0); - let mut pts: Vec = vec![]; + // One zig-zag per region (#1648): a span continues a chain of the previous row as long as the + // line from that row's end to this one's start stays inside the shape; otherwise it starts a + // chain of its own. So no line crosses a hole or a notch. In a convex shape that line always + // stays inside: one chain, as before. + struct Chain { + pts: Vec, + end: Point, + row: u64, + } + let mut chains: Vec = vec![]; let mut y = y0 + step / 2.0; let mut row = 0u64; while y < y1 { @@ -131,29 +157,42 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { } } xs.sort_by(f64::total_cmp); - let mut spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect(); - if row % 2 == 1 { - spans.reverse(); - } + let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect(); + let open = chains.len(); for (a, bx) in spans { let jitter = |k: u64| variation * noise(seed, row, k, 3); let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3))); - if row.is_multiple_of(2) { - pts.push(l); - pts.push(r); - } else { - pts.push(r); - pts.push(l); + let even = row.is_multiple_of(2); + let pair = if even { [l, r] } else { [r, l] }; + // Un-jittered ends of the span in drawing order. + let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) }; + // The nearest chain of the previous row that this span can join without leaving + // the shape. + let prev = row.checked_sub(1); + let pick = (0..open) + .filter(|&k| Some(chains[k].row) == prev && !crosses_outline(chains[k].end, start, &polys)) + .min_by(|&i, &j| (chains[i].end - start).hypot().total_cmp(&(chains[j].end - start).hypot())); + match pick { + Some(k) => { + let c = &mut chains[k]; + c.pts.extend(pair); + (c.end, c.row) = (end, row); + } + None => chains.push(Chain { pts: pair.to_vec(), end, row }), } } row += 1; y += step; } - if pts.len() < 2 { + let lines: Vec = chains + .into_iter() + .filter(|c| c.pts.len() >= 2) + .map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) }) + .collect(); + if lines.is_empty() { return PathData::default(); } - let line = if curvy > 0.0 { catmull_rom(&pts, false, curvy * 2.0) } else { SubPath::polyline(&pts, false) }; - let mut center = PathData::single(line); + let mut center = PathData::new(lines); center.transform(to_local); let stroke = kurbo::Stroke::new(width).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round); let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05); diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index b73f8439..ffafc9f8 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -392,6 +392,23 @@ fn twist_fixes_the_outer_circle_and_rotates_inside() { assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}"); } +#[test] +fn scribble_does_not_cross_holes() { + // #1648: a U (opening at the top, the notch x 30..70, y 0..60). Rows (y 2.5, 7.5, ...) through the prongs have + // two spans; their zig-zags must stay apart instead of crossing the notch. + let u = [(0.0, 0.0), (30.0, 0.0), (30.0, 60.0), (70.0, 60.0), (70.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let u = PathData::single(SubPath::polyline(&u.map(|(x, y)| Point::new(x, y)), true)); + let params = json!({"angle": 0, "variation": 0, "curviness": 0, "strokeWidth": 2, "spacing": 5}); + let out = run("stylize.scribble", params, &u).to_bezpath(); + for x in [40.0, 50.0, 60.0] { + for y in [12.5, 32.5, 52.5] { + assert_eq!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(x, y)), 0, "scribble covers the notch at ({x}, {y})"); + } + } + // The bar below the notch is still hatched. + assert_ne!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(50.0, 82.5)), 0); +} + #[test] fn scribble_produces_filled_outline_inside_bounds() { let out = run("stylize.scribble", json!({}), &square());