From 17a9a6e6c58c7abfd959667065fe69b1a6e2d92a Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 23 Jul 2026 23:26:18 +0100 Subject: [PATCH] Add mosaic mode: seam-free tessellation (pixel + polygon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the topological mosaic pipeline from docs/design/mosaic.md, turning `--hierarchical cutout` into a true gapless tessellation instead of the old re-cluster-and-retrace fake. LabelMap (flatten Segmentation top-down) → boundary-graph extraction (integer-exact: corners, node rule, segment and ring tracing on the pixel-corner lattice) → face assembly (left-region successor rule; winding falls out, so each region is one nonzero-fill path) → fit each segment ONCE (shared by both adjacent faces, reversed exactly → byte-identical shared boundaries) → compose per-region paths Backends: PixelSegmentFitter (exact reference) and PolygonSegmentFitter (symmetric open Douglas-Peucker collapsing staircases to the crack midline). The spline segment fitter is still pending; mosaic + spline currently falls back to polygon. Compositing now owns its fitter (Stacked(CurveFitter) / Mosaic(SegmentFitter)). Tests: single region, vertical split, T-junction, checkerboard pinch, nested rings, border-touching, and a pixel round-trip property test over 40 random maps (rasterize composed faces == input label map). Plus two mosaic goldens. --- crates/vtracer/src/compose/mod.rs | 18 +- crates/vtracer/src/config.rs | 18 +- crates/vtracer/src/lib.rs | 1 + crates/vtracer/src/mosaic/compose.rs | 116 ++++++ crates/vtracer/src/mosaic/face.rs | 122 ++++++ crates/vtracer/src/mosaic/fit.rs | 173 +++++++++ crates/vtracer/src/mosaic/graph.rs | 357 ++++++++++++++++++ crates/vtracer/src/mosaic/mod.rs | 295 +++++++++++++++ crates/vtracer/src/pipeline.rs | 8 +- crates/vtracer/tests/golden.rs | 22 +- .../tests/goldens/checker_mosaic_polygon.svg | 52 +++ .../tests/goldens/disc_mosaic_pixel.svg | 6 + crates/vtracer/tests/pipeline.rs | 7 +- 13 files changed, 1177 insertions(+), 18 deletions(-) create mode 100644 crates/vtracer/src/mosaic/compose.rs create mode 100644 crates/vtracer/src/mosaic/face.rs create mode 100644 crates/vtracer/src/mosaic/fit.rs create mode 100644 crates/vtracer/src/mosaic/graph.rs create mode 100644 crates/vtracer/src/mosaic/mod.rs create mode 100644 crates/vtracer/tests/goldens/checker_mosaic_polygon.svg create mode 100644 crates/vtracer/tests/goldens/disc_mosaic_pixel.svg diff --git a/crates/vtracer/src/compose/mod.rs b/crates/vtracer/src/compose/mod.rs index 4d261c8..3cd8a4d 100644 --- a/crates/vtracer/src/compose/mod.rs +++ b/crates/vtracer/src/compose/mod.rs @@ -7,12 +7,24 @@ use crate::fitter::CurveFitter; use crate::ir::{Segmentation, Shape, VectorDoc}; +use crate::mosaic::{compose_mosaic, MosaicOptions, SegmentFitter}; -/// Which compositing strategy the pipeline uses. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Which compositing strategy the pipeline uses. Each variant owns its fitter. pub enum Compositing { /// Independent per-region closed outlines, stacked bottom-to-top. - Stacked, + Stacked(Box), + /// Seam-free gapless tessellation via a shared boundary graph. + Mosaic(Box, MosaicOptions), +} + +impl Compositing { + /// Run the selected compositor over a segmentation. + pub fn compose(&self, seg: &Segmentation) -> VectorDoc { + match self { + Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()), + Compositing::Mosaic(fitter, opts) => compose_mosaic(seg, fitter.as_ref(), opts), + } + } } /// Trace every layer's closed outline and stack the shapes in paint order. diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 9b03eaa..1896590 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -9,6 +9,7 @@ use crate::compose::Compositing; use crate::error::Error; use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter}; use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend}; +use crate::mosaic::{MosaicOptions, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter}; use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass}; use crate::pipeline::Pipeline; use crate::svg::SvgWriter; @@ -160,6 +161,16 @@ impl Config { } } + fn segment_fitter(&self) -> Box { + match self.mode { + FitMode::Pixel => Box::new(PixelSegmentFitter), + FitMode::Polygon => Box::new(PolygonSegmentFitter::default()), + // The spline segment fitter is not implemented yet; mosaic falls + // back to the polygon (crack-midline) fitter for now. + FitMode::Spline => Box::new(PolygonSegmentFitter::default()), + } + } + fn optimizers(&self) -> Vec> { if self.optimize == 0 { return Vec::new(); @@ -194,18 +205,15 @@ impl Config { /// Assemble a concrete pipeline from this configuration. pub fn build(&self) -> Result { let compositing = match self.hierarchical { - Hierarchical::Stacked => Compositing::Stacked, + Hierarchical::Stacked => Compositing::Stacked(self.fitter()), Hierarchical::Cutout => { - return Err(Error::Unsupported( - "the mosaic (cutout) compositor is not yet implemented".into(), - )) + Compositing::Mosaic(self.segment_fitter(), MosaicOptions::default()) } }; Ok(Pipeline { frontend: self.frontend(), color_fitters: self.color_fitters(), - fitter: self.fitter(), compositing, optimizers: self.optimizers(), writer: self.writer(), diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs index 7fea084..44234e5 100644 --- a/crates/vtracer/src/lib.rs +++ b/crates/vtracer/src/lib.rs @@ -36,6 +36,7 @@ pub mod error; pub mod fitter; pub mod frontend; pub mod ir; +pub mod mosaic; pub mod optimize; pub mod pipeline; pub mod svg; diff --git a/crates/vtracer/src/mosaic/compose.rs b/crates/vtracer/src/mosaic/compose.rs new file mode 100644 index 0000000..9b025d2 --- /dev/null +++ b/crates/vtracer/src/mosaic/compose.rs @@ -0,0 +1,116 @@ +//! Stage 4: compose per-region SVG paths from shared fitted segments. +//! +//! Each region becomes one shape whose `d` concatenates its contours as +//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each +//! oriented segment is emitted skipping its first point (identical to the +//! previous segment's last point), so shared boundaries are byte-identical on +//! both sides. + +use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc}; +use visioncortex::PointF64; + +use super::face::{assemble, Contour, Face}; +use super::fit::{FittedGeom, FittedSegment, SegmentFitter}; +use super::graph::BoundaryGraph; +use super::{LabelMap, MosaicOptions, Segmentation}; + +/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose. +pub fn compose_mosaic( + seg: &Segmentation, + fitter: &dyn SegmentFitter, + _options: &MosaicOptions, +) -> VectorDoc { + let map = LabelMap::from_segmentation(seg); + let graph = BoundaryGraph::extract(&map); + let faces = assemble(&graph, &map); + + // Fit every segment exactly once; both adjacent faces share the result. + let fitted: Vec = graph + .segments + .iter() + .map(|s| { + if s.is_ring() { + fitter.fit_ring(s) + } else { + fitter.fit_open(s) + } + }) + .collect(); + + let mut doc = VectorDoc::new(seg.width, seg.height); + for face in &faces { + let path = build_path(face, &fitted, &graph); + if !path.is_empty() { + doc.shapes.push(Shape { + paint: map.paints[face.region as usize], + path, + }); + } + } + doc +} + +fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath { + let mut mp = MultiPath::new(); + for contour in &face.contours { + let mut sub = SubPath::new(); + emit_contour(contour, fitted, &mut sub); + if !sub.is_empty() { + sub.commands.push(PathCmd::Close); + mp.subpaths.push(sub); + } + } + mp +} + +fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) { + for (i, sref) in contour.0.iter().enumerate() { + let geom = &fitted[sref.seg as usize].geom; + emit_segment(geom, sref.forward, i == 0, sub); + } +} + +/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`; +/// otherwise the leading point (shared with the previous segment) is skipped. +fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) { + match geom { + FittedGeom::Polyline(pts) => { + if pts.len() < 2 { + return; + } + let ordered: Vec = if forward { + pts.clone() + } else { + pts.iter().rev().copied().collect() + }; + if first { + sub.commands.push(PathCmd::MoveTo(ordered[0])); + } + for p in &ordered[1..] { + sub.commands.push(PathCmd::LineTo(*p)); + } + } + FittedGeom::Beziers(curves) => { + if curves.is_empty() { + return; + } + // Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and + // the whole chain reverses in order too. + let ordered: Vec<[PointF64; 4]> = if forward { + curves.clone() + } else { + curves + .iter() + .rev() + .map(|c| [c[3], c[2], c[1], c[0]]) + .collect() + }; + if first { + sub.commands.push(PathCmd::MoveTo(ordered[0][0])); + } + for c in &ordered { + sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3])); + } + } + } +} diff --git a/crates/vtracer/src/mosaic/face.rs b/crates/vtracer/src/mosaic/face.rs new file mode 100644 index 0000000..dfd7d52 --- /dev/null +++ b/crates/vtracer/src/mosaic/face.rs @@ -0,0 +1,122 @@ +//! Stage 2: face assembly. +//! +//! Lift the "region kept on the left" successor rule from unit edges to whole +//! segments. Following it around each region yields its contours; because the +//! interior is always on the left, outer contours and hole contours come out +//! with opposite winding automatically — no containment/nesting computation is +//! needed, and the region can be filled with a single `nonzero` path. + +use super::graph::{ + edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef, +}; +use super::{LabelMap, RegionId, OUTSIDE}; + +/// 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). +#[derive(Clone, Debug)] +pub struct Face { + pub region: RegionId, + pub contours: Vec, +} + +/// Left region of a directed segment view. +fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId { + let seg = &graph.segments[r.seg as usize]; + if r.forward { + seg.left + } else { + seg.right + } +} + +/// Pick the next unit direction leaving `corner`, keeping region `r` on the +/// left: sharpest right turn first (this pinches checkerboard nodes and keeps +/// contours simple). +fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 { + for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] { + if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r { + return d; + } + } + unreachable!("no successor edge keeps the region on the left"); +} + +pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec { + let mut by_region: Vec> = vec![Vec::new(); map.paints.len()]; + // usage[seg][0] = forward view used, [1] = backward view used. + let mut used = vec![[false; 2]; graph.segments.len()]; + + for seg_id in 0..graph.segments.len() { + if graph.segments[seg_id].is_ring() { + continue; + } + for &forward in &[true, false] { + let start = SegRef { + seg: seg_id as u32, + forward, + }; + let region = left_region(graph, start); + if region == OUTSIDE || used[seg_id][forward as usize] { + continue; + } + + let mut contour = Vec::new(); + let mut cur = start; + loop { + used[cur.seg as usize][cur.forward as usize] = true; + contour.push(cur); + + let seg = &graph.segments[cur.seg as usize]; + let (node_id, d_in) = if cur.forward { + (seg.end.unwrap(), seg.last_dir) + } else { + (seg.start.unwrap(), reverse(seg.first_dir)) + }; + let corner = graph.nodes[node_id as usize].corner; + let d_next = successor(map, corner.x, corner.y, d_in, region); + cur = graph.nodes[node_id as usize].out[d_next as usize] + .expect("successor direction must have an outgoing segment"); + + if cur == start { + break; + } + } + if (region as usize) < by_region.len() { + by_region[region as usize].push(Contour(contour)); + } + } + } + + // Rings: the left side uses it forward, the right side reversed. + for seg_id in 0..graph.segments.len() { + let seg = &graph.segments[seg_id]; + 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 { + 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, + }])); + } + } + + by_region + .into_iter() + .enumerate() + .filter(|(_, c)| !c.is_empty()) + .map(|(region, contours)| Face { + region: region as RegionId, + contours, + }) + .collect() +} diff --git a/crates/vtracer/src/mosaic/fit.rs b/crates/vtracer/src/mosaic/fit.rs new file mode 100644 index 0000000..e5d3414 --- /dev/null +++ b/crates/vtracer/src/mosaic/fit.rs @@ -0,0 +1,173 @@ +//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes. +//! +//! A segment is fitted a single time and cached; both adjacent faces reference +//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so +//! the shared geometry is bitwise identical and no seam can appear. + +use visioncortex::{PointF64, PointI32}; + +use super::graph::Segment; + +/// Fitted geometry for one boundary segment. +#[derive(Clone, Debug)] +pub enum FittedGeom { + /// Polyline (pixel / polygon backends). + Polyline(Vec), + /// Chain of cubic Béziers; consecutive curves share endpoints (spline backend). + Beziers(Vec<[PointF64; 4]>), +} + +/// A fitted segment, cached and indexed by segment id. +#[derive(Clone, Debug)] +pub struct FittedSegment { + pub geom: FittedGeom, +} + +/// Fits a single boundary segment. `fit_open` pins both endpoints (junction +/// nodes must not move); `fit_ring` fits a closed loop with no pinned point. +pub trait SegmentFitter { + fn fit_open(&self, seg: &Segment) -> FittedSegment; + fn fit_ring(&self, seg: &Segment) -> FittedSegment; +} + +fn to_f64(points: &[PointI32]) -> Vec { + points + .iter() + .map(|p| PointF64 { + x: p.x as f64, + y: p.y as f64, + }) + .collect() +} + +/// Identity fitter: lattice points as f64. Produces an exact tessellation and +/// is the reference backend for tests. +#[derive(Debug, Clone, Default)] +pub struct PixelSegmentFitter; + +impl SegmentFitter for PixelSegmentFitter { + fn fit_open(&self, seg: &Segment) -> FittedSegment { + FittedSegment { + geom: FittedGeom::Polyline(to_f64(&seg.points)), + } + } + fn fit_ring(&self, seg: &Segment) -> FittedSegment { + FittedSegment { + geom: FittedGeom::Polyline(to_f64(&seg.points)), + } + } +} + +/// Symmetric open Douglas–Peucker. Endpoints are always kept, so junction +/// nodes stay pinned. Plain DP (no directional staircase removal) collapses +/// 1-px staircases to the crack midline — centered between the two regions, +/// which is what a mosaic wants. +#[derive(Debug, Clone)] +pub struct PolygonSegmentFitter { + pub tolerance: f64, +} + +impl Default for PolygonSegmentFitter { + fn default() -> Self { + Self { tolerance: 0.5 } + } +} + +impl SegmentFitter for PolygonSegmentFitter { + fn fit_open(&self, seg: &Segment) -> FittedSegment { + let pts = to_f64(&seg.points); + FittedSegment { + geom: FittedGeom::Polyline(dp_open(&pts, self.tolerance)), + } + } + + fn fit_ring(&self, seg: &Segment) -> FittedSegment { + // Closed loop: split at the vertex farthest from the start, DP each + // half, then rejoin. points[0] == points[last]. + let pts = to_f64(&seg.points); + if pts.len() <= 4 { + return FittedSegment { + geom: FittedGeom::Polyline(pts), + }; + } + let open = &pts[..pts.len() - 1]; // drop duplicate closing point + let far = farthest_from(open, 0); + let first: Vec = open[0..=far].to_vec(); + let second: Vec = open[far..] + .iter() + .chain(std::iter::once(&open[0])) + .copied() + .collect(); + let mut a = dp_open(&first, self.tolerance); + let b = dp_open(&second, self.tolerance); + // `a` ends at `far`, `b` starts at `far` and ends back at start. + a.pop(); // drop shared `far` + a.extend(b); // ...b includes far..start (closing point == start) + FittedSegment { + geom: FittedGeom::Polyline(a), + } + } +} + +fn farthest_from(pts: &[PointF64], anchor: usize) -> usize { + let a = pts[anchor]; + let mut best = anchor; + let mut best_d = -1.0; + for (i, p) in pts.iter().enumerate() { + let dx = p.x - a.x; + let dy = p.y - a.y; + let d = dx * dx + dy * dy; + if d > best_d { + best_d = d; + best = i; + } + } + best +} + +/// Douglas–Peucker on an open polyline; first and last points are always kept. +fn dp_open(pts: &[PointF64], tol: f64) -> Vec { + if pts.len() <= 2 { + return pts.to_vec(); + } + let mut keep = vec![false; pts.len()]; + keep[0] = true; + keep[pts.len() - 1] = true; + dp_recurse(pts, 0, pts.len() - 1, tol, &mut keep); + pts.iter() + .zip(keep) + .filter_map(|(p, k)| if k { Some(*p) } else { None }) + .collect() +} + +fn dp_recurse(pts: &[PointF64], lo: usize, hi: usize, tol: f64, keep: &mut [bool]) { + if hi <= lo + 1 { + return; + } + let mut max_d = -1.0; + let mut idx = lo; + for i in (lo + 1)..hi { + let d = perp_distance(pts[i], pts[lo], pts[hi]); + if d > max_d { + max_d = d; + idx = i; + } + } + if max_d > tol { + keep[idx] = true; + dp_recurse(pts, lo, idx, tol, keep); + dp_recurse(pts, idx, hi, tol, keep); + } +} + +/// Perpendicular distance from `p` to the segment `a`–`b`. +fn perp_distance(p: PointF64, a: PointF64, b: PointF64) -> f64 { + let dx = b.x - a.x; + let dy = b.y - a.y; + let len2 = dx * dx + dy * dy; + if len2 == 0.0 { + return ((p.x - a.x).powi(2) + (p.y - a.y).powi(2)).sqrt(); + } + let cross = (p.x - a.x) * dy - (p.y - a.y) * dx; + cross.abs() / len2.sqrt() +} diff --git a/crates/vtracer/src/mosaic/graph.rs b/crates/vtracer/src/mosaic/graph.rs new file mode 100644 index 0000000..a1aa0f4 --- /dev/null +++ b/crates/vtracer/src/mosaic/graph.rs @@ -0,0 +1,357 @@ +//! Stage 1: boundary-graph extraction from a [`LabelMap`]. +//! +//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`. +//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run +//! along the "cracks" between differing labels. + +use visioncortex::PointI32; + +use super::{LabelMap, RegionId, OUTSIDE}; + +pub type NodeId = u32; +pub type SegId = u32; + +// Unit directions, arranged clockwise in y-down screen space so that +// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal. +const N: u8 = 0; +const E: u8 = 1; +const S: u8 = 2; +const W: u8 = 3; +/// (dx, dy) per direction. +const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)]; + +#[inline] +pub(super) fn turn_right(d: u8) -> u8 { + (d + 1) % 4 +} +#[inline] +pub(super) fn straight(d: u8) -> u8 { + d +} +#[inline] +pub(super) fn turn_left(d: u8) -> u8 { + (d + 3) % 4 +} +#[inline] +pub(super) fn reverse(d: u8) -> u8 { + (d + 2) % 4 +} + +/// A directed reference to a segment: either traversed forward or reversed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SegRef { + pub seg: SegId, + pub forward: bool, +} + +/// A junction corner (degree ≥ 3) with the segment leaving it in each unit +/// direction (if any). +#[derive(Clone, Debug)] +pub struct Node { + pub corner: PointI32, + pub out: [Option; 4], +} + +/// A maximal boundary chain between two nodes, or a nodeless ring. +#[derive(Clone, Debug)] +pub struct Segment { + /// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`. + pub points: Vec, + pub start: Option, + pub end: Option, + /// Region on the left when traversing forward (y-down convention). + pub left: RegionId, + pub right: RegionId, + /// Direction of the first edge (leaving `start`); unused for rings. + pub first_dir: u8, + /// Direction of the last edge (arriving at `end`); unused for rings. + pub last_dir: u8, +} + +impl Segment { + pub fn is_ring(&self) -> bool { + self.start.is_none() + } +} + +/// The extracted boundary graph. Faces are assembled separately (see `face`). +pub struct BoundaryGraph { + pub nodes: Vec, + pub segments: Vec, +} + +struct Extractor<'a> { + map: &'a LabelMap, + w: i32, + h: i32, + /// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1). + node_at: Vec, + /// Visited flags for undirected unit edges. + visited_v: Vec, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x + visited_h: Vec, // horizontal edge (x in 0..W, y in 0..=H): y*W + x + nodes: Vec, + segments: Vec, +} + +impl<'a> Extractor<'a> { + fn new(map: &'a LabelMap) -> Self { + let w = map.width as i32; + let h = map.height as i32; + let cw = (map.width + 1) as usize; + let ch = (map.height + 1) as usize; + Extractor { + map, + w, + h, + node_at: vec![u32::MAX; cw * ch], + visited_v: vec![false; (map.width as usize + 1) * map.height as usize], + visited_h: vec![false; map.width as usize * (map.height as usize + 1)], + nodes: Vec::new(), + segments: Vec::new(), + } + } + + #[inline] + fn corner_index(&self, x: i32, y: i32) -> usize { + y as usize * (self.w as usize + 1) + x as usize + } + + /// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`. + fn edge_mask(&self, x: i32, y: i32) -> u8 { + let nw = self.map.label(x - 1, y - 1); + let ne = self.map.label(x, y - 1); + let sw = self.map.label(x - 1, y); + let se = self.map.label(x, y); + let mut m = 0u8; + if nw != ne { + m |= 1 << N; + } + if ne != se { + m |= 1 << E; + } + if sw != se { + m |= 1 << S; + } + if nw != sw { + m |= 1 << W; + } + m + } + + /// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`. + fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) { + let nw = self.map.label(x - 1, y - 1); + let ne = self.map.label(x, y - 1); + let sw = self.map.label(x - 1, y); + let se = self.map.label(x, y); + match d { + N => (nw, ne), + E => (ne, se), + S => (se, sw), + W => (sw, nw), + _ => unreachable!(), + } + } + + /// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`. + /// Returns the canonical (is_vertical, index). + fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) { + match d { + N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize), + S => (true, y as usize * (self.w as usize + 1) + x as usize), + E => (false, y as usize * self.w as usize + x as usize), + W => (false, y as usize * self.w as usize + (x - 1) as usize), + _ => unreachable!(), + } + } + + fn is_visited(&self, x: i32, y: i32, d: u8) -> bool { + let (v, i) = self.edge_slot(x, y, d); + if v { + self.visited_v[i] + } else { + self.visited_h[i] + } + } + + fn mark_visited(&mut self, x: i32, y: i32, d: u8) { + let (v, i) = self.edge_slot(x, y, d); + if v { + self.visited_v[i] = true; + } else { + self.visited_h[i] = true; + } + } + + /// Pass A — classify corners and allocate node ids for degree ≥ 3. + fn classify(&mut self) { + for y in 0..=self.h { + for x in 0..=self.w { + let deg = self.edge_mask(x, y).count_ones(); + if deg >= 3 { + let id = self.nodes.len() as NodeId; + self.nodes.push(Node { + corner: PointI32 { x, y }, + out: [None; 4], + }); + let ci = self.corner_index(x, y); + self.node_at[ci] = id; + } + } + } + } + + fn node_id(&self, x: i32, y: i32) -> Option { + let id = self.node_at[self.corner_index(x, y)]; + if id == u32::MAX { + None + } else { + Some(id) + } + } + + /// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to + /// the start). Returns the polyline, the final heading, and the corner + /// walked to. Marks every traversed edge visited. + fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec, u8, i32, i32) { + let mut points = vec![PointI32 { x: x0, y: y0 }]; + let (mut cx, mut cy, mut d) = (x0, y0, d0); + loop { + self.mark_visited(cx, cy, d); + let (dx, dy) = DVEC[d as usize]; + let (nx, ny) = (cx + dx, cy + dy); + points.push(PointI32 { x: nx, y: ny }); + + let mask = self.edge_mask(nx, ny); + if mask.count_ones() >= 3 { + return (points, d, nx, ny); // reached a node + } + if nx == x0 && ny == y0 { + return (points, d, nx, ny); // closed ring + } + // Degree-2: continue via the unique present edge that is not the + // reverse of how we arrived. + let rev = reverse(d); + let mut nd = d; + for cand in 0..4u8 { + if cand != rev && (mask & (1 << cand)) != 0 { + nd = cand; + break; + } + } + d = nd; + cx = nx; + cy = ny; + } + } + + /// Pass B — trace node-to-node segments. + fn trace_segments(&mut self) { + let node_corners: Vec = self.nodes.iter().map(|n| n.corner).collect(); + for (nid, corner) in node_corners.iter().enumerate() { + let nid = nid as NodeId; + let (x, y) = (corner.x, corner.y); + let mask = self.edge_mask(x, y); + for d in 0..4u8 { + if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) { + continue; + } + let (left, right) = self.side_pixels(x, y, d); + let (points, last_dir, ex, ey) = self.walk(x, y, d); + let end = self + .node_id(ex, ey) + .expect("segment must end at a node"); + + let seg_id = self.segments.len() as SegId; + self.segments.push(Segment { + points, + start: Some(nid), + end: Some(end), + left, + right, + first_dir: d, + last_dir, + }); + self.nodes[nid as usize].out[d as usize] = Some(SegRef { + seg: seg_id, + forward: true, + }); + // Leaving the end node backward along this segment. + let back = reverse(last_dir); + self.nodes[end as usize].out[back as usize] = Some(SegRef { + seg: seg_id, + forward: false, + }); + } + } + } + + /// Pass C — closed rings from any remaining unvisited boundary edges. + fn trace_rings(&mut self) { + for y in 0..=self.h { + for x in 0..=self.w { + let mask = self.edge_mask(x, y); + for d in 0..4u8 { + if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) { + continue; + } + let (left, right) = self.side_pixels(x, y, d); + let (points, _last, _ex, _ey) = self.walk(x, y, d); + self.segments.push(Segment { + points, + start: None, + end: None, + left, + right, + first_dir: d, + last_dir: 0, + }); + } + } + } + } +} + +impl BoundaryGraph { + pub fn extract(map: &LabelMap) -> BoundaryGraph { + let mut ex = Extractor::new(map); + ex.classify(); + ex.trace_segments(); + ex.trace_rings(); + BoundaryGraph { + nodes: ex.nodes, + segments: ex.segments, + } + } +} + +/// 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, + } +} + +// Direction constants and edge-present test needed by face assembly. +pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool { + 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 != ne, + E => ne != se, + S => sw != se, + W => nw != sw, + _ => false, + } +} diff --git a/crates/vtracer/src/mosaic/mod.rs b/crates/vtracer/src/mosaic/mod.rs new file mode 100644 index 0000000..887f633 --- /dev/null +++ b/crates/vtracer/src/mosaic/mod.rs @@ -0,0 +1,295 @@ +//! Mosaic mode: a seam-free, gapless tessellation. +//! +//! Instead of tracing every region independently (which lets neighboring +//! smoothed boundaries diverge and crack), the mosaic pipeline is topological: +//! +//! ```text +//! LabelMap → boundary graph → faces → fit each segment ONCE → compose +//! ``` +//! +//! Every boundary curve exists exactly once; the two adjacent regions +//! reference the same fitted geometry, one traversed reversed. Reversal is +//! exact, so the serialized coordinates match on both sides — no seams. +//! +//! Stages 1–2 (graph + faces) are pure integer arithmetic on the lattice of +//! pixel corners. Only fitting (stage 3) is floating point. + +mod compose; +mod face; +mod fit; +mod graph; + +pub use compose::compose_mosaic; +pub use fit::{ + FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, +}; +pub use graph::{BoundaryGraph, Node, Segment, SegRef}; + +use crate::ir::{Paint, Segmentation}; + +/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels. +pub type RegionId = u32; + +/// Sentinel label for pixels outside any region. +pub const OUTSIDE: RegionId = u32::MAX; + +/// Options controlling mosaic fitting and output. +#[derive(Debug, Clone, Copy, Default)] +pub struct MosaicOptions { + /// Sample fitted segments and fall back to the DP polyline on any that + /// exceed the 0.5px deviation budget, restoring a hard no-crossing guarantee. + pub strict: bool, + /// Stroke each path in its own fill color to hide antialiasing hairlines. + pub seam_stroke: bool, +} + +/// A flat partition of the canvas: one region id per pixel, plus the paint for +/// each region. This is the sole input to the boundary-graph extractor. +#[derive(Debug, Clone)] +pub struct LabelMap { + pub width: u32, + pub height: u32, + /// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels. + pub labels: Vec, + /// Paint per region, indexed by label. + pub paints: Vec, +} + +impl LabelMap { + /// Flatten a layered [`Segmentation`] top-down into a flat partition: each + /// pixel takes the paint of the topmost layer covering it. Layers are + /// bottom-to-top, so painting them in order lets higher layers win. + pub fn from_segmentation(seg: &Segmentation) -> Self { + let w = seg.width as usize; + let h = seg.height as usize; + let mut labels = vec![OUTSIDE; w * h]; + let paints: Vec = seg.layers.iter().map(|l| l.paint).collect(); + + for (i, layer) in seg.layers.iter().enumerate() { + let mask = &layer.mask; + for ly in 0..mask.image.height { + for lx in 0..mask.image.width { + if mask.image.get_pixel(lx, ly) { + let gx = mask.offset.x + lx as i32; + let gy = mask.offset.y + ly as i32; + if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h { + labels[gy as usize * w + gx as usize] = i as RegionId; + } + } + } + } + } + + LabelMap { + width: seg.width, + height: seg.height, + labels, + paints, + } + } + + /// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates. + /// Treating outside as a real label removes all image-border special cases. + #[inline] + pub fn label(&self, x: i32, y: i32) -> RegionId { + if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height { + return OUTSIDE; + } + self.labels[y as usize * self.width as usize + x as usize] + } +} + +#[cfg(test)] +mod tests { + use super::face::{assemble, Face}; + use super::graph::BoundaryGraph; + use super::*; + use crate::ir::Paint; + use visioncortex::{Color, PointF64}; + + /// Build a label map from a row-major grid (for tests). + fn grid(width: u32, height: u32, labels: Vec) -> LabelMap { + let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max(); + let n = max.map(|m| m as usize + 1).unwrap_or(0); + let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect(); + LabelMap { + width, + height, + labels, + paints, + } + } + + /// Reconstruct a face's contour polygons in exact lattice coordinates. + fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec> { + face.contours + .iter() + .map(|contour| { + let mut ring: Vec = Vec::new(); + for (i, sref) in contour.0.iter().enumerate() { + let pts = &graph.segments[sref.seg as usize].points; + let ordered: Vec = if sref.forward { + pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect() + } else { + pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect() + }; + if i == 0 { + ring.extend(ordered); + } else { + ring.extend(ordered[1..].iter().copied()); + } + } + ring + }) + .collect() + } + + fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 { + (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y) + } + + /// Winding number of point `p` w.r.t. a closed ring (last == first). + fn winding(ring: &[PointF64], p: PointF64) -> i32 { + let mut wn = 0; + for w in ring.windows(2) { + let (a, b) = (w[0], w[1]); + if a.y <= p.y { + if b.y > p.y && is_left(a, b, p) > 0.0 { + wn += 1; + } + } else if b.y <= p.y && is_left(a, b, p) < 0.0 { + wn -= 1; + } + } + wn + } + + /// The strongest guarantee: rasterize the composed faces at pixel centers + /// and assert the result is byte-identical to the input label map. + fn assert_pixel_roundtrip(map: &LabelMap) { + let graph = BoundaryGraph::extract(map); + let faces = assemble(&graph, map); + let polys: Vec<(RegionId, Vec>)> = faces + .iter() + .map(|f| (f.region, face_polygons(&graph, f))) + .collect(); + + for y in 0..map.height as i32 { + for x in 0..map.width as i32 { + let center = PointF64 { + x: x as f64 + 0.5, + y: y as f64 + 0.5, + }; + let mut hits: Vec = Vec::new(); + for (region, rings) in &polys { + let wn: i32 = rings.iter().map(|r| winding(r, center)).sum(); + if wn != 0 { + hits.push(*region); + } + } + let expected = map.label(x, y); + if expected == OUTSIDE { + assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}"); + } else { + assert_eq!( + hits, + vec![expected], + "({x},{y}) expected region {expected}, got {hits:?}" + ); + } + } + } + } + + #[test] + fn single_region_is_one_ring() { + let map = grid(3, 2, vec![0; 6]); + let graph = BoundaryGraph::extract(&map); + assert_eq!(graph.nodes.len(), 0, "no junctions in a single region"); + assert_eq!(graph.segments.len(), 1, "one border ring"); + assert!(graph.segments[0].is_ring()); + assert_pixel_roundtrip(&map); + } + + #[test] + fn vertical_split() { + // 4x2, left half 0, right half 1. + let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]); + let graph = BoundaryGraph::extract(&map); + // Two border junctions where the split meets the top and bottom edges. + assert_eq!(graph.nodes.len(), 2); + assert_pixel_roundtrip(&map); + } + + #[test] + fn t_junction() { + // top row one region, bottom row split — a degree-3 interior node. + let map = grid(2, 2, vec![0, 0, 1, 2]); + assert_pixel_roundtrip(&map); + } + + #[test] + fn checkerboard_pinch() { + // A B / B A — the center corner is a degree-4 pinch; each region is two + // lobes touching there. (The four boundary/border corners are degree-3 + // nodes too, per the border rule — so 5 nodes total.) The round-trip is + // the real check that the pinch produces exact, simple contours. + let map = grid(2, 2, vec![0, 1, 1, 0]); + let graph = BoundaryGraph::extract(&map); + let has_degree4 = graph.nodes.iter().any(|n| { + let c = n.corner; + n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1 + }); + assert!(has_degree4, "expected a degree-4 pinch node at the center"); + assert_pixel_roundtrip(&map); + } + + #[test] + fn nested_rings() { + // Concentric squares: 0 outer, 1 middle, 2 center. + let l = |x: i32, y: i32| -> RegionId { + let d = x.min(y).min(5 - x).min(5 - y); + match d { + 0 => 0, + 1 => 1, + _ => 2, + } + }; + let mut labels = Vec::new(); + for y in 0..6 { + for x in 0..6 { + labels.push(l(x, y)); + } + } + assert_pixel_roundtrip(&grid(6, 6, labels)); + } + + #[test] + fn outside_region_border_touching() { + // A region that does not fill the canvas; the rest is OUTSIDE. + let mut labels = vec![OUTSIDE; 16]; + for y in 1..3 { + for x in 1..3 { + labels[y * 4 + x] = 0; + } + } + assert_pixel_roundtrip(&grid(4, 4, labels)); + } + + #[test] + fn random_maps_roundtrip() { + // Deterministic LCG; connectivity not required. + let mut state: u64 = 0x1234_5678_9abc_def0; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (state >> 33) as u32 + }; + for _ in 0..40 { + let w = 2 + next() % 10; + let h = 2 + next() % 10; + let nlabels = 1 + next() % 5; + let labels: Vec = (0..w * h).map(|_| next() % nlabels).collect(); + assert_pixel_roundtrip(&grid(w, h, labels)); + } + } +} diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index e014e31..1d0bac3 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -3,9 +3,8 @@ use visioncortex::ColorImage; use crate::colorfit::ColorFitter; -use crate::compose::{compose_stacked, Compositing}; +use crate::compose::Compositing; use crate::error::Error; -use crate::fitter::CurveFitter; use crate::frontend::Frontend; use crate::ir::VectorDoc; use crate::optimize::OptimizerPass; @@ -16,7 +15,6 @@ use crate::svg::SvgWriter; pub struct Pipeline { pub frontend: Box, pub color_fitters: Vec>, - pub fitter: Box, pub compositing: Compositing, pub optimizers: Vec>, pub writer: SvgWriter, @@ -31,9 +29,7 @@ impl Pipeline { fitter.fit(&mut seg); } - let mut doc = match self.compositing { - Compositing::Stacked => compose_stacked(&seg, self.fitter.as_ref()), - }; + let mut doc = self.compositing.compose(&seg); for pass in &self.optimizers { pass.run(&mut doc); diff --git a/crates/vtracer/tests/golden.rs b/crates/vtracer/tests/golden.rs index a29bca4..00f4713 100644 --- a/crates/vtracer/tests/golden.rs +++ b/crates/vtracer/tests/golden.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; -use vtracer::{Color, ColorImage, ColorMode, Config, FitMode}; +use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical}; // --- synthetic image builders ------------------------------------------------ @@ -177,6 +177,26 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> { ..base() }, ), + // Mosaic (seam-free tessellation): exact pixel and polygon fitters. + ( + "disc_mosaic_pixel", + disc(), + Config { + hierarchical: Hierarchical::Cutout, + mode: FitMode::Pixel, + ..base() + }, + ), + ( + "checker_mosaic_polygon", + checker(), + Config { + hierarchical: Hierarchical::Cutout, + mode: FitMode::Polygon, + optimize: 2, + ..base() + }, + ), ] } diff --git a/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg b/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg new file mode 100644 index 0000000..03e70ed --- /dev/null +++ b/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/vtracer/tests/goldens/disc_mosaic_pixel.svg b/crates/vtracer/tests/goldens/disc_mosaic_pixel.svg new file mode 100644 index 0000000..7d4cf34 --- /dev/null +++ b/crates/vtracer/tests/goldens/disc_mosaic_pixel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/vtracer/tests/pipeline.rs b/crates/vtracer/tests/pipeline.rs index 1aeb568..515cfce 100644 --- a/crates/vtracer/tests/pipeline.rs +++ b/crates/vtracer/tests/pipeline.rs @@ -78,11 +78,12 @@ fn optimize_levels_shrink_or_match() { } #[test] -fn cutout_is_reported_unsupported() { +fn mosaic_cutout_produces_svg() { + let img = two_band_image(32); let config = Config { hierarchical: Hierarchical::Cutout, ..Config::default() }; - let err = config.build().err().expect("cutout should be unsupported"); - assert!(err.to_string().contains("mosaic")); + let svg = config.build().unwrap().to_svg(&img).unwrap(); + assert_valid_svg(&svg); }