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.
@@ -16,3 +16,8 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
visioncortex.workspace = true
|
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"
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
|
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
|
||||||
//!
|
//!
|
||||||
//! Only **stacked** composition is implemented: each layer is traced
|
//! * **Stacked** — each layer is traced independently into closed outlines and
|
||||||
//! independently into closed outlines and stacked in paint order (painter's
|
//! stacked in paint order (painter's algorithm).
|
||||||
//! algorithm). The **mosaic** compositor — gapless tessellation with shared
|
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
|
||||||
//! boundary geometry — is a separate milestone and not built yet.
|
//! geometry (see [`crate::mosaic`]).
|
||||||
|
|
||||||
use crate::fitter::CurveFitter;
|
use crate::fitter::CurveFitter;
|
||||||
use crate::ir::{Segmentation, Shape, VectorDoc};
|
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.
|
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
|
||||||
pub enum Compositing {
|
pub enum Compositing {
|
||||||
/// Independent per-region closed outlines, stacked bottom-to-top.
|
/// Independent per-region closed outlines, stacked bottom-to-top.
|
||||||
Stacked(Box<dyn CurveFitter>),
|
Stacked(Box<dyn CurveFitter>),
|
||||||
/// Seam-free gapless tessellation via a shared boundary graph.
|
/// Seam-free gapless tessellation via a shared boundary graph.
|
||||||
Mosaic(Box<dyn SegmentFitter>, MosaicOptions),
|
Mosaic(Box<dyn SegmentFitter>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Compositing {
|
impl Compositing {
|
||||||
@@ -22,7 +22,7 @@ impl Compositing {
|
|||||||
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
|
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
|
||||||
match self {
|
match self {
|
||||||
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
|
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use crate::compose::Compositing;
|
|||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
||||||
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
|
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::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
|
||||||
use crate::pipeline::Pipeline;
|
use crate::pipeline::Pipeline;
|
||||||
use crate::svg::SvgWriter;
|
use crate::svg::SvgWriter;
|
||||||
@@ -165,9 +167,13 @@ impl Config {
|
|||||||
match self.mode {
|
match self.mode {
|
||||||
FitMode::Pixel => Box::new(PixelSegmentFitter),
|
FitMode::Pixel => Box::new(PixelSegmentFitter),
|
||||||
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
|
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
|
||||||
// The spline segment fitter is not implemented yet; mosaic falls
|
FitMode::Spline => Box::new(SplineSegmentFitter {
|
||||||
// back to the polygon (crack-midline) fitter for now.
|
corner_threshold: deg2rad(self.corner_threshold),
|
||||||
FitMode::Spline => Box::new(PolygonSegmentFitter::default()),
|
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> {
|
pub fn build(&self) -> Result<Pipeline, Error> {
|
||||||
let compositing = match self.hierarchical {
|
let compositing = match self.hierarchical {
|
||||||
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
|
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
|
||||||
Hierarchical::Cutout => {
|
Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()),
|
||||||
Compositing::Mosaic(self.segment_fitter(), MosaicOptions::default())
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Pipeline {
|
Ok(Pipeline {
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ impl Frontend for ColorClusterFrontend {
|
|||||||
// paint order for the layer stack.
|
// paint order for the layer stack.
|
||||||
for &cluster_index in view.clusters_output.iter().rev() {
|
for &cluster_index in view.clusters_output.iter().rev() {
|
||||||
let cluster = view.get_cluster(cluster_index);
|
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(
|
let mask = RegionMask::new(
|
||||||
image,
|
image,
|
||||||
PointI32 {
|
PointI32 {
|
||||||
|
|||||||
@@ -12,14 +12,10 @@ use visioncortex::PointF64;
|
|||||||
use super::face::{assemble, Contour, Face};
|
use super::face::{assemble, Contour, Face};
|
||||||
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
|
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
|
||||||
use super::graph::BoundaryGraph;
|
use super::graph::BoundaryGraph;
|
||||||
use super::{LabelMap, MosaicOptions, Segmentation};
|
use super::{LabelMap, Segmentation};
|
||||||
|
|
||||||
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
|
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
|
||||||
pub fn compose_mosaic(
|
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorDoc {
|
||||||
seg: &Segmentation,
|
|
||||||
fitter: &dyn SegmentFitter,
|
|
||||||
_options: &MosaicOptions,
|
|
||||||
) -> VectorDoc {
|
|
||||||
let map = LabelMap::from_segmentation(seg);
|
let map = LabelMap::from_segmentation(seg);
|
||||||
let graph = BoundaryGraph::extract(&map);
|
let graph = BoundaryGraph::extract(&map);
|
||||||
let faces = assemble(&graph, &map);
|
let faces = assemble(&graph, &map);
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
|
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
|
||||||
//! the shared geometry is bitwise identical and no seam can appear.
|
//! 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;
|
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.
|
/// Fitted geometry for one boundary segment.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum FittedGeom {
|
pub enum FittedGeom {
|
||||||
@@ -58,116 +61,204 @@ impl SegmentFitter for PixelSegmentFitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Symmetric open Douglas–Peucker. Endpoints are always kept, so junction
|
/// Straight-segment fitter. Uses visioncortex's symmetric `limit_penalties`
|
||||||
/// nodes stay pinned. Plain DP (no directional staircase removal) collapses
|
/// simplification, which collapses 1px staircases toward the crack midline
|
||||||
/// 1-px staircases to the crack midline — centered between the two regions,
|
/// (centered, no directional outset) so the boundary stays gapless. Endpoints
|
||||||
/// which is what a mosaic wants.
|
/// are preserved, pinning junction nodes.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PolygonSegmentFitter {
|
pub struct PolygonSegmentFitter;
|
||||||
pub tolerance: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PolygonSegmentFitter {
|
impl PolygonSegmentFitter {
|
||||||
fn default() -> Self {
|
fn fit(&self, seg: &Segment) -> FittedSegment {
|
||||||
Self { tolerance: 0.5 }
|
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 {
|
impl SegmentFitter for PolygonSegmentFitter {
|
||||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
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 {
|
FittedSegment {
|
||||||
geom: FittedGeom::Polyline(dp_open(&pts, self.tolerance)),
|
geom: FittedGeom::Beziers(beziers),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||||
// Closed loop: split at the vertex farthest from the start, DP each
|
// Rings are closed loops — this is exactly the stacked closed-spline
|
||||||
// half, then rejoin. points[0] == points[last].
|
// pipeline (simplify → smooth → fit).
|
||||||
let pts = to_f64(&seg.points);
|
if seg.points.len() <= 4 {
|
||||||
if pts.len() <= 4 {
|
|
||||||
return FittedSegment {
|
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 {
|
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<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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ mod graph;
|
|||||||
|
|
||||||
pub use compose::compose_mosaic;
|
pub use compose::compose_mosaic;
|
||||||
pub use fit::{
|
pub use fit::{
|
||||||
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
|
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
||||||
};
|
};
|
||||||
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
|
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
|
||||||
|
|
||||||
@@ -33,16 +33,6 @@ pub type RegionId = u32;
|
|||||||
/// Sentinel label for pixels outside any region.
|
/// Sentinel label for pixels outside any region.
|
||||||
pub const OUTSIDE: RegionId = u32::MAX;
|
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
|
/// 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.
|
/// each region. This is the sole input to the boundary-graph extractor.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -276,6 +266,44 @@ mod tests {
|
|||||||
assert_pixel_roundtrip(&grid(4, 4, labels));
|
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]
|
#[test]
|
||||||
fn random_maps_roundtrip() {
|
fn random_maps_roundtrip() {
|
||||||
// Deterministic LCG; connectivity not required.
|
// Deterministic LCG; connectivity not required.
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ struct Emitter {
|
|||||||
precision: Option<u32>,
|
precision: Option<u32>,
|
||||||
out: String,
|
out: String,
|
||||||
cur: PointF64,
|
cur: PointF64,
|
||||||
|
/// Start of the current subpath; `cur` returns here after `Z`.
|
||||||
|
subpath_start: PointF64,
|
||||||
started: bool,
|
started: bool,
|
||||||
/// Absolute second control point of the previous cubic, for `S` detection.
|
/// Absolute second control point of the previous cubic, for `S` detection.
|
||||||
prev_cubic_c2: Option<PointF64>,
|
prev_cubic_c2: Option<PointF64>,
|
||||||
@@ -144,6 +146,7 @@ impl Emitter {
|
|||||||
precision,
|
precision,
|
||||||
out: String::new(),
|
out: String::new(),
|
||||||
cur: PointF64::default(),
|
cur: PointF64::default(),
|
||||||
|
subpath_start: PointF64::default(),
|
||||||
started: false,
|
started: false,
|
||||||
prev_cubic_c2: None,
|
prev_cubic_c2: None,
|
||||||
}
|
}
|
||||||
@@ -161,6 +164,9 @@ impl Emitter {
|
|||||||
PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e),
|
PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e),
|
||||||
PathCmd::Close => {
|
PathCmd::Close => {
|
||||||
self.out.push('Z');
|
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;
|
self.prev_cubic_c2 = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +190,7 @@ impl Emitter {
|
|||||||
self.out.push_str(&token);
|
self.out.push_str(&token);
|
||||||
}
|
}
|
||||||
self.cur = p;
|
self.cur = p;
|
||||||
|
self.subpath_start = p;
|
||||||
self.prev_cubic_c2 = None;
|
self.prev_cubic_c2 = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,4 +433,150 @@ mod tests {
|
|||||||
assert!(!d.contains('c'));
|
assert!(!d.contains('c'));
|
||||||
assert!(d.contains('L'));
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -197,6 +197,15 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
|||||||
..base()
|
..base()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"disc_mosaic_spline",
|
||||||
|
disc(),
|
||||||
|
Config {
|
||||||
|
hierarchical: Hierarchical::Cutout,
|
||||||
|
mode: FitMode::Spline,
|
||||||
|
..base()
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
<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="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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 514 B After Width: | Height: | Size: 512 B |
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
<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="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
|
||||||
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
|
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
|
||||||
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>
|
<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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
<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="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
|
||||||
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
|
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
|
||||||
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>
|
<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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
<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="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="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"/>
|
<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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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="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="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"/>
|
<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,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 820 B After Width: | Height: | Size: 573 B |
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 770 B After Width: | Height: | Size: 544 B |
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 770 B After Width: | Height: | Size: 544 B |
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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="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"/>
|
<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>
|
</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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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,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,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"/>
|
<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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
<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,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="#FF4380"/>
|
<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="#55DC80"/>
|
<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="#553280"/>
|
<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="#FFED80"/>
|
<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="#55DC80"/>
|
<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="#FF4380"/>
|
<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="#553280"/>
|
<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="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,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,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="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="#FF4380"/>
|
<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,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,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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |