mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-08-30 17:05:57 -07:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 1–2.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: Bradley–Roth 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
|
||||
**Bradley–Roth 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
|
||||
|
||||
@@ -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 1–2.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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Generated
+10
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user