From ef9496f7926a85ae25761cd7d00e6f6d6364e562 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Mon, 27 Jul 2026 22:30:05 +0100 Subject: [PATCH] Add a curve-simplification stage (--simplify), paper.js style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (promoted from mosaic::fit) so stacked contours flow through the same pass machinery; the optimizer's SimplifyPass is renamed CleanupPass to free the word. --- CHANGELOG.md | 1 + Cargo.toml | 4 + README.md | 7 +- crates/vtracer-cli/src/main.rs | 18 +- crates/vtracer-py/src/lib.rs | 2 + crates/vtracer/Cargo.toml | 1 + crates/vtracer/src/compose.rs | 56 +++- crates/vtracer/src/config.rs | 24 +- crates/vtracer/src/fitter.rs | 94 +++++-- crates/vtracer/src/lib.rs | 15 +- crates/vtracer/src/mosaic.rs | 50 +++- crates/vtracer/src/mosaic/compose.rs | 25 +- crates/vtracer/src/mosaic/fit.rs | 11 +- crates/vtracer/src/optimize.rs | 20 +- crates/vtracer/src/pipeline.rs | 7 +- crates/vtracer/src/simplify.rs | 383 +++++++++++++++++++++++++++ crates/vtracer/tests/simplify.rs | 93 +++++++ docs/design/architecture.md | 11 +- 18 files changed, 742 insertions(+), 80 deletions(-) create mode 100644 crates/vtracer/src/simplify.rs create mode 100644 crates/vtracer/tests/simplify.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d786e..557cc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, `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 diff --git a/Cargo.toml b/Cargo.toml index 4188256..21ce3dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index b39fe25..4108aa5 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,12 @@ Options: -c, --corner-threshold Minimum momentary angle (degrees) to be a corner (0..=180) -l, --segment-length Subdivide until all segments are shorter than this (3.5..=10) -s, --splice-threshold Minimum angle displacement (degrees) to splice a spline (0..=180) + --simplify Simplify curves: fewest cubics within this tolerance in px (try 1–2.5) --path-precision Decimal places to use in path coordinates --palette Fixed palette: comma-separated hex colors, e.g. '#112233,#445566' --palette-file Fixed palette from a file (hex colors, comma/newline separated) --max-colors Auto-quantize to at most N colors - --optimize Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands + --optimize Output optimization: 0 = off, 1 = quantize+cleanup, 2 = + shorthands --threshold Binary mode: fixed threshold 0..=255 (foreground below it) --adaptive Binary mode: Bradley–Roth adaptive threshold (uneven lighting) --adaptive-window Adaptive window size in px (0 = auto); implies --adaptive @@ -113,6 +114,10 @@ Options: - **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or **Bradley–Roth adaptive** thresholding (`--adaptive`, with `--adaptive-window` / `--adaptive-t`) for scans with uneven lighting. +- **`--simplify `** — 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 diff --git a/crates/vtracer-cli/src/main.rs b/crates/vtracer-cli/src/main.rs index 27923aa..d4553ec 100644 --- a/crates/vtracer-cli/src/main.rs +++ b/crates/vtracer-cli/src/main.rs @@ -71,6 +71,11 @@ struct Args { #[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))] splice_threshold: Option, + /// Simplify curves (spline mode): re-fit with the fewest cubics staying + /// within this tolerance in px (try 1–2.5; paper.js uses 2.5). + #[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)] + simplify: Option, + /// Decimal places to use in path coordinates. #[arg(long)] path_precision: Option, @@ -87,7 +92,7 @@ struct Args { #[arg(long)] max_colors: Option, - /// 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, @@ -112,6 +117,14 @@ struct Args { watershed_detail: Option, } +fn parse_simplify_tolerance(s: &str) -> Result { + 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 { 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 { 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; } diff --git a/crates/vtracer-py/src/lib.rs b/crates/vtracer-py/src/lib.rs index a0619c1..cdba7c3 100644 --- a/crates/vtracer-py/src/lib.rs +++ b/crates/vtracer-py/src/lib.rs @@ -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, diff --git a/crates/vtracer/Cargo.toml b/crates/vtracer/Cargo.toml index 14d66d4..022454d 100644 --- a/crates/vtracer/Cargo.toml +++ b/crates/vtracer/Cargo.toml @@ -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 diff --git a/crates/vtracer/src/compose.rs b/crates/vtracer/src/compose.rs index d78dff6..79b41d4 100644 --- a/crates/vtracer/src/compose.rs +++ b/crates/vtracer/src/compose.rs @@ -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]) -> 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 { + pub fn compose_with( + &self, + seg: &Segmentation, + passes: &[Box], + ctx: &mut Ctx, + ) -> Result { 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], +) -> 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], ctx: &mut Ctx, ) -> Result { 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], +) -> 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, diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 6086a5c..e4f624e 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -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, /// Coordinate precision (decimal places) for output. pub path_precision: Option, /// Fixed palette (empty = none). Takes priority over `max_colors`. pub palette: Vec, /// Auto-quantize target color count (None = off). pub max_colors: Option, - /// 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> { + 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> { 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(), }) diff --git a/crates/vtracer/src/fitter.rs b/crates/vtracer/src/fitter.rs index 96892c1..e41b2be 100644 --- a/crates/vtracer/src/fitter.rs +++ b/crates/vtracer/src/fitter.rs @@ -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 Douglas–Peucker 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), + /// 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; } /// 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 { 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 { 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 { 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 { + 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, 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 diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs index 2c42b97..b3980d6 100644 --- a/crates/vtracer/src/lib.rs +++ b/crates/vtracer/src/lib.rs @@ -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}; diff --git a/crates/vtracer/src/mosaic.rs b/crates/vtracer/src/mosaic.rs index b44b89c..14159d1 100644 --- a/crates/vtracer/src/mosaic.rs +++ b/crates/vtracer/src/mosaic.rs @@ -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, 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"); diff --git a/crates/vtracer/src/mosaic/compose.rs b/crates/vtracer/src/mosaic/compose.rs index cb4ee1e..7e53660 100644 --- a/crates/vtracer/src/mosaic/compose.rs +++ b/crates/vtracer/src/mosaic/compose.rs @@ -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], +) -> 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(); diff --git a/crates/vtracer/src/mosaic/fit.rs b/crates/vtracer/src/mosaic/fit.rs index 13b21ef..399d227 100644 --- a/crates/vtracer/src/mosaic/fit.rs +++ b/crates/vtracer/src/mosaic/fit.rs @@ -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), - /// 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 { diff --git a/crates/vtracer/src/optimize.rs b/crates/vtracer/src/optimize.rs index 0591ffb..438d7d8 100644 --- a/crates/vtracer/src/optimize.rs +++ b/crates/vtracer/src/optimize.rs @@ -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); diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index 0021349..1bec571 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -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, pub color_fitters: Vec>, 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>, pub optimizers: Vec>, 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() { diff --git a/crates/vtracer/src/simplify.rs b/crates/vtracer/src/simplify.rs new file mode 100644 index 0000000..bb88c0e --- /dev/null +++ b/crates/vtracer/src/simplify.rs @@ -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 = (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 = 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> { + let start_tan = tangent_out(run.first()?)?; + let end_tan = tangent_in(run.last()?)?; + let samples: Vec = sample_run(run, tolerance) + .into_iter() + .map(|p| Coord2(p.x, p.y)) + .collect(); + + let fitted: Vec> = 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 { + 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 { + let mut a: Vec = 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"), + } + } +} diff --git a/crates/vtracer/tests/simplify.rs b/crates/vtracer/tests/simplify.rs new file mode 100644 index 0000000..50be0a0 --- /dev/null +++ b/crates/vtracer/tests/simplify.rs @@ -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"); + } +} diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 22b21fb..890d33f 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -80,6 +80,11 @@ pub trait CurveFitter { fn fit_open(&self, polyline: &[PointF64]) -> Vec; // 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>, pub fitter: Box, pub compositing: Compositing, + pub curve_passes: Vec>, pub optimizers: Vec>, } @@ -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