diff --git a/crates/vtracer/src/mosaic/face.rs b/crates/vtracer/src/mosaic/face.rs index dfd7d52..f2adaac 100644 --- a/crates/vtracer/src/mosaic/face.rs +++ b/crates/vtracer/src/mosaic/face.rs @@ -6,22 +6,108 @@ //! with opposite winding automatically — no containment/nesting computation is //! needed, and the region can be filled with a single `nonzero` path. +use std::collections::BTreeMap; + use super::graph::{ - edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef, + dir_from_delta, edge_present, left_pixel_at, left_pixel_coord, reverse, straight, turn_left, + turn_right, BoundaryGraph, SegRef, }; use super::{LabelMap, RegionId, OUTSIDE}; +/// Island id for pixels that belong to no region. +const NO_ISLAND: u32 = u32::MAX; + /// A closed cycle of directed segments bounding (part of) a region. #[derive(Clone, Debug)] pub struct Contour(pub Vec); -/// One region and all of its contours (outer + holes). +/// One connected patch of a region and all of its contours (outer + holes). +/// +/// A region can appear as several disjoint patches; each gets its own face, so +/// isolated islands never share a path. #[derive(Clone, Debug)] pub struct Face { pub region: RegionId, pub contours: Vec, } +/// Connected-component ("island") id per pixel, grouping equal labels with +/// 8-connectivity. [`OUTSIDE`] pixels get [`NO_ISLAND`]. +/// +/// 8-connectivity is what matches [`successor`]: it pinches a checkerboard +/// corner into one contour, so two lobes meeting only at a diagonal are walked +/// as a single contour and must land in a single face. Splitting them +/// (4-connectivity) could put a hole contour in a different face than the ring +/// enclosing it, and a lone hole ring fills solid under `nonzero`. +fn islands(map: &LabelMap) -> Vec { + let (w, h) = (map.width as usize, map.height as usize); + let mut ids = vec![NO_ISLAND; w * h]; + let mut next = 0u32; + let mut stack: Vec<(usize, usize)> = Vec::new(); + + for start in 0..w * h { + if ids[start] != NO_ISLAND || map.labels[start] == OUTSIDE { + continue; + } + let label = map.labels[start]; + let id = next; + next += 1; + ids[start] = id; + stack.push((start % w, start / w)); + + while let Some((x, y)) = stack.pop() { + for dy in -1i32..=1 { + for dx in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { + continue; + } + let n = ny as usize * w + nx as usize; + if ids[n] == NO_ISLAND && map.labels[n] == label { + ids[n] = id; + stack.push((nx as usize, ny as usize)); + } + } + } + } + } + ids +} + +/// Which island a contour bounds, taken from the region-side pixel flanking its +/// first directed edge. Every contour is walked with its region on the left, so +/// that pixel is always interior to the patch the contour belongs to — an outer +/// ring and the holes inside it therefore agree. +fn island_of(graph: &BoundaryGraph, map: &LabelMap, ids: &[u32], r: SegRef) -> u32 { + let seg = &graph.segments[r.seg as usize]; + let (corner, dir) = if seg.is_ring() { + let n = seg.points.len(); + // A ring is used forward by the region on its left, reversed by the one + // on its right; take the first step of the chosen direction. + let (from, to) = if r.forward { + (seg.points[0], seg.points[1]) + } else { + (seg.points[n - 1], seg.points[n - 2]) + }; + (from, dir_from_delta(to.x - from.x, to.y - from.y)) + } else if r.forward { + let node = seg.start.expect("non-ring segment has a start node"); + (graph.nodes[node as usize].corner, seg.first_dir) + } else { + let node = seg.end.expect("non-ring segment has an end node"); + (graph.nodes[node as usize].corner, reverse(seg.last_dir)) + }; + + let (px, py) = left_pixel_coord(corner.x, corner.y, dir); + if px < 0 || py < 0 || px as u32 >= map.width || py as u32 >= map.height { + return NO_ISLAND; + } + ids[py as usize * map.width as usize + px as usize] +} + /// Left region of a directed segment view. fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId { let seg = &graph.segments[r.seg as usize]; @@ -45,7 +131,12 @@ fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 { } pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec { - let mut by_region: Vec> = vec![Vec::new(); map.paints.len()]; + let ids = islands(map); + // Keyed by (region, island) rather than by region alone, so disjoint patches + // of one region become separate faces — and separate paths downstream. The + // BTreeMap keeps face order deterministic: region ascending, then island in + // raster-scan order. + let mut by_island: BTreeMap<(RegionId, u32), Vec> = BTreeMap::new(); // usage[seg][0] = forward view used, [1] = backward view used. let mut used = vec![[false; 2]; graph.segments.len()]; @@ -84,8 +175,12 @@ pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec { break; } } - if (region as usize) < by_region.len() { - by_region[region as usize].push(Contour(contour)); + if (region as usize) < map.paints.len() { + let island = island_of(graph, map, &ids, start); + by_island + .entry((region, island)) + .or_default() + .push(Contour(contour)); } } } @@ -96,27 +191,24 @@ pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec { if !seg.is_ring() { continue; } - if seg.left != OUTSIDE && (seg.left as usize) < by_region.len() { - by_region[seg.left as usize].push(Contour(vec![SegRef { + for (region, forward) in [(seg.left, true), (seg.right, false)] { + if region == OUTSIDE || (region as usize) >= map.paints.len() { + continue; + } + let r = SegRef { seg: seg_id as u32, - forward: true, - }])); - } - if seg.right != OUTSIDE && (seg.right as usize) < by_region.len() { - by_region[seg.right as usize].push(Contour(vec![SegRef { - seg: seg_id as u32, - forward: false, - }])); + forward, + }; + let island = island_of(graph, map, &ids, r); + by_island + .entry((region, island)) + .or_default() + .push(Contour(vec![r])); } } - by_region + by_island .into_iter() - .enumerate() - .filter(|(_, c)| !c.is_empty()) - .map(|(region, contours)| Face { - region: region as RegionId, - contours, - }) + .map(|((region, _island), contours)| Face { region, contours }) .collect() } diff --git a/crates/vtracer/src/mosaic/graph.rs b/crates/vtracer/src/mosaic/graph.rs index a1aa0f4..10fc07a 100644 --- a/crates/vtracer/src/mosaic/graph.rs +++ b/crates/vtracer/src/mosaic/graph.rs @@ -325,20 +325,33 @@ impl BoundaryGraph { } } +/// Pixel flanking the left of the directed edge leaving `(x,y)` in `d`. May be +/// out of bounds, in which case it is [`OUTSIDE`] as far as the map is concerned. +pub(super) fn left_pixel_coord(x: i32, y: i32, d: u8) -> (i32, i32) { + match d { + N => (x - 1, y - 1), + E => (x, y - 1), + S => (x, y), + W => (x - 1, y), + _ => (x, y), + } +} + +/// Unit direction of a single lattice step. +pub(super) fn dir_from_delta(dx: i32, dy: i32) -> u8 { + DVEC.iter() + .position(|&v| v == (dx, dy)) + .expect("consecutive lattice points differ by one unit step") as u8 +} + /// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the /// face-assembly successor rule against a [`LabelMap`]. pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId { - let nw = map.label(x - 1, y - 1); - let ne = map.label(x, y - 1); - let sw = map.label(x - 1, y); - let se = map.label(x, y); - match d { - N => nw, - E => ne, - S => se, - W => sw, - _ => OUTSIDE, + if !matches!(d, N | E | S | W) { + return OUTSIDE; } + let (px, py) = left_pixel_coord(x, y, d); + map.label(px, py) } // Direction constants and edge-present test needed by face assembly. diff --git a/crates/vtracer/src/mosaic/mod.rs b/crates/vtracer/src/mosaic/mod.rs index 7e4519b..a22dea8 100644 --- a/crates/vtracer/src/mosaic/mod.rs +++ b/crates/vtracer/src/mosaic/mod.rs @@ -234,6 +234,49 @@ mod tests { assert_pixel_roundtrip(&map); } + #[test] + fn disjoint_patches_of_one_region_get_separate_faces() { + // Region 0 appears as two islands, separated by a column of region 1. + // Each island must get its own face, so they cannot share a path. + #[rustfmt::skip] + let map = grid(3, 2, vec![ + 0, 1, 0, + 0, 1, 0, + ]); + let graph = BoundaryGraph::extract(&map); + let faces = assemble(&graph, &map); + + assert_eq!( + faces.iter().filter(|f| f.region == 0).count(), + 2, + "each island of region 0 gets its own face" + ); + assert_eq!(faces.len(), 3, "two islands of region 0, plus region 1"); + assert_pixel_roundtrip(&map); + } + + #[test] + fn diagonal_lobes_share_one_face() { + // A B / B A — region 0's lobes meet only at the center corner, which the + // successor rule pinches into a single contour. They must stay in one + // face: splitting them could separate a hole contour from the ring that + // encloses it, and a lone hole ring fills solid under `nonzero`. + #[rustfmt::skip] + let map = grid(2, 2, vec![ + 0, 1, + 1, 0, + ]); + let graph = BoundaryGraph::extract(&map); + let faces = assemble(&graph, &map); + + assert_eq!( + faces.iter().filter(|f| f.region == 0).count(), + 1, + "diagonally touching lobes stay in one face" + ); + assert_pixel_roundtrip(&map); + } + #[test] fn nested_rings() { // Concentric squares: 0 outer, 1 middle, 2 center.