mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-08-30 17:05:57 -07:00
Give each disjoint patch of a region its own face in cutout mode
Face assembly bucketed contours by region label, so a region appearing as several disjoint patches contributed all of their contours to one face — and compose emitted them as subpaths of a single <path>. Isolated islands were therefore not separately addressable downstream. Faces are now keyed by (region, island). `islands` flood-fills the label map into connected components in one pass, and `island_of` attributes a contour via the region-side pixel flanking its first directed edge: every contour is walked with its region on the left, so that pixel is interior to the patch the contour bounds, and an outer ring agrees with the holes inside it. graph gains `left_pixel_coord` (the coordinate half of `left_pixel_at`, which now delegates to it) and `dir_from_delta`, needed because a ring has no start node or first_dir. A BTreeMap keeps face order deterministic: region ascending, then island in raster-scan order. Connectivity is 8-way to match the successor rule, which pinches a checkerboard corner into a single contour: lobes meeting only at a diagonal are walked as one contour and must stay in one face. Splitting them could separate a hole contour from the ring enclosing it, and a lone hole ring fills solid under nonzero. Gum tree in cutout goes 492 -> 517 paths and the tank sprite 1022 -> 1023, with renders byte-identical in both cases: the change is structural only. Costs one O(W*H) pass over a pipeline that is already O(W*H).
This commit is contained in:
@@ -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<SegRef>);
|
||||
|
||||
/// 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<Contour>,
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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<Face> {
|
||||
let mut by_region: Vec<Vec<Contour>> = 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<Contour>> = 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<Face> {
|
||||
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<Face> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user