From 7f9fc47c3d8fc8be18158a9d1911037b2e42aa23 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 23 Oct 2026 22:23:24 -0400 Subject: [PATCH 1/2] fix: honor thickness on semantic line regions --- docs/book/src/format/regions.md | 8 +++++ src/structured.rs | 63 +++++++++++++++++++++++++++++++++ tests/line_thickness.rs | 56 +++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 tests/line_thickness.rs diff --git a/docs/book/src/format/regions.md b/docs/book/src/format/regions.md index 5de6bbd..631e55f 100644 --- a/docs/book/src/format/regions.md +++ b/docs/book/src/format/regions.md @@ -43,6 +43,14 @@ crack: { line: [[0, 0], [2, 3], [4, 2]] } Optional: `thickness` (default: 1) +A line uses a square brush of exactly `thickness` pixels along its Bresenham +centreline, including end caps and polyline joins. Odd widths are centred; +even widths place the extra pixel toward negative x/y. For example, a horizontal +line from `[2, 4]` to `[5, 4]` with thickness 3 covers x=1..6 and y=3..5. +Thickness 0 produces no pixels. Thickness 1 preserves the original centreline. +The same rule applies to lines nested inside compound regions. Rendering clips +the result at the canvas boundary after region modifiers and z-ordering. + ### Rectangle Filled rectangle. diff --git a/src/structured.rs b/src/structured.rs index ec0ab48..379c916 100644 --- a/src/structured.rs +++ b/src/structured.rs @@ -83,6 +83,29 @@ pub fn rasterize_region( } } } + // Stamp a square brush along the Bresenham centreline, including joins + // and end caps. Even widths put their extra pixel on the negative side. + // Keep width one byte-for-byte compatible with the original rasterizer. + let thickness = region.thickness.unwrap_or(1); + if thickness == 0 { + pixels.clear(); + } else if thickness > 1 { + let start = -(i64::from(thickness) / 2); + let end = start + i64::from(thickness); + let mut thickened = HashSet::new(); + for (x, y) in pixels { + for dx in start..end { + for dy in start..end { + if let (Ok(x), Ok(y)) = + (i32::try_from(i64::from(x) + dx), i32::try_from(i64::from(y) + dy)) + { + thickened.insert((x, y)); + } + } + } + } + pixels = thickened; + } } else if let Some([x, y, w, h]) = region.rect { pixels = rasterize_rect(x as i32, y as i32, w as i32, h as i32); } else if let Some([x, y, w, h]) = region.stroke { @@ -511,6 +534,46 @@ mod tests { assert!(warnings.is_empty()); } + #[test] + fn test_line_thickness_odd_even_zero_and_legacy() { + let mut warnings = Vec::new(); + let regions = HashMap::new(); + let line = RegionDef { line: Some(vec![[2, 4], [5, 4]]), ..Default::default() }; + let draw = |region: &RegionDef, warnings: &mut Vec| { + rasterize_region(region, ®ions, 8, 8, warnings) + }; + let legacy = rasterize_line((2, 4), (5, 4)); + assert_eq!(draw(&line, &mut warnings), legacy); + assert_eq!(draw(&RegionDef { thickness: Some(1), ..line.clone() }, &mut warnings), legacy); + let odd: HashSet<_> = (1..=6).flat_map(|x| (3..=5).map(move |y| (x, y))).collect(); + assert_eq!(draw(&RegionDef { thickness: Some(3), ..line.clone() }, &mut warnings), odd); + let even: HashSet<_> = (1..=5).flat_map(|x| (3..=4).map(move |y| (x, y))).collect(); + assert_eq!(draw(&RegionDef { thickness: Some(2), ..line.clone() }, &mut warnings), even); + assert!(draw(&RegionDef { thickness: Some(0), ..line }, &mut warnings).is_empty()); + assert!(warnings.is_empty()); + } + + #[test] + fn test_line_thickness_compounds_and_diagonal() { + let line = RegionDef { + line: Some(vec![[2, 2], [4, 4]]), + thickness: Some(3), + ..Default::default() + }; + let regions = HashMap::new(); + let mut warnings = Vec::new(); + let direct = rasterize_region(&line, ®ions, 8, 8, &mut warnings); + assert_eq!(direct.len(), 19); + assert!(direct.contains(&(1, 1)) && direct.contains(&(5, 5))); + assert!(!direct.contains(&(5, 1))); + let nested = RegionDef { union: Some(vec![line.clone()]), ..Default::default() }; + assert_eq!(rasterize_region(&nested, ®ions, 8, 8, &mut warnings), direct); + let shifted = RegionDef { translate: Some([-2, -2]), ..nested }; + let expected: HashSet<_> = direct.iter().map(|&(x, y)| (x - 2, y - 2)).collect(); + assert_eq!(rasterize_region(&shifted, ®ions, 8, 8, &mut warnings), expected); + assert!(warnings.is_empty()); + } + #[test] fn test_rasterize_region_stroke() { let region = diff --git a/tests/line_thickness.rs b/tests/line_thickness.rs new file mode 100644 index 0000000..594f141 --- /dev/null +++ b/tests/line_thickness.rs @@ -0,0 +1,56 @@ +//! The CLI renderer and mask reader must agree on semantic line coverage. +use image::Rgba; +use serde_json::json; +use std::process::Command; + +#[test] +fn thick_line_png_mask_union_and_z_order() { + let dir = tempfile::tempdir().expect("temporary fixture directory"); + for (name, shape, skin_z, count, sample) in [ + ("thin", json!({"line": [[2, 4], [5, 4]], "thickness": 1}), 0, 4, "skin"), + ("thick", json!({"line": [[2, 4], [5, 4]], "thickness": 3}), 0, 18, "hair"), + ("nested", json!({"union": [{"line": [[2, 4], [5, 4]], "thickness": 3}]}), 0, 18, "hair"), + ("occluded", json!({"line": [[2, 4], [5, 4]], "thickness": 3}), 20, 0, "skin"), + ("clipped", json!({"line": [[0, 0], [1, 0]], "thickness": 3}), 0, 6, "skin"), + ] { + let source = dir.path().join(format!("{name}.pxl")); + let png = dir.path().join(format!("{name}.png")); + let mut hair = shape; + hair["z"] = json!(10); + let palette = json!({"type":"palette", "name":"p", + "colors":{"skin":"#bb937a", "hair":"#302e31"}}); + let sprite = json!({"type":"sprite", "name":name, "size":[8,8], "palette":"p", + "regions":{"skin":{"rect":[0,0,8,8], "z":skin_z}, "hair":hair}}); + std::fs::write(&source, format!("{palette}\n{sprite}\n")).expect("write fixture"); + let validate = Command::new(env!("CARGO_BIN_EXE_pxl")) + .args(["validate", "--strict"]) + .arg(&source) + .output() + .expect("run validator"); + assert!(validate.status.success(), "{name}: {:?}", validate); + let render = Command::new(env!("CARGO_BIN_EXE_pxl")) + .arg("render") + .arg(&source) + .args(["--strict", "--scale", "1", "-o"]) + .arg(&png) + .output() + .expect("run renderer"); + assert!(render.status.success(), "{name}: {:?}", render); + let image = image::open(png).expect("load rendered PNG").into_rgba8(); + assert_eq!(image.dimensions(), (8, 8)); + assert_eq!( + image.pixels().filter(|&&p| p == Rgba([48, 46, 49, 255])).count(), + count, + "{name}" + ); + let mask = Command::new(env!("CARGO_BIN_EXE_pxl")) + .arg("mask") + .arg(&source) + .args(["--sprite", name, "--sample", "3,3", "--json"]) + .output() + .expect("run mask reader"); + assert!(mask.status.success(), "{name}: {:?}", mask); + let value: serde_json::Value = serde_json::from_slice(&mask.stdout).expect("mask JSON"); + assert_eq!(value["token"], format!("{{{sample}}}"), "{name}"); + } +} From 48023226cacf81ce5ba5e493cc98d94fea5d7e23 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 23 Oct 2026 22:26:56 -0400 Subject: [PATCH 2/2] test: preserve strict warnings for clipped thick lines --- tests/line_thickness.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/line_thickness.rs b/tests/line_thickness.rs index 594f141..628041f 100644 --- a/tests/line_thickness.rs +++ b/tests/line_thickness.rs @@ -27,14 +27,18 @@ fn thick_line_png_mask_union_and_z_order() { .arg(&source) .output() .expect("run validator"); - assert!(validate.status.success(), "{name}: {:?}", validate); - let render = Command::new(env!("CARGO_BIN_EXE_pxl")) - .arg("render") - .arg(&source) - .args(["--strict", "--scale", "1", "-o"]) - .arg(&png) - .output() - .expect("run renderer"); + if name == "clipped" { + assert!(!validate.status.success()); + assert!(String::from_utf8_lossy(&validate.stderr).contains("outside the 8x8 canvas")); + } else { + assert!(validate.status.success(), "{name}: {:?}", validate); + } + let mut command = Command::new(env!("CARGO_BIN_EXE_pxl")); + command.arg("render").arg(&source); + if name != "clipped" { + command.arg("--strict"); + } + let render = command.args(["--scale", "1", "-o"]).arg(&png).output().expect("run renderer"); assert!(render.status.success(), "{name}: {:?}", render); let image = image::open(png).expect("load rendered PNG").into_rgba8(); assert_eq!(image.dimensions(), (8, 8));