Add a curve-simplification stage (--simplify), paper.js style

A new pipeline slot between curve fitting and composition: CurvePasses
rewrite each fitted contour, so mosaic mode transforms every shared
boundary segment exactly once and the tessellation stays seam-free by
construction. SimplifyCurves re-fits each smooth run between corners
with the fewest cubics within the tolerance (Schneider's algorithm via
a current flo_curves — visioncortex's copy is pinned to an old one and
block-splits at 200 points), with tangents from the chain's own ends,
corners kept in place, junction endpoints pinned bit-for-bit, and rings
seamed at their sharpest junction. Off by default; polylines pass
through untouched. Cityscape at tolerance 1: 229 -> 138 KB stacked,
103 -> 36 KB watershed cutout, with render diffs under golden noise.

CurveFitter now returns Vec<FittedGeom> (promoted from mosaic::fit) so
stacked contours flow through the same pass machinery; the optimizer's
SimplifyPass is renamed CleanupPass to free the word.
This commit is contained in:
Chris Tsang
2026-07-27 22:30:05 +01:00
parent d585984e78
commit ef9496f792
18 changed files with 742 additions and 80 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
* Cutout mode merges neighbouring mosaic regions whose colors are within one gradient step — the flattened tessellation no longer keeps the near-identical faces that stacked gradient layering splits a smooth area into.
* Watershed clustering (`--clustering watershed`): an alternative region-forming frontend — a hierarchical watershed by volume on the pixel graph (Cousty et al., TPAMI 2009; Najman, Cousty & Perret, ISMM 2013), cut at a single `--watershed-detail` dial (0..=255, each +25.5 roughly doubles the region count). Content-adaptive regions with no watershed-line pixels; antialiased boundary pixels snap to the color-midpoint iso-line, so edges come out as calm as the color-cluster frontend's instead of meandering with the pixel noise inside the ramp. With `cutout` the partition reaches the mosaic natively, and near-identical neighbouring faces merge within a detail-derived tolerance (`max(2, (255 detail) / 8)`: the color-cluster default gradient step at the default detail, and never less than a just-noticeable difference — faces a human cannot tell apart never survive as separate patches); with `stacked` the merge tree itself is the stack — coarse ancestors below, refined regions on top, the same principle as color clustering — so sub-pixel gaps show ancestor colors and overdraw stays seam-free.
* `WatershedHierarchy` is public and split into `build` (expensive, depends only on the image) and `cut` (near-instant): `Session` builds it once and re-cuts on every `watershed_detail`/`filter_speckle` change, making the detail slider fully interactive (~25 ms re-cut vs ~40 ms rebuild on a 1400×775 photo).
* Curve simplification (`--simplify <tolerance>`, `Config::simplify`; off by default): a paper.js-style Schneider re-fit of the fitted splines — each smooth run between corners is re-fitted with the fewest cubics that stay within the tolerance (px), cutting anchor counts and file size (the 1400×775 sample photo: 229 → 138 KB stacked, 103 → 36 KB watershed cutout at tolerance 1). Implemented as a new pipeline stage (`CurvePass`) that runs on fitted geometry *before* composition, so mosaic mode simplifies each shared boundary exactly once and the tessellation stays seam-free; corners survive in place, junction endpoints are pinned bit-for-bit, and pixel/polygon polylines pass through untouched.
### Changed
+4
View File
@@ -27,3 +27,7 @@ repository = "https://github.com/visioncortex/vtracer/"
[workspace.dependencies]
visioncortex = "0.9.1"
# Schneider curve fitting for the simplify pass. visioncortex pins an old
# flo_curves internally for legacy reasons; we depend on the current one
# directly and convert at the call boundary.
flo_curves = "0.8"
+6 -1
View File
@@ -89,11 +89,12 @@ Options:
-c, --corner-threshold <CORNER_THRESHOLD> Minimum momentary angle (degrees) to be a corner (0..=180)
-l, --segment-length <SEGMENT_LENGTH> Subdivide until all segments are shorter than this (3.5..=10)
-s, --splice-threshold <SPLICE_THRESHOLD> Minimum angle displacement (degrees) to splice a spline (0..=180)
--simplify <TOLERANCE> Simplify curves: fewest cubics within this tolerance in px (try 12.5)
--path-precision <PATH_PRECISION> Decimal places to use in path coordinates
--palette <PALETTE> Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+cleanup, 2 = + shorthands
--threshold <THRESHOLD> Binary mode: fixed threshold 0..=255 (foreground below it)
--adaptive Binary mode: BradleyRoth adaptive threshold (uneven lighting)
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
@@ -113,6 +114,10 @@ Options:
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
**BradleyRoth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
/ `--adaptive-t`) for scans with uneven lighting.
- **`--simplify <tolerance>`** — paper.js-style curve simplification: re-fits
smooth runs with the fewest cubics that stay within the tolerance (px),
typically halving file size; seam-free in cutout mode because shared
boundaries are simplified once for both faces.
- **`--clustering watershed`** — an alternative region-forming algorithm: a
hierarchical watershed on the pixel graph (Cousty et al., TPAMI 2009; Najman,
Cousty & Perret, ISMM 2013), cut at `--watershed-detail`. Content-adaptive
+17 -1
View File
@@ -71,6 +71,11 @@ struct Args {
#[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))]
splice_threshold: Option<i64>,
/// Simplify curves (spline mode): re-fit with the fewest cubics staying
/// within this tolerance in px (try 12.5; paper.js uses 2.5).
#[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)]
simplify: Option<f64>,
/// Decimal places to use in path coordinates.
#[arg(long)]
path_precision: Option<u32>,
@@ -87,7 +92,7 @@ struct Args {
#[arg(long)]
max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
@@ -112,6 +117,14 @@ struct Args {
watershed_detail: Option<u8>,
}
fn parse_simplify_tolerance(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
if !v.is_finite() || v <= 0.0 {
return Err(format!("simplify tolerance {v} must be positive"));
}
Ok(v)
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
if !(3.5..=10.0).contains(&v) {
@@ -177,6 +190,9 @@ fn build_config(args: &Args) -> Result<Config, String> {
if let Some(v) = args.splice_threshold {
config.splice_threshold = v as i32;
}
if args.simplify.is_some() {
config.simplify = args.simplify;
}
if args.path_precision.is_some() {
config.path_precision = args.path_precision;
}
+2
View File
@@ -188,6 +188,8 @@ impl PyConfig {
length_threshold,
max_iterations,
splice_threshold,
// Curve simplification is not surfaced in the Python API yet.
simplify: None,
path_precision: Some(path_precision),
palette,
max_colors,
+1
View File
@@ -17,6 +17,7 @@ path = "src/lib.rs"
[dependencies]
visioncortex.workspace = true
flo_curves.workspace = true
[dev-dependencies]
# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not
+45 -11
View File
@@ -4,12 +4,18 @@
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
//!
//! Both compositors run the pipeline's [`CurvePass`]es over every fitted
//! contour before assembling paths — geometry passes have to happen here, on
//! the fitted geometry, so that in mosaic mode each shared boundary segment
//! is transformed exactly once for both of its faces.
use crate::error::Error;
use crate::fitter::CurveFitter;
use crate::ir::{Segmentation, Shape, VectorDoc};
use crate::ir::{MultiPath, RegionMask, Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
use crate::progress::{Ctx, Phase};
use crate::simplify::CurvePass;
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
@@ -27,12 +33,13 @@ pub enum Compositing {
}
impl Compositing {
/// Run the selected compositor over a segmentation.
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
/// Run the selected compositor over a segmentation, applying `passes` to
/// every fitted contour before paths are assembled.
pub fn compose(&self, seg: &Segmentation, passes: &[Box<dyn CurvePass>]) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref(), passes),
Compositing::Mosaic { fitter, merge_diff } => {
compose_mosaic(seg, fitter.as_ref(), *merge_diff)
compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes)
}
}
}
@@ -43,13 +50,18 @@ impl Compositing {
/// layers. Mosaic builds its boundary graph in one pass, so it reports
/// coarsely (start/end) and is cancellable only at the boundaries — the
/// dominant cost is upstream in clustering, which cancels finely.
pub fn compose_with(&self, seg: &Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
pub fn compose_with(
&self,
seg: &Segmentation,
passes: &[Box<dyn CurvePass>],
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
match self {
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), ctx),
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), passes, ctx),
Compositing::Mosaic { fitter, merge_diff } => {
ctx.check()?;
ctx.report(Phase::Compose, 0.0);
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff);
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes);
ctx.check()?;
ctx.report(Phase::Compose, 1.0);
Ok(doc)
@@ -58,18 +70,36 @@ impl Compositing {
}
}
/// Fit one region's outlines and run the curve passes over each contour.
/// Stacked contours are closed rings, so the ring form of each pass applies.
fn fit_region(
fitter: &dyn CurveFitter,
mask: &RegionMask,
passes: &[Box<dyn CurvePass>],
) -> MultiPath {
let mut path = MultiPath::new();
for mut geom in fitter.fit_region(mask) {
for pass in passes {
geom = pass.ring(geom);
}
path.push(geom.into_closed_subpath());
}
path
}
/// Progress-aware [`compose_stacked`]: reports after each layer and checks for
/// cancellation between them.
fn compose_stacked_with(
seg: &Segmentation,
fitter: &dyn CurveFitter,
passes: &[Box<dyn CurvePass>],
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
let mut doc = VectorDoc::new(seg.width, seg.height);
let total = seg.layers.len().max(1);
for (i, layer) in seg.layers.iter().enumerate() {
ctx.check()?;
let path = fitter.fit_region(&layer.mask);
let path = fit_region(fitter, &layer.mask, passes);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
@@ -82,10 +112,14 @@ fn compose_stacked_with(
}
/// Trace every layer's closed outline and stack the shapes in paint order.
pub fn compose_stacked(seg: &Segmentation, fitter: &dyn CurveFitter) -> VectorDoc {
pub fn compose_stacked(
seg: &Segmentation,
fitter: &dyn CurveFitter,
passes: &[Box<dyn CurvePass>],
) -> VectorDoc {
let mut doc = VectorDoc::new(seg.width, seg.height);
for layer in &seg.layers {
let path = fitter.fit_region(&layer.mask);
let path = fit_region(fitter, &layer.mask, passes);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
+21 -3
View File
@@ -14,8 +14,9 @@ use crate::frontend::{
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::optimize::{CleanupPass, OptimizerPass, QuantizePass};
use crate::pipeline::Pipeline;
use crate::simplify::{CurvePass, SimplifyCurves};
use crate::svg::SvgWriter;
/// Which region-forming algorithm segments the image.
@@ -88,13 +89,18 @@ pub struct Config {
pub max_iterations: usize,
/// Splice threshold in degrees.
pub splice_threshold: i32,
/// Curve simplification tolerance in px (paper.js-style `simplify`):
/// re-fit smooth runs of fitted cubics with the fewest curves that stay
/// within this distance, keeping corners in place. `None` = off. Only
/// affects spline mode; pixel/polygon polylines pass through untouched.
pub simplify: Option<f64>,
/// Coordinate precision (decimal places) for output.
pub path_precision: Option<u32>,
/// Fixed palette (empty = none). Takes priority over `max_colors`.
pub palette: Vec<Color>,
/// Auto-quantize target color count (None = off).
pub max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
pub optimize: u8,
/// Binary-mode fixed threshold (0..=255): foreground when grayscale
/// intensity is below this. Ignored when `binary_adaptive` is set.
@@ -124,6 +130,7 @@ impl Default for Config {
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
simplify: None,
path_precision: Some(2),
palette: Vec::new(),
max_colors: None,
@@ -236,6 +243,16 @@ impl Config {
}
}
fn curve_passes(&self) -> Vec<Box<dyn CurvePass>> {
match self.simplify {
Some(tolerance) if tolerance > 0.0 => vec![Box::new(SimplifyCurves {
tolerance,
corner_threshold: deg2rad(self.corner_threshold),
})],
_ => Vec::new(),
}
}
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
if self.optimize == 0 {
return Vec::new();
@@ -243,7 +260,7 @@ impl Config {
let precision = self.path_precision.unwrap_or(2);
vec![
Box::new(QuantizePass::new(precision)),
Box::new(SimplifyPass),
Box::new(CleanupPass),
]
}
@@ -314,6 +331,7 @@ impl Config {
frontend: self.frontend(),
color_fitters: self.color_fitters(),
compositing,
curve_passes: self.curve_passes(),
optimizers: self.optimizers(),
writer: self.writer(),
})
+65 -29
View File
@@ -1,22 +1,44 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit our [`MultiPath`] IR in absolute (document) coordinates:
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
//!
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
//! * [`PolygonFitter`] — staircase-symmetric DouglasPeucker polygon.
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
//!
//! All three trace *closed* region outlines (outer ring plus holes). Open
//! polyline fitting (needed for the mosaic compositor) will arrive with that
//! milestone.
//! All three trace *closed* region outlines (outer ring plus holes). The
//! mosaic compositor fits open boundary segments instead; see
//! [`crate::mosaic::SegmentFitter`].
use visioncortex::clusters::Cluster as BinaryCluster;
use visioncortex::{
CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32,
};
use crate::ir::{MultiPath, PathCmd, RegionMask, SubPath};
use crate::ir::{PathCmd, RegionMask, SubPath};
/// Fitted geometry for one contour — the common currency between the curve
/// fitters, the [`CurvePass`](crate::simplify::CurvePass) stage, and
/// composition. The stacked fitters produce one per closed outline; the
/// mosaic fitters produce one per shared boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
impl FittedGeom {
/// Convert one closed contour into a `MoveTo … Close` subpath.
pub fn into_closed_subpath(self) -> SubPath {
match self {
FittedGeom::Polyline(points) => polyline_subpath(&points),
FittedGeom::Beziers(chain) => beziers_subpath(&chain),
}
}
}
/// Fitting parameters shared by the built-in fitters. Only the spline fitter
/// consults the smoothing/splice fields.
@@ -43,9 +65,10 @@ impl Default for FitParams {
}
}
/// A curve fitter traces a region mask into closed vector outlines.
/// A curve fitter traces a region mask into closed vector outlines, one
/// [`FittedGeom`] per contour (outer ring or hole).
pub trait CurveFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath;
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom>;
}
/// Exact lattice polyline; every pixel-boundary step is preserved.
@@ -53,7 +76,7 @@ pub trait CurveFitter {
pub struct PixelFitter;
impl CurveFitter for PixelFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::None, FitParams::default())
}
}
@@ -63,7 +86,7 @@ impl CurveFitter for PixelFitter {
pub struct PolygonFitter;
impl CurveFitter for PolygonFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
}
}
@@ -81,19 +104,19 @@ impl SplineFitter {
}
impl CurveFitter for SplineFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::Spline, self.params)
}
}
/// Trace every connected component of a masked region and merge the resulting
/// outlines into a single [`MultiPath`] in absolute coordinates.
/// Trace every connected component of a masked region and collect the
/// resulting outlines, one [`FittedGeom`] per contour, in absolute coordinates.
///
/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with
/// holes already punched) is split into connected sub-clusters, each traced
/// independently, then offset into document space.
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> MultiPath {
let mut multi = MultiPath::new();
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> Vec<FittedGeom> {
let mut geoms = Vec::new();
for sub in mask.image.to_clusters(false).iter() {
let offset = PointI32 {
x: mask.offset.x + sub.rect.left,
@@ -108,12 +131,12 @@ fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) ->
params.max_iterations,
params.splice_threshold,
);
append_compound(&mut multi, &compound);
append_compound(&mut geoms, &compound);
}
multi
geoms
}
fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) {
fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
for element in compound.iter() {
match element {
CompoundPathElement::PathI32(p) => {
@@ -125,18 +148,34 @@ fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) {
y: q.y as f64,
})
.collect();
multi.push(polyline_subpath(&pts));
geoms.push(FittedGeom::Polyline(pts));
}
CompoundPathElement::PathF64(p) => {
multi.push(polyline_subpath(&p.path));
geoms.push(FittedGeom::Polyline(p.path.clone()));
}
CompoundPathElement::Spline(s) => {
multi.push(spline_subpath(&s.points));
geoms.push(FittedGeom::Beziers(spline_chain(&s.points)));
}
}
}
}
/// A spline of `1 + 3n` points becomes a chain of `n` cubics sharing endpoints.
fn spline_chain(points: &[PointF64]) -> Vec<[PointF64; 4]> {
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
return Vec::new();
}
let mut chain = Vec::with_capacity((points.len() - 1) / 3);
let mut start = points[0];
let mut i = 1;
while i + 2 < points.len() {
chain.push([start, points[i], points[i + 1], points[i + 2]]);
start = points[i + 2];
i += 3;
}
chain
}
/// A closed polyline whose last point repeats the first becomes
/// `MoveTo · LineTo* · Close`.
fn polyline_subpath(points: &[PointF64]) -> SubPath {
@@ -155,18 +194,15 @@ fn polyline_subpath(points: &[PointF64]) -> SubPath {
sub
}
/// A spline of `1 + 3n` points becomes `MoveTo · CubicTo* · Close`.
fn spline_subpath(points: &[PointF64]) -> SubPath {
/// A cubic chain becomes `MoveTo · CubicTo* · Close`.
fn beziers_subpath(chain: &[[PointF64; 4]]) -> SubPath {
let mut sub = SubPath::new();
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
if chain.is_empty() {
return sub;
}
sub.commands.push(PathCmd::MoveTo(points[0]));
let mut i = 1;
while i + 2 < points.len() {
sub.commands
.push(PathCmd::CubicTo(points[i], points[i + 1], points[i + 2]));
i += 3;
sub.commands.push(PathCmd::MoveTo(chain[0][0]));
for c in chain {
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
}
sub.commands.push(PathCmd::Close);
sub
+8 -7
View File
@@ -4,11 +4,11 @@
//! pipeline of pluggable stages.
//!
//! ```text
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ VectorDoc
//! │
//! OptimizerPass* ─────┤
//! ▼
//! SvgWriter ─▶ SVG
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ CurvePass* ─▶ VectorDoc
//!
//! OptimizerPass* ─────┤
//!
//! SvgWriter ─▶ SVG
//! ```
//!
//! The crate is wasm-safe: it performs no file or image I/O (that lives in the
@@ -26,8 +26,8 @@
//! ```
//!
//! For finer control, assemble a [`Pipeline`] directly from the stage traits
//! in [`frontend`], [`colorfit`], [`fitter`], [`compose`], [`optimize`], and
//! [`svg`].
//! in [`frontend`], [`colorfit`], [`fitter`], [`simplify`], [`compose`],
//! [`optimize`], and [`svg`].
pub mod colorfit;
pub mod compose;
@@ -41,6 +41,7 @@ pub mod optimize;
pub mod pipeline;
pub mod progress;
pub mod session;
pub mod simplify;
pub mod svg;
pub use config::{Clustering, Config, FitMode, Hierarchical, Preset, SegmentKey};
+47 -3
View File
@@ -21,7 +21,8 @@ mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
FittedGeom, FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
SplineSegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
@@ -485,6 +486,49 @@ mod tests {
assert!(checked > 0, "expected some open segments");
}
#[test]
fn curve_passes_keep_segment_endpoints_pinned() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
use crate::simplify::{CurvePass, SimplifyCurves};
// Simplification runs per shared segment; junction nodes must not
// move or the faces meeting there would disagree.
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 pass = SimplifyCurves {
tolerance: 2.0,
corner_threshold: std::f64::consts::PI / 3.0,
};
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
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 pass.open(fitter.fit_open(seg).geom) {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned through pass");
assert_eq!(b.last().unwrap()[3], end, "end pinned through pass");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
/// Build a label map with explicit per-region gray levels.
fn gray_grid(width: u32, height: u32, labels: Vec<RegionId>, grays: &[u8]) -> LabelMap {
LabelMap {
@@ -594,8 +638,8 @@ mod tests {
});
}
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0);
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16);
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0, &[]);
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16, &[]);
assert_eq!(unmerged.shapes.len(), 3);
assert_eq!(merged.shapes.len(), 1, "gradient strips coalesce into one face");
assert_eq!(merged.shapes[0].paint.color().r, 102, "area-weighted mean");
+20 -5
View File
@@ -7,6 +7,7 @@
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use crate::simplify::CurvePass;
use visioncortex::PointF64;
use super::face::{assemble, Contour, Face};
@@ -15,14 +16,23 @@ use super::graph::BoundaryGraph;
use super::{LabelMap, Segmentation};
/// Run the full mosaic pipeline: flatten → merge similar neighbours →
/// boundary graph → faces → fit → compose.
/// boundary graph → faces → fit → curve passes → compose.
///
/// `merge_diff` is the color-difference threshold for
/// [`LabelMap::merge_similar`]; pass the clustering `deepen_diff`
/// (gradient step) so the flattened mosaic rejoins what only the stacked
/// gradient layering had split. `0` still merges identical-color
/// neighbours; negative disables merging entirely.
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter, merge_diff: i32) -> VectorDoc {
///
/// `passes` run on each fitted segment before composition — once per shared
/// boundary, so both adjacent faces reference the transformed geometry and
/// the tessellation stays seam-free.
pub fn compose_mosaic(
seg: &Segmentation,
fitter: &dyn SegmentFitter,
merge_diff: i32,
passes: &[Box<dyn CurvePass>],
) -> VectorDoc {
let mut map = LabelMap::from_segmentation(seg);
map.merge_similar(merge_diff);
let graph = BoundaryGraph::extract(&map);
@@ -33,11 +43,16 @@ pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter, merge_diff
.segments
.iter()
.map(|s| {
if s.is_ring() {
fitter.fit_ring(s)
let ring = s.is_ring();
let mut geom = if ring {
fitter.fit_ring(s).geom
} else {
fitter.fit_open(s)
fitter.fit_open(s).geom
};
for pass in passes {
geom = if ring { pass.ring(geom) } else { pass.open(geom) };
}
FittedSegment { geom }
})
.collect();
+2 -9
View File
@@ -8,18 +8,11 @@ use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideS
use super::graph::Segment;
pub use crate::fitter::FittedGeom;
/// 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 {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
/// A fitted segment, cached and indexed by segment id.
#[derive(Clone, Debug)]
pub struct FittedSegment {
+11 -9
View File
@@ -1,11 +1,13 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! it here (rather than at write time) lets [`SimplifyPass`] act on the
//! it here (rather than at write time) lets [`CleanupPass`] act on the
//! rounded geometry, and it bakes offsets into coordinates so the writer
//! never needs a per-path `translate`.
//! * [`SimplifyPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created.
//! * [`CleanupPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created. (Curve *simplification* is not an
//! optimizer pass: it must run on shared fitted geometry before composition
//! — see [`crate::simplify`].)
use visioncortex::PointF64;
@@ -63,7 +65,7 @@ impl OptimizerPass for QuantizePass {
/// Remove zero-length segments and collinear-redundant line vertices.
#[derive(Debug, Clone, Copy, Default)]
pub struct SimplifyPass;
pub struct CleanupPass;
/// Tolerance for treating two points as coincident.
const COINCIDENT_EPS: f64 = 1e-6;
@@ -84,7 +86,7 @@ fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
(cross.abs() / base) < COLLINEAR_EPS
}
fn simplify_subpath(sub: &SubPath) -> SubPath {
fn cleanup_subpath(sub: &SubPath) -> SubPath {
let mut out = SubPath::new();
// `prev` is the point active before the last emitted command; `last` is the
// current point after it. Both are needed to test collinearity of a run.
@@ -127,12 +129,12 @@ fn simplify_subpath(sub: &SubPath) -> SubPath {
out
}
impl OptimizerPass for SimplifyPass {
impl OptimizerPass for CleanupPass {
fn run(&self, doc: &mut VectorDoc) {
for shape in &mut doc.shapes {
let mut subpaths = Vec::with_capacity(shape.path.subpaths.len());
for sub in &shape.path.subpaths {
let simplified = simplify_subpath(sub);
let simplified = cleanup_subpath(sub);
// Keep only subpaths with real geometry (a MoveTo plus at least
// one drawing command beyond Close).
let draws = simplified
@@ -185,7 +187,7 @@ mod tests {
}
#[test]
fn simplify_drops_collinear_and_zero_length() {
fn cleanup_drops_collinear_and_zero_length() {
// A straight run of colinear points plus a duplicate should collapse.
let mut doc = doc_with(vec![
PathCmd::MoveTo(pt(0.0, 0.0)),
@@ -195,7 +197,7 @@ mod tests {
PathCmd::LineTo(pt(2.0, 5.0)),
PathCmd::Close,
]);
SimplifyPass.run(&mut doc);
CleanupPass.run(&mut doc);
let cmds = &doc.shapes[0].path.subpaths[0].commands;
// MoveTo, one merged horizontal LineTo, one vertical LineTo, Close.
assert_eq!(cmds.len(), 4);
+6 -1
View File
@@ -9,6 +9,7 @@ use crate::frontend::Frontend;
use crate::ir::{Segmentation, VectorDoc};
use crate::optimize::OptimizerPass;
use crate::progress::{CancelToken, Ctx, Phase, Progress};
use crate::simplify::CurvePass;
use crate::svg::SvgWriter;
/// A fully-assembled vectorization pipeline. Build one with
@@ -17,6 +18,10 @@ pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub compositing: Compositing,
/// Geometry passes over fitted contours (e.g. curve simplification), run
/// inside compositing — after curve fitting, before paths are assembled —
/// so mosaic mode applies them once per shared boundary segment.
pub curve_passes: Vec<Box<dyn CurvePass>>,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
}
@@ -106,7 +111,7 @@ impl Pipeline {
ctx.check()?;
}
let mut doc = self.compositing.compose_with(&seg, ctx)?;
let mut doc = self.compositing.compose_with(&seg, &self.curve_passes, ctx)?;
let total = self.optimizers.len().max(1);
for (i, pass) in self.optimizers.iter().enumerate() {
+383
View File
@@ -0,0 +1,383 @@
//! Curve passes: geometry transforms between curve fitting and composition.
//!
//! A [`CurvePass`] rewrites one fitted contour at a time. Passes run *before*
//! composition on the fitted geometry itself — in mosaic mode each shared
//! boundary segment is transformed exactly once and both adjacent faces
//! reference the result, so the tessellation stays seam-free by construction.
//! Running instead on the composed [`VectorDoc`](crate::ir::VectorDoc) would
//! re-fit the two copies of every shared boundary independently and reopen
//! the seams the mosaic exists to prevent.
//!
//! The built-in pass is [`SimplifyCurves`], the paper.js `simplify` analogue.
use flo_curves::bezier::{fit_curve_cubic, Curve};
use flo_curves::Coord2;
use visioncortex::PointF64;
use crate::fitter::FittedGeom;
/// A geometry pass over one fitted contour, run between curve fitting and
/// composition. Implementations must keep an open chain's endpoints exactly
/// (mosaic junction nodes must not move) and keep a ring closed.
pub trait CurvePass {
/// Transform an open chain; both endpoints are pinned.
fn open(&self, geom: FittedGeom) -> FittedGeom;
/// Transform a closed ring.
fn ring(&self, geom: FittedGeom) -> FittedGeom;
}
/// paper.js-style curve simplification (Schneider's fit): re-fit each smooth
/// run of cubics between corners with the fewest curves that stay within
/// `tolerance` of the fitted geometry.
///
/// The spline fitters cut an outline at every splice point and fit each short
/// slice separately, so a lazily curving edge carries an anchor per splice.
/// This pass samples the fitted curve (~1 px spacing) and re-fits whole
/// corner-to-corner runs with `flo_curves`' Schneider implementation
/// (`fit_curve_cubic`, tangents taken from the chain's own ends), merging
/// those slices down to what the tolerance genuinely requires.
///
/// A run is replaced only when the re-fit uses strictly fewer cubics and is
/// kept verbatim otherwise, so the pass never increases the curve count and
/// never moves the geometry more than `tolerance` (measured at the samples).
/// Polylines (pixel / polygon modes) pass through untouched.
#[derive(Debug, Clone, Copy)]
pub struct SimplifyCurves {
/// Maximum distance (px) the simplified curve may stray from the fitted
/// one. paper.js defaults to 2.5.
pub tolerance: f64,
/// Tangent-break angle (radians) above which an anchor is a corner and
/// must survive in place; runs are re-fitted between corners.
pub corner_threshold: f64,
}
impl CurvePass for SimplifyCurves {
fn open(&self, geom: FittedGeom) -> FittedGeom {
match geom {
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, false)),
other => other,
}
}
fn ring(&self, geom: FittedGeom) -> FittedGeom {
match geom {
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, true)),
other => other,
}
}
}
impl SimplifyCurves {
fn simplify_chain(&self, mut chain: Vec<[PointF64; 4]>, closed: bool) -> Vec<[PointF64; 4]> {
if self.tolerance <= 0.0 || chain.len() < 2 {
return chain;
}
if closed {
// The re-fit pins run endpoints, so a ring needs a seam. Put it at
// the sharpest junction (wraparound included): a corner the fit
// would keep anyway, or the least-smooth anchor when the ring has
// none, so any residual tangent break lands where it hides best.
let angles: Vec<f64> = (0..chain.len())
.map(|k| {
let prev = if k == 0 { chain.len() - 1 } else { k - 1 };
break_angle(&chain[prev], &chain[k])
})
.collect();
let seam = angles
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
chain.rotate_left(seam);
}
// Cut into smooth runs at corner anchors (chain ends are always cuts).
let mut cuts: Vec<usize> = vec![0];
for i in 1..chain.len() {
if break_angle(&chain[i - 1], &chain[i]) >= self.corner_threshold {
cuts.push(i);
}
}
cuts.push(chain.len());
let mut out: Vec<[PointF64; 4]> = Vec::with_capacity(chain.len());
for w in cuts.windows(2) {
let run = &chain[w[0]..w[1]];
if run.len() < 2 {
out.extend_from_slice(run);
continue;
}
match refit_run(run, self.tolerance) {
Some(refit) if refit.len() < run.len() => out.extend(refit),
_ => out.extend_from_slice(run),
}
}
out
}
}
/// Schneider-fit one smooth run: sample it, then `fit_curve_cubic` with the
/// run's own end tangents (`end_tangent` points backward, per its contract).
/// The recursion splits at sample points, so consecutive fitted cubics share
/// endpoints exactly; the outer endpoints are pinned to the run's, bit for
/// bit. Returns `None` for degenerate (point-like) runs.
fn refit_run(run: &[[PointF64; 4]], tolerance: f64) -> Option<Vec<[PointF64; 4]>> {
let start_tan = tangent_out(run.first()?)?;
let end_tan = tangent_in(run.last()?)?;
let samples: Vec<Coord2> = sample_run(run, tolerance)
.into_iter()
.map(|p| Coord2(p.x, p.y))
.collect();
let fitted: Vec<Curve<Coord2>> = fit_curve_cubic(
&samples,
&Coord2(start_tan.0, start_tan.1),
&Coord2(-end_tan.0, -end_tan.1),
tolerance,
);
if fitted.is_empty() {
return None;
}
let pt = |c: Coord2| PointF64 { x: c.0, y: c.1 };
let mut out: Vec<[PointF64; 4]> = fitted
.into_iter()
.map(|c| [pt(c.start_point), pt(c.control_points.0), pt(c.control_points.1), pt(c.end_point)])
.collect();
out.first_mut()?[0] = run[0][0];
out.last_mut()?[3] = run[run.len() - 1][3];
Some(out)
}
fn dist(a: PointF64, b: PointF64) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
/// Unit direction a→b, or `None` when the points (nearly) coincide.
fn dir(a: PointF64, b: PointF64) -> Option<(f64, f64)> {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
None
} else {
Some((dx / len, dy / len))
}
}
/// Tangent arriving at a cubic's end: the last distinct control point wins.
fn tangent_in(c: &[PointF64; 4]) -> Option<(f64, f64)> {
dir(c[2], c[3]).or_else(|| dir(c[1], c[3])).or_else(|| dir(c[0], c[3]))
}
/// Tangent leaving a cubic's start: the first distinct control point wins.
fn tangent_out(c: &[PointF64; 4]) -> Option<(f64, f64)> {
dir(c[0], c[1]).or_else(|| dir(c[0], c[2])).or_else(|| dir(c[0], c[3]))
}
/// Turn angle at the junction of two consecutive cubics. A fully degenerate
/// (point-like) neighbour counts as a corner so it is never smoothed across.
fn break_angle(prev: &[PointF64; 4], next: &[PointF64; 4]) -> f64 {
match (tangent_in(prev), tangent_out(next)) {
(Some(a), Some(b)) => (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0).acos(),
_ => std::f64::consts::PI,
}
}
fn cubic_at(c: &[PointF64; 4], t: f64) -> PointF64 {
let u = 1.0 - t;
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
PointF64 {
x: b0 * c[0].x + b1 * c[1].x + b2 * c[2].x + b3 * c[3].x,
y: b0 * c[0].y + b1 * c[1].y + b2 * c[2].y + b3 * c[3].y,
}
}
/// Sample a run of cubics at roughly 1 px spacing (by control-polygon length),
/// tighter when the tolerance is sub-pixel — the fit measures its error at
/// the samples, so their spacing is the fidelity guard. The first and last
/// samples are the run's endpoints, exactly: `cubic_at` with `t = 1` returns
/// `c[3]` bit for bit.
fn sample_run(run: &[[PointF64; 4]], tolerance: f64) -> Vec<PointF64> {
let spacing = tolerance.clamp(0.25, 1.0);
let mut samples = vec![run[0][0]];
for c in run {
let len = dist(c[0], c[1]) + dist(c[1], c[2]) + dist(c[2], c[3]);
let n = ((len / spacing).ceil() as usize).clamp(1, 512);
for k in 1..=n {
samples.push(cubic_at(c, k as f64 / n as f64));
}
}
samples
}
#[cfg(test)]
mod tests {
use super::*;
fn pt(x: f64, y: f64) -> PointF64 {
PointF64 { x, y }
}
/// A degenerate cubic tracing the straight line `a`→`b`.
fn straight(a: PointF64, b: PointF64) -> [PointF64; 4] {
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
[a, lerp(1.0 / 3.0), lerp(2.0 / 3.0), b]
}
/// `n` straight cubics subdividing the segment `a`→`b`.
fn straight_chain(a: PointF64, b: PointF64, n: usize) -> Vec<[PointF64; 4]> {
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
(0..n)
.map(|i| straight(lerp(i as f64 / n as f64), lerp((i + 1) as f64 / n as f64)))
.collect()
}
/// One cubic approximating the circular arc `a0..a1` on a circle of
/// radius `r` about the origin (the classic 4/3·tan(Δ/4) handle length).
fn arc_cubic(r: f64, a0: f64, a1: f64) -> [PointF64; 4] {
let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan();
let (p0, p3) = (pt(r * a0.cos(), r * a0.sin()), pt(r * a1.cos(), r * a1.sin()));
[
p0,
pt(p0.x - k * r * a0.sin(), p0.y + k * r * a0.cos()),
pt(p3.x + k * r * a1.sin(), p3.y - k * r * a1.cos()),
p3,
]
}
fn pass() -> SimplifyCurves {
SimplifyCurves {
tolerance: 1.0,
corner_threshold: std::f64::consts::PI / 3.0,
}
}
fn anchors(chain: &[[PointF64; 4]]) -> Vec<PointF64> {
let mut a: Vec<PointF64> = chain.iter().map(|c| c[0]).collect();
a.push(chain.last().unwrap()[3]);
a
}
#[test]
fn collinear_run_collapses_to_one_cubic() {
let chain = straight_chain(pt(0.0, 0.0), pt(100.0, 0.0), 10);
let out = match pass().open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 1, "ten collinear cubics become one");
assert_eq!(out[0][0], pt(0.0, 0.0), "start pinned");
assert_eq!(out[0][3], pt(100.0, 0.0), "end pinned");
}
#[test]
fn corner_survives_in_place() {
// An L: two straight runs meeting at a right angle.
let mut chain = straight_chain(pt(0.0, 0.0), pt(50.0, 0.0), 5);
chain.extend(straight_chain(pt(50.0, 0.0), pt(50.0, 50.0), 5));
let out = match pass().open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 2, "one cubic per leg");
assert_eq!(out[0][3], pt(50.0, 0.0), "corner anchor exact");
assert_eq!(out[1][0], pt(50.0, 0.0), "chain continuous through corner");
assert_eq!(out[0][0], pt(0.0, 0.0));
assert_eq!(out[1][3], pt(50.0, 50.0));
}
#[test]
fn ring_stays_closed_and_keeps_square_corners() {
// A closed square, three cubics per side, seam mid-side (anchor 0 is
// smooth) — the pass must rotate the seam onto a corner.
let corners = [pt(0.0, 0.0), pt(60.0, 0.0), pt(60.0, 60.0), pt(0.0, 60.0)];
let mut chain = Vec::new();
for i in 0..4 {
chain.extend(straight_chain(corners[i], corners[(i + 1) % 4], 3));
}
chain.rotate_left(1); // seam mid-side
let out = match pass().ring(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 4, "one cubic per side");
assert_eq!(out[0][0], out.last().unwrap()[3], "ring closed");
let mut got = anchors(&out);
got.pop(); // last repeats first
for c in corners {
assert!(got.contains(&c), "corner {c:?} kept, got {got:?}");
}
}
#[test]
fn arc_merges_within_tolerance() {
// A quarter circle as 8 short arcs collapses to far fewer cubics, and
// the result stays within tolerance of the true circle.
let r = 50.0;
let n = 8;
let chain: Vec<[PointF64; 4]> = (0..n)
.map(|i| {
let step = std::f64::consts::FRAC_PI_2 / n as f64;
arc_cubic(r, i as f64 * step, (i + 1) as f64 * step)
})
.collect();
let tol = 0.5;
let p = SimplifyCurves {
tolerance: tol,
corner_threshold: std::f64::consts::PI / 3.0,
};
let out = match p.open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert!(out.len() < 8, "arcs merge, got {}", out.len());
for c in &out {
for k in 0..=32 {
let q = cubic_at(c, k as f64 / 32.0);
let radial = ((q.x * q.x + q.y * q.y).sqrt() - r).abs();
assert!(radial <= tol + 0.1, "deviation {radial} beyond tolerance");
}
}
}
#[test]
fn refit_never_grows_the_chain() {
// A single cubic is untouchable; a sharp S of two cubics that cannot
// merge within a tiny tolerance is kept verbatim.
let lone = vec![arc_cubic(50.0, 0.0, 1.0)];
match pass().open(FittedGeom::Beziers(lone.clone())) {
FittedGeom::Beziers(c) => assert_eq!(c, lone),
_ => panic!("geometry kind changed"),
}
let s_curve = vec![
[pt(0.0, 0.0), pt(20.0, 40.0), pt(30.0, 40.0), pt(50.0, 0.0)],
[pt(50.0, 0.0), pt(70.0, -40.0), pt(80.0, -40.0), pt(100.0, 0.0)],
];
let tight = SimplifyCurves {
tolerance: 0.01,
corner_threshold: std::f64::consts::PI / 3.0,
};
match tight.open(FittedGeom::Beziers(s_curve.clone())) {
FittedGeom::Beziers(c) => {
assert!(c.len() <= s_curve.len(), "never more cubics than input")
}
_ => panic!("geometry kind changed"),
}
}
#[test]
fn polylines_pass_through_untouched() {
let poly = vec![pt(0.0, 0.0), pt(1.0, 0.0), pt(2.0, 0.0), pt(3.0, 0.0)];
match pass().open(FittedGeom::Polyline(poly.clone())) {
FittedGeom::Polyline(p) => assert_eq!(p, poly),
_ => panic!("polyline must stay a polyline"),
}
match pass().ring(FittedGeom::Polyline(poly.clone())) {
FittedGeom::Polyline(p) => assert_eq!(p, poly),
_ => panic!("polyline must stay a polyline"),
}
}
}
+93
View File
@@ -0,0 +1,93 @@
//! The curve-simplification stage, end to end: `Config::simplify` must cut
//! anchor counts in both compositing modes without changing geometry kind,
//! and leave output untouched when off (the goldens enforce the byte-level
//! version of that).
use vtracer::ir::PathCmd;
use vtracer::{ColorImage, Config, FitMode, Hierarchical, VectorDoc};
/// A filled disc — one long smooth boundary, the best case for merging the
/// per-splice cubics the spline fitter emits.
fn disc_image(size: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(size * size * 4);
let (c, r) = (size as f64 / 2.0, size as f64 * 0.4);
for y in 0..size {
for x in 0..size {
let (dx, dy) = (x as f64 + 0.5 - c, y as f64 + 0.5 - c);
let (rr, gg, bb) = if (dx * dx + dy * dy).sqrt() < r {
(200, 60, 60)
} else {
(240, 240, 240)
};
pixels.extend_from_slice(&[rr, gg, bb, 255]);
}
}
ColorImage {
pixels,
width: size,
height: size,
}
}
fn cubic_count(doc: &VectorDoc) -> usize {
doc.shapes
.iter()
.flat_map(|s| &s.path.subpaths)
.flat_map(|sub| &sub.commands)
.filter(|c| matches!(c, PathCmd::CubicTo(..)))
.count()
}
fn run(config: &Config) -> VectorDoc {
config.build().unwrap().run(&disc_image(128)).unwrap()
}
#[test]
fn simplify_reduces_cubics_in_stacked_mode() {
let base = Config::default();
let simplified = Config {
simplify: Some(2.0),
..Config::default()
};
let (before, after) = (cubic_count(&run(&base)), cubic_count(&run(&simplified)));
assert!(before > 0, "the disc must be traced with cubics");
assert!(
after < before,
"simplify must reduce anchors: {before} -> {after}"
);
}
#[test]
fn simplify_reduces_cubics_in_cutout_mode() {
let cutout = |simplify| Config {
hierarchical: Hierarchical::Cutout,
simplify,
..Config::default()
};
let (before, after) = (
cubic_count(&run(&cutout(None))),
cubic_count(&run(&cutout(Some(2.0)))),
);
assert!(before > 0, "the disc must be traced with cubics");
assert!(
after < before,
"simplify must reduce anchors: {before} -> {after}"
);
}
#[test]
fn simplify_leaves_polyline_modes_untouched() {
for mode in [FitMode::Pixel, FitMode::Polygon] {
let base = Config {
mode,
..Config::default()
};
let simplified = Config {
simplify: Some(2.0),
..base.clone()
};
let a = base.build().unwrap().to_svg(&disc_image(64)).unwrap();
let b = simplified.build().unwrap().to_svg(&disc_image(64)).unwrap();
assert_eq!(a, b, "{mode:?} output must not change");
}
}
+10 -1
View File
@@ -80,6 +80,11 @@ pub trait CurveFitter {
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait CurvePass {
fn open(&self, geom: FittedGeom) -> FittedGeom; // endpoints pinned
fn ring(&self, geom: FittedGeom) -> FittedGeom; // stays closed
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
@@ -91,6 +96,7 @@ pub struct Pipeline {
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub curve_passes: Vec<Box<dyn CurvePass>>,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
@@ -106,6 +112,7 @@ Driver flow:
3. compositing:
- **Stacked** — trace each layer's closed outlines independently (port of today's `to_compound_path` flow) via `fitter.fit_closed`
- **Mosaic** — flatten to `LabelMap`, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once via `fitter.fit_open`, assemble faces (see [mosaic.md](mosaic.md))
- either way, `CurvePass`es run on each fitted contour *before* paths are assembled — in mosaic mode that means once per shared boundary segment, so both faces reference the transformed geometry and the tessellation stays seam-free. Running them any later (on the `VectorDoc`) would re-fit the two copies of every shared boundary independently and reopen the seams.
4. optimizer passes over the `VectorDoc`
5. `SvgWriter` serializes
@@ -125,13 +132,15 @@ Driver flow:
- `PixelFitter` — exact lattice polyline
- `PolygonFitter` — staircase-symmetric Douglas-Peucker
- `SplineFitter` — subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
- **CurvePasses** (selected by `Config::simplify`)
- `SimplifyCurves { tolerance, corner_threshold }` — the paper.js `simplify` analogue: samples each smooth run of fitted cubics between corners and re-fits it with the fewest curves that stay within `tolerance` px (Schneider's algorithm via a current `flo_curves`, with tangents taken from the chain's own ends; visioncortex's internal copy is pinned to an old flo_curves and block-splits at 200 points, so it is not used here). A run is only replaced when the re-fit is strictly smaller, corners stay in place, open-segment endpoints are pinned bit-for-bit, and rings are seamed at their sharpest junction. Polylines pass through untouched.
## Optimizer and SVG writer
Two levels: geometry passes over `VectorDoc`, then encoding choices in the writer.
- `QuantizePass { precision }` — round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-path `translate(x,y)` transform by baking offsets into coordinates.
- `SimplifyPass` — drop zero-length and collinear-redundant segments *after* quantization.
- `CleanupPass` — drop zero-length and collinear-redundant segments *after* quantization. (Curve *simplification* is deliberately not an optimizer pass — see `CurvePass` above.)
- `SvgWriter { relative: bool, shorthands: bool, precision }` — per segment picks the shortest encoding:
- relative (`l c s h v`) vs absolute deltas, whichever serializes shorter
- `h`/`v` for axis-aligned lines, `s` for smooth cubic continuations