diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e86478c3..66ea8203 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -695,16 +695,17 @@ impl Renderer { ctx.pop_layer(); } StrokeAlign::Outside if closed => { - let outline = - kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px); - let mut p = outline; - // Remove the interior: even-odd with the path itself. - for el in bp.elements() { - p.push(*el); - } - ctx.set_fill_rule(peniko::Fill::EvenOdd); - ctx.fill_path(&p); - ctx.set_fill_rule(peniko::Fill::NonZero); + // Subtract the filled path from a double-width stroke. Combining a stroked + // outline (already hollow) with the path using even-odd would paint its centre. + // Isolate the stroke so the subtraction leaves the item's fill/content and + // backdrop intact; the same nonzero fill rule also preserves compound holes. + ctx.push_layer(None, None, None, None, None); + ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke }); + ctx.stroke_path(bp); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + ctx.fill_path(bp); + ctx.pop_layer(); + ctx.pop_layer(); } _ => { ctx.set_stroke(stroke); diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/stroke_alignment.rs new file mode 100644 index 00000000..eee8450a --- /dev/null +++ b/crates/render/tests/stroke_alignment.rs @@ -0,0 +1,156 @@ +//! Synthetic pixel regressions for stroke alignment. No imported artwork is needed. +use designcraft_color::{Color, swatch::Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Join, Shape, SpreadRef, Stroke, StrokeAlign, StrokeType}; +use designcraft_geom::{Affine, PathData, Point, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +fn render(path: PathData, fill: bool, stroke: Stroke, xf: Affine, threads: u16) -> Rendered { + render_with(path, fill, stroke, xf, threads, 1.0, Some(BLUE)) +} + +fn render_with(path: PathData, fill: bool, stroke: Stroke, xf: Affine, threads: u16, opacity: f32, background: Option<[u8; 4]>) -> Rendered { + let mut d = Document::new(&NewDocument { width: 96.0, height: 96.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Red", Color::rgb8(255, 0, 0))); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, path); + item.fill = if fill { Fill::swatch(designcraft_color::swatch::PAPER) } else { Fill::default() }; + item.stroke = stroke; + item.xf = xf; + item.opacity = opacity; + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = threads; + renderer.render( + &d, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 96, + 96, + Affine::IDENTITY, + &RenderOptions { paper: false, background, ..Default::default() }, + ) +} + +fn stroke(align: StrokeAlign) -> Stroke { + Stroke { swatch: "Red".into(), weight: 6.0, align, ..Default::default() } +} + +fn rectangle() -> PathData { + shapes::rectangle(Rect::new(24.0, 24.0, 72.0, 72.0)) +} + +#[test] +fn outside_stroke_keeps_white_center_and_does_not_cover_fill() { + let img = render(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(48, 48), WHITE, "an outside stroke must not paint the center"); + assert_eq!(img.pixel(26, 48), WHITE, "the inside half of the doubled stroke is excluded"); + assert_eq!(img.pixel(20, 48), RED, "the full stroke weight is outside the path"); + assert_eq!(img.pixel(16, 48), BLUE, "the outside stroke remains bounded"); +} + +#[test] +fn outside_stroke_preserves_backdrop_without_a_fill() { + let img = render(rectangle(), false, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(48, 48), BLUE); + assert_eq!(img.pixel(26, 48), BLUE, "removing the inner stroke must not erase the backdrop"); + assert_eq!(img.pixel(20, 48), RED); +} + +#[test] +fn outside_stroke_preserves_transparency() { + let img = render_with(rectangle(), false, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0, 1.0, None); + assert_eq!(img.pixel(48, 48), [0; 4]); + assert_eq!(img.pixel(26, 48), [0; 4]); + assert_eq!(img.pixel(20, 48), RED); + assert_eq!(img.pixel(16, 48), [0; 4]); +} + +#[test] +fn outside_stroke_applies_item_opacity_once() { + let img = render_with(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0, 0.5, None); + for (x, expected) in [(48, [255, 255, 255]), (26, [255, 255, 255]), (20, [255, 0, 0])] { + let pixel = img.pixel(x, 48); + assert_eq!(&pixel[..3], &expected, "colour at {x}"); + assert!(pixel[3].abs_diff(128) <= 1, "opacity at {x}: {pixel:?}"); + } + assert_eq!(img.pixel(16, 48), [0; 4]); +} + +#[test] +fn outside_stroke_follows_compound_holes_and_nonzero_winding() { + // Opposite winding creates a hole. Its boundary strokes toward the hole. + let mut path = rectangle(); + let mut hole = shapes::rectangle(Rect::new(36.0, 36.0, 60.0, 60.0)); + for subpath in &mut hole.subpaths { + subpath.reverse(); + } + path.subpaths.extend(hole.subpaths); + let img = render(path, true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(30, 48), WHITE, "stroke must stay out of the filled area"); + assert_eq!(img.pixel(38, 48), RED, "the hole is outside the compound shape"); + assert_eq!(img.pixel(48, 48), BLUE, "the hole center remains unpainted"); + assert_eq!(img.pixel(20, 48), RED); + + // Same-winding nested paths do not make a hole under the renderer's nonzero fill rule. + let mut path = rectangle(); + path.subpaths.extend(shapes::rectangle(Rect::new(36.0, 36.0, 60.0, 60.0)).subpaths); + let img = render(path, true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(38, 48), WHITE, "nested filled contours must not acquire interior ink"); + assert_eq!(img.pixel(48, 48), WHITE); + assert_eq!(img.pixel(20, 48), RED); +} + +#[test] +fn stroke_alignment_preserves_widths_and_open_path_behavior() { + for (align, exterior, interior) in [(StrokeAlign::Outside, RED, WHITE), (StrokeAlign::Inside, BLUE, RED), (StrokeAlign::Center, BLUE, WHITE)] { + let img = render(rectangle(), true, stroke(align), Affine::IDENTITY, 0); + assert_eq!(img.pixel(20, 48), exterior, "{align:?} exterior"); + assert_eq!(img.pixel(28, 48), interior, "{align:?} interior"); + assert_eq!(img.pixel(48, 48), WHITE); + } + let line = shapes::line(Point::new(24.0, 48.0), Point::new(72.0, 48.0)); + let centered = render(line.clone(), false, stroke(StrokeAlign::Center), Affine::IDENTITY, 0); + for align in [StrokeAlign::Inside, StrokeAlign::Outside] { + let img = render(line.clone(), false, stroke(align), Affine::IDENTITY, 0); + assert_eq!(img.pixels, centered.pixels, "open paths always use centered strokes"); + } +} + +#[test] +fn outside_stroke_retains_joins_dashes_and_transforms() { + for join in [Join::Miter, Join::Round, Join::Bevel] { + let mut st = stroke(StrokeAlign::Outside); + st.join = join; + let img = render(rectangle(), true, st, Affine::IDENTITY, 0); + assert_eq!(img.pixel(48, 48), WHITE); + assert_eq!(img.pixel(20, 48), RED); + assert_eq!(img.pixel(18, 18), if join == Join::Miter { RED } else { BLUE }, "{join:?} corner"); + } + let mut dashed = stroke(StrokeAlign::Outside); + dashed.kind = StrokeType::Dashed { pattern: vec![12.0, 12.0] }; + let img = render(rectangle(), true, dashed, Affine::IDENTITY, 0); + assert_eq!(img.pixel(30, 20), RED, "dash stays outside"); + assert_eq!(img.pixel(42, 20), BLUE, "dash gap stays empty"); + assert_eq!(img.pixel(30, 26), WHITE, "dash stays out of the fill"); + assert_eq!(img.pixel(48, 48), WHITE); + + let xf = Affine::translate((48.0, 48.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-48.0, -48.0)); + let img = render(rectangle(), true, stroke(StrokeAlign::Outside), xf, 0); + assert_eq!(img.pixel(48, 48), WHITE); + assert_eq!(img.pixel(28, 28), RED); + assert_eq!(img.pixel(34, 34), WHITE); + assert_eq!(img.pixel(22, 22), BLUE); +} + +#[test] +fn outside_stroke_is_identical_with_multiple_render_threads() { + let single = render(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + let multi = render(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 2); + assert_eq!(single.pixels, multi.pixels); +}