Add mosaic spline fitter; fix stacked holes & relative writer; add test suite

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.
This commit is contained in:
Chris Tsang
2026-07-24 11:05:25 +01:00
parent 5ac90bb97c
commit 3300f97e37
22 changed files with 663 additions and 150 deletions
+5
View File
@@ -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"
+7 -7
View File
@@ -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<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic(Box<dyn SegmentFitter>, MosaicOptions),
Mosaic(Box<dyn SegmentFitter>),
}
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()),
}
}
}
+11 -7
View File
@@ -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<Pipeline, Error> {
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 {
+6 -1
View File
@@ -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 {
+2 -6
View File
@@ -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);
+187 -96
View File
@@ -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 DouglasPeucker. 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<usize> = 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<PointF64> = open[0..=far].to_vec();
let second: Vec<PointF64> = 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
}
/// DouglasPeucker on an open polyline; first and last points are always kept.
fn dp_open(pts: &[PointF64], tol: f64) -> Vec<PointF64> {
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()
}
+39 -11
View File
@@ -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.
+153
View File
@@ -131,6 +131,8 @@ struct Emitter {
precision: Option<u32>,
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<PointF64>,
@@ -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}"
);
}
}
}
+215
View File
@@ -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 ~12px 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<u8> {
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<bool> {
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<u8> {
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"
);
}
+9
View File
@@ -197,6 +197,15 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
..base()
},
),
(
"disc_mosaic_spline",
disc(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Spline,
..base()
},
),
]
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M12,0C23.88,0,35.76,0,48,0c0,13.2,0,26.4,0,40c-11.88,0-23.76,0-36,0c0-13.2,0-26.4,0-40Z" fill="#FFFFFF"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#000000"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 514 B

After

Width:  |  Height:  |  Size: 512 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M12,0L36,0L36,40L12,40Z" fill="#28C83C"/>
<path d="M0,0L48,0L48,40L0,40Z" fill="#28C83C"/>
<path d="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>

Before

Width:  |  Height:  |  Size: 381 B

After

Width:  |  Height:  |  Size: 379 B

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M12,0L36,0l0,40L12,40Z" fill="#28C83C"/>
<path d="M0,0L48,0l0,40L0,40Z" fill="#28C83C"/>
<path d="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 375 B

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M12,0c7.92,0,15.84,0,24,0c0,13.2,0,26.4,0,40c-7.92,0-15.84,0-24,0c0-13.2,0-26.4,0-40Z" fill="#28C83C"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#28C83C"/>
<path d="M36,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#E6D228"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#323CDC"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#DC2828"/>

Before

Width:  |  Height:  |  Size: 623 B

After

Width:  |  Height:  |  Size: 623 B

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-2.64,0-5.28,0-8,0c0,2.64,0,5.28,0,8c-13.2,0-26.4,0-40,0C0,32.16,0,16.32,0,0Z" fill="#EBEBEB"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#EBEBEB"/>
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
<path d="M32,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C0,16.32,0,32.16,0,48c16.32,0,32.16,0,48,0c0-16.32,0-32.16,0-48C31.68,0,15.84,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
<path d="M31.56,9.75C23.31,7.33,14,8.99,9.75,17.5c-.26,.85-.5,1.68-.75,2.5c-1.33,4-1.33,4,.81,10.44c1.84,3.95,3.73,6.83,7.69,8.81c.85,.26,1.68,.5,2.5,.75c4,1.33,4,1.33,10.44-.81c3.95-1.84,6.83-3.73,8.81-7.69c.26-.85,.5-1.67,.75-2.5c.75-2.24,1.06-3.6,.69-5.94c-1.85-5.53-3.65-10.5-9.13-13.31Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 888 B

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0C48,15.84,48,31.68,48,48C32.16,48,16.32,48,0,48C0,32.16,0,16.32,0,0ZM12.06,13.69C9.23,18.23,7.55,21.93,8.34,27.34C9.88,32.82,12.06,35.92,17,39C21.43,41.03,24.98,41.31,29.69,40C34.72,38.01,37.57,35.12,39.85,30.15C41.43,25.38,40.94,21.68,39.11,17.05C36.65,12.5,32.98,10.09,28.19,8.5C21.83,7.23,16.73,9.42,12.06,13.69Z" fill="#F0F0F0"/>
<path d="M0,0C15.84,0,31.68,0,48,0C48,15.84,48,31.68,48,48C32.16,48,16.32,48,0,48C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06C39.1,16.2,41.16,20.44,40.91,26.15C39.91,31.12,37.77,34.55,34,38C29.69,40.33,25.67,41.47,20.81,40.5C16.02,38.91,12.35,36.5,9.89,31.95C8.06,27.32,7.57,23.62,9.15,18.85C11.43,13.88,14.28,10.99,19.31,9C25.64,7.23,29.86,8.66,35.31,12.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 573 B

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0ZM12.06,13.69c-2.83,4.54-4.51,8.24-3.72,13.65C9.88,32.82,12.06,35.92,17,39c4.43,2.03,7.98,2.31,12.69,1c5.03-1.99,7.88-4.88,10.16-9.85c1.58-4.77,1.09-8.47-.74-13.1c-2.46-4.55-6.13-6.96-10.92-8.55c-6.36-1.27-11.46,.92-16.13,5.19Z" fill="#F0F0F0"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 770 B

After

Width:  |  Height:  |  Size: 544 B

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0ZM12.06,13.69c-2.83,4.54-4.51,8.24-3.72,13.65C9.88,32.82,12.06,35.92,17,39c4.43,2.03,7.98,2.31,12.69,1c5.03-1.99,7.88-4.88,10.16-9.85c1.58-4.77,1.09-8.47-.74-13.1c-2.46-4.55-6.13-6.96-10.92-8.55c-6.36-1.27-11.46,.92-16.13,5.19Z" fill="#F0F0F0"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 770 B

After

Width:  |  Height:  |  Size: 544 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M39,10c3.74,4.65,5.89,8.93,6,15c-.79,6.14-2.84,11.45-7.79,15.45c-4.85,3.28-9.22,5.06-15.21,4.29c-6.43-1.34-11.09-4.05-14.81-9.55c-2.9-5.06-3.7-9.53-2.5-15.25C6.65,13.3,10.06,9.37,16,5.98C23.76,2.19,32.65,4.52,39,10ZM17.44,17.44C15.29,21.27,15.25,24.71,16,29c1.44,2.56,1.44,2.56,4,4c4.29,.75,7.73,.71,11.56-1.44C33.71,27.73,33.75,24.29,33,20c-1.44-2.56-1.44-2.56-4-4c-4.29-.75-7.73-.71-11.56,1.44Z" fill="#285AC8"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#285AC8"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 829 B

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,24c15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0c0-7.92,0-15.84,0-24Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#55FF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#555580"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,16 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,24c15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0c0-7.92,0-15.84,0-24Z" fill="#FFED80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF4380"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#55DC80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#553280"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FFED80"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#55DC80"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF4380"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#553280"/>
<path d="M24,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#FFED80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,7.92,0,15.84,0,24c-3.96,0-7.92,0-12,0c0-7.92,0-15.84,0-24Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#55DC80"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#FF4380"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,7.92,0,15.84,0,24c-3.96,0-7.92,0-12,0C0,16.08,0,8.16,0,0ZM24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#553280"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#FFFF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#AA2A80"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#AA2A80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#FFFF80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0ZM24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB