From 3300f97e3780d92a96f58b053480ca8aca605f18 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Fri, 24 Jul 2026 11:05:25 +0100 Subject: [PATCH] Add mosaic spline fitter; fix stacked holes & relative writer; add test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature — mosaic spline segment fitter (crates/vtracer/src/mosaic/fit.rs): open-path cubic fitting for boundary segments, reusing the now-public visioncortex primitives (PathSimplify::limit_penalties for symmetric, gap-free staircase removal; open-path SubdivideSmooth::{find_corners, subdivide_keep_corners,find_splice_points}; fit_points_with_bezier per splice slice). Matches stacked spline curve quality; endpoints pinned to lattice nodes so shared boundaries stay seam-free. Fix — stacked mode punched holes in cluster masks (to_image_with_hole .. true); stacked must trace solid layers and occlude by paint-order overdraw (false). Holes left the layer below exposed as hairline seams. Fix — the relative SVG writer measured a subpath's opening `m` from the last vertex instead of the subpath start (SVG resets the current point to the start after Z), misplacing holes / extra subpaths at optimize=1/2. Tests — new tests/equivalence.rs: stacked-vs-mosaic interior agreement (all fitters) and a seam guard (a full-coverage image must render fully opaque). svg round-trip test (absolute vs relative encode identical geometry). mosaic spline endpoint-pinning test. Regenerated goldens; added disc_mosaic_spline. resvg added as a dev-dependency (test-only; not compiled for wasm). Drop unused MosaicOptions placeholder The strict/seam-stroke mitigations aren't needed — the mosaic geometry is already gapless and seam-free. Remove the no-op MosaicOptions struct and thread it out of Compositing::Mosaic and compose_mosaic. --- crates/vtracer/Cargo.toml | 5 + crates/vtracer/src/compose/mod.rs | 14 +- crates/vtracer/src/config.rs | 18 +- crates/vtracer/src/frontend/color_cluster.rs | 7 +- crates/vtracer/src/mosaic/compose.rs | 8 +- crates/vtracer/src/mosaic/fit.rs | 283 ++++++++++++------ crates/vtracer/src/mosaic/mod.rs | 50 +++- crates/vtracer/src/svg/mod.rs | 153 ++++++++++ crates/vtracer/tests/equivalence.rs | 215 +++++++++++++ crates/vtracer/tests/golden.rs | 9 + .../vtracer/tests/goldens/bands_palette.svg | 2 +- crates/vtracer/tests/goldens/bands_pixel.svg | 2 +- .../vtracer/tests/goldens/bands_polygon.svg | 2 +- crates/vtracer/tests/goldens/bands_spline.svg | 2 +- .../vtracer/tests/goldens/checker_spline.svg | 2 +- .../tests/goldens/disc_mosaic_spline.svg | 6 + crates/vtracer/tests/goldens/disc_opt0.svg | 2 +- crates/vtracer/tests/goldens/disc_opt2.svg | 2 +- crates/vtracer/tests/goldens/disc_spline.svg | 2 +- crates/vtracer/tests/goldens/ring_spline.svg | 2 +- .../vtracer/tests/goldens/swatches_color.svg | 2 +- .../vtracer/tests/goldens/swatches_quant4.svg | 25 +- 22 files changed, 663 insertions(+), 150 deletions(-) create mode 100644 crates/vtracer/tests/equivalence.rs create mode 100644 crates/vtracer/tests/goldens/disc_mosaic_spline.svg diff --git a/crates/vtracer/Cargo.toml b/crates/vtracer/Cargo.toml index ba7178c..69e1ace 100644 --- a/crates/vtracer/Cargo.toml +++ b/crates/vtracer/Cargo.toml @@ -16,3 +16,8 @@ path = "src/lib.rs" [dependencies] visioncortex.workspace = true + +[dev-dependencies] +# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not +# compiled for wasm targets, so the library stays wasm-safe. +resvg = "0.45" diff --git a/crates/vtracer/src/compose/mod.rs b/crates/vtracer/src/compose/mod.rs index 3cd8a4d..0a86861 100644 --- a/crates/vtracer/src/compose/mod.rs +++ b/crates/vtracer/src/compose/mod.rs @@ -1,20 +1,20 @@ //! Compositing: turn a [`Segmentation`] into a [`VectorDoc`]. //! -//! Only **stacked** composition is implemented: each layer is traced -//! independently into closed outlines and stacked in paint order (painter's -//! algorithm). The **mosaic** compositor — gapless tessellation with shared -//! boundary geometry — is a separate milestone and not built yet. +//! * **Stacked** — each layer is traced independently into closed outlines and +//! stacked in paint order (painter's algorithm). +//! * **Mosaic** — a seam-free gapless tessellation with shared boundary +//! geometry (see [`crate::mosaic`]). use crate::fitter::CurveFitter; use crate::ir::{Segmentation, Shape, VectorDoc}; -use crate::mosaic::{compose_mosaic, MosaicOptions, SegmentFitter}; +use crate::mosaic::{compose_mosaic, SegmentFitter}; /// Which compositing strategy the pipeline uses. Each variant owns its fitter. pub enum Compositing { /// Independent per-region closed outlines, stacked bottom-to-top. Stacked(Box), /// Seam-free gapless tessellation via a shared boundary graph. - Mosaic(Box, MosaicOptions), + Mosaic(Box), } impl Compositing { @@ -22,7 +22,7 @@ impl Compositing { 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), + Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()), } } } diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 1896590..bfa2b66 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -9,7 +9,9 @@ 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::mosaic::{ + PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter, +}; use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass}; use crate::pipeline::Pipeline; use crate::svg::SvgWriter; @@ -165,9 +167,13 @@ impl Config { 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()), + FitMode::Spline => Box::new(SplineSegmentFitter { + corner_threshold: deg2rad(self.corner_threshold), + length_threshold: self.length_threshold, + max_iterations: self.max_iterations, + splice_threshold: deg2rad(self.splice_threshold), + ..SplineSegmentFitter::default() + }), } } @@ -206,9 +212,7 @@ impl Config { pub fn build(&self) -> Result { let compositing = match self.hierarchical { Hierarchical::Stacked => Compositing::Stacked(self.fitter()), - Hierarchical::Cutout => { - Compositing::Mosaic(self.segment_fitter(), MosaicOptions::default()) - } + Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()), }; Ok(Pipeline { diff --git a/crates/vtracer/src/frontend/color_cluster.rs b/crates/vtracer/src/frontend/color_cluster.rs index 2382e07..858ffe3 100644 --- a/crates/vtracer/src/frontend/color_cluster.rs +++ b/crates/vtracer/src/frontend/color_cluster.rs @@ -73,7 +73,12 @@ impl Frontend for ColorClusterFrontend { // paint order for the layer stack. for &cluster_index in view.clusters_output.iter().rev() { let cluster = view.get_cluster(cluster_index); - let image = cluster.to_image_with_hole(view.width, true); + // Solid cluster masks (no holes punched): stacked mode relies on + // paint-order overdraw for occlusion, matching 0.6.x. Punching + // holes here would leave the layer below exposed as hairline seams. + // The mosaic flatten is unaffected — a higher layer still wins per + // pixel — so a solid parent gives the same partition. + let image = cluster.to_image_with_hole(view.width, false); let mask = RegionMask::new( image, PointI32 { diff --git a/crates/vtracer/src/mosaic/compose.rs b/crates/vtracer/src/mosaic/compose.rs index 9b025d2..bbe56d3 100644 --- a/crates/vtracer/src/mosaic/compose.rs +++ b/crates/vtracer/src/mosaic/compose.rs @@ -12,14 +12,10 @@ use visioncortex::PointF64; use super::face::{assemble, Contour, Face}; use super::fit::{FittedGeom, FittedSegment, SegmentFitter}; use super::graph::BoundaryGraph; -use super::{LabelMap, MosaicOptions, Segmentation}; +use super::{LabelMap, Segmentation}; /// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose. -pub fn compose_mosaic( - seg: &Segmentation, - fitter: &dyn SegmentFitter, - _options: &MosaicOptions, -) -> VectorDoc { +pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorDoc { let map = LabelMap::from_segmentation(seg); let graph = BoundaryGraph::extract(&map); let faces = assemble(&graph, &map); diff --git a/crates/vtracer/src/mosaic/fit.rs b/crates/vtracer/src/mosaic/fit.rs index e5d3414..56fe335 100644 --- a/crates/vtracer/src/mosaic/fit.rs +++ b/crates/vtracer/src/mosaic/fit.rs @@ -4,10 +4,13 @@ //! 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 visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideSmooth}; use super::graph::Segment; +/// Outset ratio for the 4-point subdivision scheme (matches visioncortex). +const OUTSET_RATIO: f64 = 8.0; + /// Fitted geometry for one boundary segment. #[derive(Clone, Debug)] pub enum FittedGeom { @@ -58,116 +61,204 @@ impl SegmentFitter for PixelSegmentFitter { } } -/// 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, -} +/// Straight-segment fitter. Uses visioncortex's symmetric `limit_penalties` +/// simplification, which collapses 1px staircases toward the crack midline +/// (centered, no directional outset) so the boundary stays gapless. Endpoints +/// are preserved, pinning junction nodes. +#[derive(Debug, Clone, Default)] +pub struct PolygonSegmentFitter; -impl Default for PolygonSegmentFitter { - fn default() -> Self { - Self { tolerance: 0.5 } +impl PolygonSegmentFitter { + fn fit(&self, seg: &Segment) -> FittedSegment { + let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone())); + FittedSegment { + geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()), + } } } impl SegmentFitter for PolygonSegmentFitter { fn fit_open(&self, seg: &Segment) -> FittedSegment { - let pts = to_f64(&seg.points); + self.fit(seg) + } + fn fit_ring(&self, seg: &Segment) -> FittedSegment { + self.fit(seg) + } +} + +/// Smooth (cubic-Bézier) open-path fitter — the mosaic analogue of the stacked +/// [`crate::fitter::SplineFitter`], but for open segments with pinned +/// endpoints. +/// +/// Staircase removal reuses visioncortex's symmetric `limit_penalties` +/// simplification (the same de-noising stacked mode applies), which collapses +/// staircases toward the crack midline. Unlike `remove_staircase`, it has no +/// directional outset, so the boundary stays centered (≤√2/2 px from its +/// crack) and cannot cross a non-adjacent segment — the tessellation stays +/// gapless. A distance-based DP can't do this: near the √2/2 threshold it +/// can't separate staircase noise from real curvature. Smoothing and per-slice +/// cubic fitting then reuse the same visioncortex machinery stacked mode uses +/// (open-path variants of the smoothing primitives + `fit_points_with_bezier`), +/// so the curve character matches stacked. +#[derive(Debug, Clone)] +pub struct SplineSegmentFitter { + /// Corner angle threshold, radians. + pub corner_threshold: f64, + /// Subdivide until segments are shorter than this (px). + pub length_threshold: f64, + pub max_iterations: usize, + /// Splice angle threshold, radians. + pub splice_threshold: f64, +} + +impl Default for SplineSegmentFitter { + fn default() -> Self { + Self { + corner_threshold: std::f64::consts::PI / 3.0, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: std::f64::consts::PI / 4.0, + } + } +} + +fn pt(p: PointI32) -> PointF64 { + PointF64 { + x: p.x as f64, + y: p.y as f64, + } +} + +/// A degenerate cubic tracing the straight line `a`→`b`. +fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] { + let c1 = PointF64 { + x: a.x + (b.x - a.x) / 3.0, + y: a.y + (b.y - a.y) / 3.0, + }; + let c2 = PointF64 { + x: a.x + 2.0 * (b.x - a.x) / 3.0, + y: a.y + 2.0 * (b.y - a.y) / 3.0, + }; + [a, c1, c2, b] +} + +/// Error bound for the per-slice cubic fit. Matches the value stacked mode +/// uses in `Spline::from_path_f64`, so mosaic curves have the same character. +const FIT_ERROR: f64 = 10.0; + +/// Fit one splice slice into a single cubic, exactly as stacked mode does +/// (`fit_points_with_bezier`: one retract-handled cubic per slice, endpoints +/// pinned to the slice ends). +fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) { + match slice.len() { + 0 | 1 => {} + 2 => out.push(straight_cubic(slice[0], slice[1])), + _ => out.push(SubdivideSmooth::fit_points_with_bezier(slice, FIT_ERROR)), + } +} + +fn spline_to_beziers(spline: &Spline) -> Vec<[PointF64; 4]> { + spline + .get_control_points() + .into_iter() + .filter(|w| w.len() == 4) + .map(|w| [w[0], w[1], w[2], w[3]]) + .collect() +} + +impl SegmentFitter for SplineSegmentFitter { + fn fit_open(&self, seg: &Segment) -> FittedSegment { + if seg.points.len() <= 2 { + return FittedSegment { + geom: FittedGeom::Polyline(to_f64(&seg.points)), + }; + } + + // 1. Staircase removal via visioncortex's `limit_penalties` — the + // symmetric (area-based, no directional outset) simplifier stacked + // mode runs after remove_staircase. Used alone here it collapses + // staircases toward the crack midline, so the boundary stays + // centered and cannot cross a non-adjacent segment (which would + // open a gap in the tessellation). Endpoints are preserved. + let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone())); + if simplified.len() <= 2 { + return FittedSegment { + geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()), + }; + } + + // 2. Corner detection (open, endpoints forced as corners). + let mut corners = SubdivideSmooth::find_corners(&simplified, self.corner_threshold, false); + // 3. Open 4-point subdivision. + let mut path = simplified.to_path_f64(); + for _ in 0..self.max_iterations { + let (np, nc, done) = SubdivideSmooth::subdivide_keep_corners( + &path, + &corners, + OUTSET_RATIO, + self.length_threshold, + false, + ); + path = np; + corners = nc; + if done { + break; + } + } + // 4. Splice points (open, endpoints forced). + let splice = SubdivideSmooth::find_splice_points(&path, self.splice_threshold, false); + let cuts: Vec = splice + .iter() + .enumerate() + .filter_map(|(i, &s)| if s { Some(i) } else { None }) + .collect(); + + // 5. Per-slice cubic fit. + let mut beziers = Vec::new(); + for w in cuts.windows(2) { + fit_slice(&path.path[w[0]..=w[1]], &mut beziers); + } + + if beziers.is_empty() { + return FittedSegment { + geom: FittedGeom::Polyline(path.path.clone()), + }; + } + + // Pin the segment's endpoints exactly to the lattice nodes so that + // segments meeting at a junction share identical coordinates. + beziers.first_mut().unwrap()[0] = pt(seg.points[0]); + beziers.last_mut().unwrap()[3] = pt(seg.points[seg.points.len() - 1]); + FittedSegment { - geom: FittedGeom::Polyline(dp_open(&pts, self.tolerance)), + geom: FittedGeom::Beziers(beziers), } } 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 { + // Rings are closed loops — this is exactly the stacked closed-spline + // pipeline (simplify → smooth → fit). + if seg.points.len() <= 4 { return FittedSegment { - geom: FittedGeom::Polyline(pts), + geom: FittedGeom::Polyline(to_f64(&seg.points)), + }; + } + let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone())); + let smoothed = simplified.smooth( + self.corner_threshold, + OUTSET_RATIO, + self.length_threshold, + self.max_iterations, + ); + let spline = Spline::from_path_f64(&smoothed, self.splice_threshold); + let beziers = spline_to_beziers(&spline); + if beziers.is_empty() { + return FittedSegment { + geom: FittedGeom::Polyline(to_f64(&seg.points)), }; } - 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), + geom: FittedGeom::Beziers(beziers), } } } - -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/mod.rs b/crates/vtracer/src/mosaic/mod.rs index 887f633..7e4519b 100644 --- a/crates/vtracer/src/mosaic/mod.rs +++ b/crates/vtracer/src/mosaic/mod.rs @@ -21,7 +21,7 @@ mod graph; pub use compose::compose_mosaic; pub use fit::{ - FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, + FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter, }; pub use graph::{BoundaryGraph, Node, Segment, SegRef}; @@ -33,16 +33,6 @@ 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)] @@ -276,6 +266,44 @@ mod tests { assert_pixel_roundtrip(&grid(4, 4, labels)); } + #[test] + fn spline_segments_pin_endpoints_to_lattice() { + use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter}; + // A shape with junctions so there are open (non-ring) segments. + let map = grid(4, 4, vec![ + 0, 0, 1, 1, + 0, 0, 1, 1, + 2, 2, 1, 1, + 2, 2, 2, 2, + ]); + let graph = BoundaryGraph::extract(&map); + let fitter = SplineSegmentFitter::default(); + let mut checked = 0; + for seg in &graph.segments { + if seg.is_ring() { + continue; + } + let fitted = fitter.fit_open(seg); + let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 }; + let end = { + let p = seg.points[seg.points.len() - 1]; + PointF64 { x: p.x as f64, y: p.y as f64 } + }; + match fitted.geom { + FittedGeom::Beziers(b) => { + assert_eq!(b.first().unwrap()[0], start, "start pinned to node"); + assert_eq!(b.last().unwrap()[3], end, "end pinned to node"); + } + FittedGeom::Polyline(p) => { + assert_eq!(*p.first().unwrap(), start); + assert_eq!(*p.last().unwrap(), end); + } + } + checked += 1; + } + assert!(checked > 0, "expected some open segments"); + } + #[test] fn random_maps_roundtrip() { // Deterministic LCG; connectivity not required. diff --git a/crates/vtracer/src/svg/mod.rs b/crates/vtracer/src/svg/mod.rs index 2aac0e1..a68371b 100644 --- a/crates/vtracer/src/svg/mod.rs +++ b/crates/vtracer/src/svg/mod.rs @@ -131,6 +131,8 @@ struct Emitter { precision: Option, out: String, cur: PointF64, + /// Start of the current subpath; `cur` returns here after `Z`. + subpath_start: PointF64, started: bool, /// Absolute second control point of the previous cubic, for `S` detection. prev_cubic_c2: Option, @@ -144,6 +146,7 @@ impl Emitter { precision, out: String::new(), cur: PointF64::default(), + subpath_start: PointF64::default(), started: false, prev_cubic_c2: None, } @@ -161,6 +164,9 @@ impl Emitter { PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e), PathCmd::Close => { self.out.push('Z'); + // SVG resets the current point to the subpath's start after + // Z; a following relative `m`/`l` is measured from there. + self.cur = self.subpath_start; self.prev_cubic_c2 = None; } } @@ -184,6 +190,7 @@ impl Emitter { self.out.push_str(&token); } self.cur = p; + self.subpath_start = p; self.prev_cubic_c2 = None; } @@ -426,4 +433,150 @@ mod tests { assert!(!d.contains('c')); assert!(d.contains('L')); } + + /// A shape with a hole (second subpath). Encoded absolute vs relative must + /// describe the *same* geometry — regression for the bug where the current + /// point was not reset to the subpath start after `Z`, so the relative `m` + /// of the hole was measured from the wrong origin. + fn holed_shape() -> Shape { + use visioncortex::PointF64; + let p = |x, y| PointF64 { x, y }; + let outer = SubPath { + commands: vec![ + PathCmd::MoveTo(p(0.0, 0.0)), + PathCmd::LineTo(p(30.0, 0.0)), + PathCmd::LineTo(p(30.0, 30.0)), + PathCmd::LineTo(p(0.0, 30.0)), + PathCmd::Close, + ], + }; + let hole = SubPath { + commands: vec![ + PathCmd::MoveTo(p(10.0, 10.0)), + PathCmd::LineTo(p(20.0, 10.0)), + PathCmd::LineTo(p(20.0, 20.0)), + PathCmd::LineTo(p(10.0, 20.0)), + PathCmd::Close, + ], + }; + Shape { + paint: Paint::Solid(Color::new(0, 0, 0)), + path: MultiPath { + subpaths: vec![outer, hole], + }, + } + } + + /// Parse an SVG `d` (M/m/L/l/H/h/V/v/Z only) into absolute points. + fn parse_abs(d: &str) -> Vec<(f64, f64)> { + let mut toks = Vec::new(); + let mut i = 0; + let b = d.as_bytes(); + while i < b.len() { + let c = b[i] as char; + if c.is_ascii_alphabetic() { + toks.push(c.to_string()); + i += 1; + } else if c == '-' || c == '.' || c.is_ascii_digit() { + let start = i; + i += 1; + while i < b.len() && { + let d = b[i] as char; + d.is_ascii_digit() || d == '.' + } { + i += 1; + } + toks.push(d[start..i].to_string()); + } else { + i += 1; + } + } + let mut out = Vec::new(); + let (mut cx, mut cy, mut sx, mut sy) = (0.0, 0.0, 0.0, 0.0); + let mut j = 0; + let mut cmd = ' '; + let num = |j: &mut usize| -> f64 { + let v = toks[*j].parse().unwrap(); + *j += 1; + v + }; + while j < toks.len() { + if toks[j].chars().next().unwrap().is_ascii_alphabetic() { + cmd = toks[j].chars().next().unwrap(); + j += 1; + } + let rel = cmd.is_ascii_lowercase(); + match cmd.to_ascii_uppercase() { + 'M' => { + let (mut x, mut y) = (num(&mut j), num(&mut j)); + if rel { + x += cx; + y += cy; + } + cx = x; + cy = y; + sx = x; + sy = y; + out.push((cx, cy)); + cmd = if rel { 'l' } else { 'L' }; + } + 'L' => { + let (mut x, mut y) = (num(&mut j), num(&mut j)); + if rel { + x += cx; + y += cy; + } + cx = x; + cy = y; + out.push((cx, cy)); + } + 'H' => { + let mut x = num(&mut j); + if rel { + x += cx; + } + cx = x; + out.push((cx, cy)); + } + 'V' => { + let mut y = num(&mut j); + if rel { + y += cy; + } + cy = y; + out.push((cx, cy)); + } + 'Z' => { + cx = sx; + cy = sy; + } + _ => unreachable!(), + } + } + out + } + + #[test] + fn relative_and_absolute_encode_same_geometry() { + let shape = holed_shape(); + let abs = SvgWriter { + relative: false, + shorthands: false, + precision: Some(2), + } + .encode_path(&shape); + for shorthands in [false, true] { + let rel = SvgWriter { + relative: true, + shorthands, + precision: Some(2), + } + .encode_path(&shape); + assert_eq!( + parse_abs(&abs), + parse_abs(&rel), + "relative (shorthands={shorthands}) geometry diverges from absolute:\n abs={abs}\n rel={rel}" + ); + } + } } diff --git a/crates/vtracer/tests/equivalence.rs b/crates/vtracer/tests/equivalence.rs new file mode 100644 index 0000000..5255782 --- /dev/null +++ b/crates/vtracer/tests/equivalence.rs @@ -0,0 +1,215 @@ +//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes. +//! +//! Both modes render the *same* flattened partition of the image — stacked by +//! painting layers top-down, mosaic as a gapless tessellation. So their +//! rasterizations must agree in every region interior; they may differ only +//! within a thin band along region boundaries, where the two fitting paths +//! legitimately place the edge a fraction of a pixel apart. This test asserts +//! exactly that: any pixel that differs must lie within ~1–2px of a boundary. +//! +//! `resvg` is a dev-dependency, so this never enters a wasm build. + +use resvg::{tiny_skia, usvg}; +use vtracer::{ColorImage, Config, FitMode, Hierarchical}; + +/// A few smooth colored discs on a background — curved boundaries, limited +/// boundary length, no thin (1px) features. +fn blobs(w: usize, h: usize) -> ColorImage { + let discs = [ + (28.0f64, 30.0, 18.0, (210u8, 60, 60)), + (64.0, 40.0, 20.0, (60, 160, 90)), + (44.0, 68.0, 16.0, (70, 90, 200)), + ]; + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let mut col = (235u8, 230, 225); // background + for &(cx, cy, r, c) in &discs { + let dx = x as f64 - cx; + let dy = y as f64 - cy; + if dx * dx + dy * dy <= r * r { + col = c; + } + } + pixels.extend_from_slice(&[col.0, col.1, col.2, 255]); + } + } + ColorImage { + pixels, + width: w, + height: h, + } +} + +fn rasterize(svg: &str, w: u32, h: u32) -> Vec { + let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg"); + let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); + resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + pixmap.data().to_vec() +} + +/// Max per-channel difference between two RGBA pixels at index `i`. +fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 { + (0..4) + .map(|c| a[i + c].abs_diff(b[i + c])) + .max() + .unwrap_or(0) +} + +/// Mark pixels within Chebyshev radius `r` of a color edge in either image. +fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec { + const EDGE: u8 = 24; + let idx = |x: usize, y: usize| (y * w + x) * 4; + let mut edge = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let i = idx(x, y); + // An edge is where either rendering changes color vs its right/down + // neighbor. + let mut is_edge = false; + for img in [a, b] { + if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE { + is_edge = true; + } + if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE { + is_edge = true; + } + } + if is_edge { + edge[y * w + x] = true; + } + } + } + // Dilate the edge set by r. + let mut band = vec![false; w * h]; + for y in 0..h as i32 { + for x in 0..w as i32 { + let mut near = false; + 'outer: for dy in -r..=r { + for dx in -r..=r { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] { + near = true; + break 'outer; + } + } + } + band[y as usize * w + x as usize] = near; + } + } + band +} + +fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 { + (0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0) +} + +fn assert_equivalent(mode: FitMode) { + let (w, h) = (96usize, 96usize); + let img = blobs(w, h); + + let stacked = Config { + mode, + hierarchical: Hierarchical::Stacked, + ..Config::default() + } + .build() + .unwrap() + .to_svg(&img) + .unwrap(); + + let cutout = Config { + mode, + hierarchical: Hierarchical::Cutout, + ..Config::default() + } + .build() + .unwrap() + .to_svg(&img) + .unwrap(); + + let a = rasterize(&stacked, w as u32, h as u32); + let b = rasterize(&cutout, w as u32, h as u32); + assert_eq!(a.len(), b.len()); + + let band = boundary_band(&a, &b, w, h, 2); + + const DIFF: u8 = 40; + let mut interior_mismatches = 0; + for p in 0..(w * h) { + let i = p * 4; + if pixel_diff(&a, &b, i) > DIFF && !band[p] { + interior_mismatches += 1; + } + } + + // Every real difference must live in the boundary band; interiors match. + assert_eq!( + interior_mismatches, 0, + "{mode:?}: {interior_mismatches} interior pixels differ between stacked and cutout \ + (differences must be confined to the boundary band)" + ); +} + +#[test] +fn stacked_and_cutout_agree_in_interiors_spline() { + assert_equivalent(FitMode::Spline); +} + +#[test] +fn stacked_and_cutout_agree_in_interiors_polygon() { + assert_equivalent(FitMode::Polygon); +} + +#[test] +fn stacked_and_cutout_agree_in_interiors_pixel() { + assert_equivalent(FitMode::Pixel); +} + +// --- seam / show-through test ------------------------------------------------- + +fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec { + let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg"); + let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); + pixmap.fill(tiny_skia::Color::from_rgba8(bg[0], bg[1], bg[2], 255)); + resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + pixmap.data().to_vec() +} + +/// A full-canvas-coverage image rendered in stacked mode must be fully opaque: +/// solid layers overdraw with no gaps, so nothing shows through. Show-through +/// (backdrop-dependent pixels away from the canvas edge) means seams — which is +/// exactly the hole-punching bug this guards against. +#[test] +fn stacked_has_no_seams() { + let (w, h) = (96usize, 96usize); + let img = blobs(w, h); // background fills the whole canvas + let svg = Config { + mode: FitMode::Spline, + hierarchical: Hierarchical::Stacked, + ..Config::default() + } + .build() + .unwrap() + .to_svg(&img) + .unwrap(); + + let white = rasterize_on(&svg, w as u32, h as u32, [255, 255, 255, 255]); + let black = rasterize_on(&svg, w as u32, h as u32, [0, 0, 0, 255]); + + // Count backdrop-dependent pixels, ignoring the 1px canvas border (the only + // legitimate outer-silhouette antialiasing for a full-coverage image). + let mut show_through = 0; + for y in 1..h - 1 { + for x in 1..w - 1 { + let i = (y * w + x) * 4; + if (0..3).any(|c| white[i + c].abs_diff(black[i + c]) > 8) { + show_through += 1; + } + } + } + assert_eq!( + show_through, 0, + "stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw" + ); +} diff --git a/crates/vtracer/tests/golden.rs b/crates/vtracer/tests/golden.rs index 00f4713..9b31c44 100644 --- a/crates/vtracer/tests/golden.rs +++ b/crates/vtracer/tests/golden.rs @@ -197,6 +197,15 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> { ..base() }, ), + ( + "disc_mosaic_spline", + disc(), + Config { + hierarchical: Hierarchical::Cutout, + mode: FitMode::Spline, + ..base() + }, + ), ] } diff --git a/crates/vtracer/tests/goldens/bands_palette.svg b/crates/vtracer/tests/goldens/bands_palette.svg index 69e844c..dd12864 100644 --- a/crates/vtracer/tests/goldens/bands_palette.svg +++ b/crates/vtracer/tests/goldens/bands_palette.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/bands_pixel.svg b/crates/vtracer/tests/goldens/bands_pixel.svg index 4c658eb..cf9013a 100644 --- a/crates/vtracer/tests/goldens/bands_pixel.svg +++ b/crates/vtracer/tests/goldens/bands_pixel.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/bands_polygon.svg b/crates/vtracer/tests/goldens/bands_polygon.svg index 6b47cb9..e98a61c 100644 --- a/crates/vtracer/tests/goldens/bands_polygon.svg +++ b/crates/vtracer/tests/goldens/bands_polygon.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/bands_spline.svg b/crates/vtracer/tests/goldens/bands_spline.svg index d96b5c2..5b12122 100644 --- a/crates/vtracer/tests/goldens/bands_spline.svg +++ b/crates/vtracer/tests/goldens/bands_spline.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/checker_spline.svg b/crates/vtracer/tests/goldens/checker_spline.svg index 0c89efa..475e174 100644 --- a/crates/vtracer/tests/goldens/checker_spline.svg +++ b/crates/vtracer/tests/goldens/checker_spline.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_mosaic_spline.svg b/crates/vtracer/tests/goldens/disc_mosaic_spline.svg new file mode 100644 index 0000000..db1cfce --- /dev/null +++ b/crates/vtracer/tests/goldens/disc_mosaic_spline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/vtracer/tests/goldens/disc_opt0.svg b/crates/vtracer/tests/goldens/disc_opt0.svg index 74ce451..f3b329d 100644 --- a/crates/vtracer/tests/goldens/disc_opt0.svg +++ b/crates/vtracer/tests/goldens/disc_opt0.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_opt2.svg b/crates/vtracer/tests/goldens/disc_opt2.svg index 6193163..da91a39 100644 --- a/crates/vtracer/tests/goldens/disc_opt2.svg +++ b/crates/vtracer/tests/goldens/disc_opt2.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_spline.svg b/crates/vtracer/tests/goldens/disc_spline.svg index 6193163..da91a39 100644 --- a/crates/vtracer/tests/goldens/disc_spline.svg +++ b/crates/vtracer/tests/goldens/disc_spline.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/ring_spline.svg b/crates/vtracer/tests/goldens/ring_spline.svg index a7a30c9..c6c27ea 100644 --- a/crates/vtracer/tests/goldens/ring_spline.svg +++ b/crates/vtracer/tests/goldens/ring_spline.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/swatches_color.svg b/crates/vtracer/tests/goldens/swatches_color.svg index e2c100d..bd98675 100644 --- a/crates/vtracer/tests/goldens/swatches_color.svg +++ b/crates/vtracer/tests/goldens/swatches_color.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/swatches_quant4.svg b/crates/vtracer/tests/goldens/swatches_quant4.svg index e17c3a3..6de3ab2 100644 --- a/crates/vtracer/tests/goldens/swatches_quant4.svg +++ b/crates/vtracer/tests/goldens/swatches_quant4.svg @@ -1,16 +1,17 @@ - - - - - - - - - - - - + + + + + + + + + + + + +