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

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

CurveFitter now returns Vec<FittedGeom> (promoted from mosaic::fit) so
stacked contours flow through the same pass machinery; the optimizer's
SimplifyPass is renamed CleanupPass to free the word.
This commit is contained in:
Chris Tsang
2026-07-27 22:30:05 +01:00
parent d585984e78
commit ef9496f792
18 changed files with 742 additions and 80 deletions
+17 -1
View File
@@ -71,6 +71,11 @@ struct Args {
#[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))]
splice_threshold: Option<i64>,
/// Simplify curves (spline mode): re-fit with the fewest cubics staying
/// within this tolerance in px (try 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;
}