From 913d0ac7e058a506f4f0b5ba9b788f1c1810d572 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 23 Jul 2026 18:11:19 +0100 Subject: [PATCH] New design --- docs/design/README.md | 46 +++++++++ docs/design/architecture.md | 153 +++++++++++++++++++++++++++++ docs/design/bindings.md | 71 ++++++++++++++ docs/design/mosaic.md | 190 ++++++++++++++++++++++++++++++++++++ docs/design/roadmap.md | 19 ++++ 5 files changed, 479 insertions(+) create mode 100644 docs/design/README.md create mode 100644 docs/design/architecture.md create mode 100644 docs/design/bindings.md create mode 100644 docs/design/mosaic.md create mode 100644 docs/design/roadmap.md diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..57b4902 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,46 @@ +# VTracer 1.0 Design Documents + +VTracer is being rearchitected from a single hardcoded pipeline into a **vectorization framework**. These documents describe the target design. + +| Document | Contents | +|---|---| +| [architecture.md](architecture.md) | Workspace layout, core IR, stage traits, pipeline driver, optimizer & SVG writer, CLI | +| [mosaic.md](mosaic.md) | The seam-free cutout/mosaic mode: boundary-graph tracing and shared-edge curve fitting | +| [bindings.md](bindings.md) | Python (PyPI), wasm, and the new Node.js (npm) package | +| [roadmap.md](roadmap.md) | Milestones and verification strategy | + +## Motivation + +VTracer today (0.6.x) is a thin driver around the `visioncortex` crate: one pipeline (color clustering → per-cluster tracing → SVG string), a CLI, a pyo3 binding, and a web demo that duplicates the pipeline. The rewrite turns it into a framework with pluggable stages: + +1. **Frontend** — any algorithm that produces clusters/segmentation from a raster image +2. **Curve fitting backend** — pluggable polyline→curve fitters (pixel, polygon, spline, future potrace-style) +3. **Color fitting** — mapping cluster colors to final paints, including custom fixed palettes +4. **Optimizer** — a pass pipeline that shrinks output (relative path syntax, shorthand commands, precision reduction) +5. **True mosaic cutout** — a perfect, gapless tessellation with shared boundary geometry, replacing today's fake cutout (which re-clusters a re-rendered image and shows seams) + +The project stays backend/CLI focused, and everything except image file I/O compiles to `wasm32-unknown-unknown`. + +## Decisions + +- **`visioncortex` remains a dependency**, wrapped behind traits. Development uses a path/`[patch]` dependency on the local checkout; API additions are committed to visioncortex directly and published as 0.8.x releases. Verified that everything the new design needs is already public: the fitting primitives (`fit_points_with_bezier`, `find_corners`, `subdivide_keep_corners`, `reduce`, `PathSimplify::*`) and cluster pixel access via `ClustersView`. +- **In-repo rewrite, clean break.** New workspace layout, new API, version bump. Old CLI flags are kept only where they map naturally. +- **Python binding stays** (ported to the new API). The **webapp GUI is dropped**; a wasm library crate replaces it. +- **New Node.js library** published to npm, using the wasm build internally plus a native image reader (sharp). + +## Pipeline at a glance + +``` + ┌───────────┐ ┌──────────────┐ ┌─────────────────────────────┐ + raster ───▶ │ Frontend │ ─▶│ ColorFitter* │ ─▶│ Compositing │ + image │ (segment) │ │ (palette, │ │ Stacked: closed outlines │ + └───────────┘ │ quantize, │ │ Mosaic: boundary graph + │ + │ merge) │ │ shared-edge fit │ + └──────────────┘ └──────────────┬──────────────┘ + │ CurveFitter + ▼ (pixel/polygon/spline) + ┌──────────────────────────────┐ + SVG ◀──── │ VectorDoc ─ OptimizerPass* ─ │ + │ SvgWriter │ + └──────────────────────────────┘ +``` diff --git a/docs/design/architecture.md b/docs/design/architecture.md new file mode 100644 index 0000000..c8214d0 --- /dev/null +++ b/docs/design/architecture.md @@ -0,0 +1,153 @@ +# Architecture + +## Workspace layout + +``` +Cargo.toml # workspace +crates/ +├── vtracer-core/ # the framework. wasm-safe, no file/image I/O, no clap/pyo3 +│ └── src/ +│ ├── lib.rs +│ ├── ir/ # Segmentation, LabelMap, VectorDoc, geometry types +│ ├── frontend/ # trait Frontend + ColorClusterFrontend, BinaryFrontend, keying +│ ├── colorfit/ # trait ColorFitter + Identity, FixedPalette, AutoQuantize +│ ├── fitter/ # trait CurveFitter + Pixel, Polygon, Spline +│ ├── compose/ # stacked composition (per-region closed tracing) +│ ├── mosaic/ # boundary-graph extraction + shared-edge fitting (see mosaic.md) +│ ├── optimize/ # trait OptimizerPass + passes over VectorDoc +│ ├── svg/ # writer (absolute/relative, shorthands, precision) +│ └── pipeline.rs # Pipeline driver + Config/presets +├── vtracer/ # publishable bin+lib crate, keeps the crate name. +│ # image I/O (image crate), clap 4 CLI, +│ # pyo3 binding behind `python-binding` feature +└── vtracer-wasm/ # wasm-bindgen bindings over vtracer-core +nodejs/ # npm package: TS wrapper + embedded wasm build + sharp reader +``` + +- `webapp/` and `cmdapp/` are deleted (git history preserves them). +- `vtracer` re-exports `vtracer-core`, so library users need a single dependency. +- During development the workspace carries `[patch.crates-io] visioncortex = { path = "../visioncortex" }`; releases pin a published 0.8.x. +- `flo_curves` (already in the tree via visioncortex) becomes a direct dependency of `vtracer-core` for configurable-error Bezier fitting. + +## Core IR + +Value types from `visioncortex` are reused where they fit (`ColorImage`, `Color`, `PointF64`, `CompoundPath`); the pipeline IR is our own: + +```rust +/// Frontend output — the general form is ordered layers (painter's algorithm). +pub struct Segmentation { + pub width: u32, + pub height: u32, + pub layers: Vec, // bottom-to-top paint order +} + +pub struct Layer { + pub paint: Paint, // starts as mean cluster color; ColorFitter may rewrite + pub mask: RegionMask, // the cluster's pixel indices +} + +/// Flat partition for mosaic mode, derived by painting layers top-down. +pub struct LabelMap { + pub width: u32, + pub height: u32, + pub labels: Vec, // one label per pixel; u32::MAX = OUTSIDE (keyed/transparent) + pub paints: Vec, // indexed by label +} + +/// Output document IR — what the optimizer and the writer operate on. +pub struct VectorDoc { pub width: u32, pub height: u32, pub shapes: Vec } +pub struct Shape { pub paint: Paint, pub path: MultiPath } // subpaths: MoveTo + (Line|Cubic)* + Close +pub enum Paint { Solid(Color) } // room for gradients later +``` + +Why layers, not a label map, as the frontend output: in stacked mode clusters genuinely overlap (each hierarchical cluster is painted over its parents), which a flat label map cannot represent. The flat `LabelMap` needed by mosaic mode is derived from the layers by a top-down flatten — cheap and lossless for that purpose. + +## Stage traits + +All object-safe; the driver composes boxed trait objects (ergonomic across CLI/py/wasm boundaries, negligible dispatch cost next to the per-pixel work). + +```rust +pub trait Frontend { + fn segment(&self, img: &ColorImage) -> Result; +} + +pub trait ColorFitter { + fn fit(&self, seg: &mut Segmentation); +} + +pub trait CurveFitter { + fn fit_closed(&self, polyline: &[PointF64]) -> Vec; // stacked outlines, rings + fn fit_open(&self, polyline: &[PointF64]) -> Vec; // mosaic edges, endpoints pinned +} + +pub trait OptimizerPass { + fn run(&self, doc: &mut VectorDoc); +} + +pub enum Compositing { Stacked, Mosaic } + +pub struct Pipeline { + pub frontend: Box, + pub color_fitters: Vec>, + pub fitter: Box, + pub compositing: Compositing, + pub optimizers: Vec>, +} + +impl Pipeline { + pub fn run(&self, img: &ColorImage) -> Result { /* driver */ } +} +``` + +Driver flow: + +1. `frontend.segment(img)` → `Segmentation` +2. each `ColorFitter` rewrites layer paints (e.g. palette snapping) +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)) +4. optimizer passes over the `VectorDoc` +5. `SvgWriter` serializes + +## Built-in implementations + +- **Frontends** + - `ColorClusterFrontend` — wraps `visioncortex::color_clusters::Runner`, including the transparency-keying logic that currently lives in `converter.rs` (find unused key color, key fully-transparent pixels, `KeyingAction`). + - `BinaryFrontend` — threshold → `BinaryImage::to_clusters`. + - Third parties implement `Frontend` to feed external label maps or ML segmentation. +- **ColorFitters** + - `Identity` (today's behavior: mean cluster color) + - `FixedPalette { colors: Vec }` — snaps each layer paint to the nearest palette entry in OKLab + - `AutoQuantize { max_colors }` — k-means/median-cut over layer paints + - After palette snapping, a built-in merge step unions adjacent regions with identical paint (mosaic path) / merges consecutive identical-paint layers (stacked path). +- **CurveFitters** + - `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) + +## 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. +- `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 + - number formatting: trim trailing zeros, omit the space before negative numbers, leading-dot decimals +- Paint grouping: shapes sharing a fill emitted inside `` when it saves bytes. + +Output size is a tracked metric: the test suite asserts a byte-size budget against golden samples (see [roadmap.md](roadmap.md)). + +## CLI + +clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--colormode color|bw`, `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`. + +New: + +- `--hierarchical stacked|cutout` — `cutout` now runs the true mosaic pipeline +- `--palette '#112233,#445566,…'` / `--palette-file colors.txt` — fixed palette color fitting +- `--optimize 0..2` — optimizer level (0 = off, 1 = quantize+simplify, 2 = + full writer shorthands/grouping) +- mosaic extras: `--seam-stroke`, `--mosaic-strict` (see mosaic.md) + +Range validation moves from `panic!` to clap `value_parser` ranges. diff --git a/docs/design/bindings.md b/docs/design/bindings.md new file mode 100644 index 0000000..a05cda7 --- /dev/null +++ b/docs/design/bindings.md @@ -0,0 +1,71 @@ +# Bindings + +Backend/CLI focused, with three language surfaces on top of `vtracer-core`. Everything except image file I/O compiles to `wasm32-unknown-unknown`. + +## Python (PyPI) + +Lives in the `vtracer` crate behind the `python-binding` feature (keeps the existing maturin / PyPI Trusted Publisher workflow intact). + +- Ported functions with today's signatures: `convert_image_to_svg_py(image_path, out_path, **config)` and `convert_raw_image_to_svg(img_bytes, img_format=None, **config) -> str`. +- New kwargs: `palette: list[str]` (hex colors), `optimize: int`, and `hierarchical='cutout'` now meaning true mosaic. + +## Wasm (`vtracer-wasm` crate) + +wasm-bindgen bindings over `vtracer-core`, replacing the old `webapp/` (the GUI demo is dropped). + +```text +convert(rgba: Uint8Array, width: u32, height: u32, config_json: string) -> string // SVG +``` + +- Input is raw RGBA pixels — no image decoding in wasm (keeps the module small; decoding is the host's job). +- The `fastrand/js` feature wiring moves here. +- Built with `wasm-pack`; consumed by the Node.js package below and usable directly in browsers/bundlers. + +## Node.js (npm) + +New top-level `nodejs/` directory; recommended package name **`@visioncortex/vtracer`** (scoped — avoids collision/squatting on bare `vtracer`). + +Design: wasm internally, native image reading. + +- The `vtracer-wasm` build (`wasm-pack --target nodejs`) is **embedded in the package** — no network fetch, works offline. +- **[sharp](https://sharp.pixelplumbing.com/)** (native libvips binding with prebuilt binaries) decodes PNG/JPEG/WebP/GIF/AVIF/TIFF to raw RGBA, which is fed to the wasm converter. sharp is a regular dependency (this is a Node-focused library); the pixel-level API still works if the native install fails. + +TypeScript API: + +```ts +export interface Options { + // camelCase mirror of the Rust Config: + colorMode?: 'color' | 'binary'; + hierarchical?: 'stacked' | 'cutout'; // cutout = true mosaic + mode?: 'pixel' | 'polygon' | 'spline'; + filterSpeckle?: number; + colorPrecision?: number; + gradientStep?: number; + cornerThreshold?: number; + segmentLength?: number; + spliceThreshold?: number; + pathPrecision?: number; + palette?: string[]; // ['#112233', ...] + optimize?: 0 | 1 | 2; +} + +/** Pure wasm — no native dependency needed. */ +export function convertPixels(rgba: Uint8Array, width: number, height: number, options?: Options): string; + +/** Decodes via sharp (native), then converts. Accepts a file path or an encoded image buffer. */ +export function convertImage(input: string | Buffer, options?: Options): Promise; +``` + +- Tests: vitest (or `node:test`) over the same sample images used by the Rust snapshot tests. +- Publishing: `npm publish` wired into the release workflow alongside crates.io and PyPI. + +## visioncortex development flow + +`visioncortex` stays a dependency. The workspace carries + +```toml +[patch.crates-io] +visioncortex = { path = "../visioncortex" } +``` + +during development; API additions are committed directly to the local visioncortex repo and published as 0.8.x before a vtracer release, which then pins the published version. diff --git a/docs/design/mosaic.md b/docs/design/mosaic.md new file mode 100644 index 0000000..e4f839c --- /dev/null +++ b/docs/design/mosaic.md @@ -0,0 +1,190 @@ +# Mosaic Mode — Seam-Free Cutout + +Today's cutout re-renders the clustered image and re-clusters it, then traces every region independently; independently smoothed neighbors diverge, producing seams. The new mosaic mode replaces it with a topological pipeline that is seam-free **by construction**: + +``` +label map (Vec, W·H) + → 1. boundary-graph extraction (nodes, shared segments, rings) [integer, exact] + → 2. face assembly (per-region contours as cycles of (seg, dir)) [integer, exact] + → 3. fit each segment ONCE (pluggable pixel/polygon/spline) [float, endpoints pinned] + → 4. compose per-region SVG paths from shared fitted segments +``` + +Every boundary curve exists exactly once; the two adjacent regions reference the same fitted object, one traversed reversed. Reversal is exact for both polylines and cubic Beziers (`[p0,p1,p2,p3] → [p3,p2,p1,p0]`), so the serialized coordinates are identical text on both sides — no seams, no T-junction cracks. + +**Coordinate convention**: pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; all boundary geometry lives on the lattice of pixel corners `0..=W × 0..=H` ("crack" boundaries). Stages 1–2 are pure integer arithmetic. + +## 1. Boundary-graph extraction + +### Definitions + +- `type RegionId = u32; const OUTSIDE: RegionId = u32::MAX;` — `label(x,y)` returns `OUTSIDE` out of bounds. Treating outside as a real label removes all image-border special cases: border edges and border junctions fall out of the same rules. +- At lattice corner `c=(x,y)` the 2×2 pixel neighborhood is `NW NE / SW SE`. Four potential unit edges at `c`: N present iff `NW≠NE`, E iff `NE≠SE`, S iff `SW≠SE`, W iff `NW≠SW`. Degree = popcount ∈ {0, 2, 3, 4}. +- Quadrant/edge incidence for traversal: NE ↔ {N,E}, SE ↔ {E,S}, SW ↔ {S,W}, NW ↔ {W,N}. + +### Node rule (junctions) and the checkerboard decision + +**A corner is a node iff degree ≥ 3.** + +- Three distinct labels in the 2×2 always gives degree ≥ 3 — "3+ regions meet here" is covered. +- Degree 4 with two labels is exactly the checkerboard `A B / B A` (diagonal contact). **Decision: it is a junction node of 4 edges, and faces are pinched there.** The traversal rule below always takes the sharpest right turn, staying within the current quadrant, never crossing diagonally. If clustering was 8-connected (visioncortex `diagonal: true`), a two-lobe region yields **two separate simple contours** sharing the node coordinate but no edges — emitted as one SVG path with two subpaths. Faces stay simple; the tessellation stays exact. +- Image corners (three quadrants OUTSIDE) are degree-2 chain points, not nodes. Points where two regions meet the border are degree 3 — nodes automatically. + +Invariant used by segment tracing: at a degree-2 corner the 2×2 contains exactly two labels and both incident edges separate the same unordered pair — so the (left, right) region pair is constant along any chain of degree-2 corners. + +### Data structures + +```rust +pub type NodeId = u32; +pub type SegId = u32; + +#[derive(Clone, Copy)] +pub struct SegRef { pub seg: SegId, pub forward: bool } + +pub struct Node { + pub corner: PointI32, // lattice coords + pub out: [Option; 4], // outgoing directed segment per unit direction N,E,S,W +} + +pub struct Segment { + pub points: Vec, // lattice polyline; len >= 2; ring: points[0] == points[last] + pub start: Option, // None,None for rings (no junction anywhere on the loop) + pub end: Option, // start may == end (self-loop pinned at one node) + pub left: RegionId, // region on the left traversing forward (y-down convention) + pub right: RegionId, // either side may be OUTSIDE +} + +pub struct Contour(pub Vec); // cycle; a ring is a 1-element contour +pub struct Face { pub region: RegionId, pub contours: Vec } + +pub struct BoundaryGraph { + pub nodes: Vec, + pub segments: Vec, + pub faces: Vec, +} +``` + +Transient: `corner_mask: Vec` of `(W+1)·(H+1)` (4-bit edge mask + node flag), a corner-index → `NodeId` map, and visited bitsets for undirected edges (horizontal `W·(H+1)`, vertical `(W+1)·H`; closed-form edge ids, no hashing). + +"Left" in y-down screen space: heading E → left pixel above; heading S → left pixel to the east; heading W → below; heading N → to the west (4-entry lookup). + +### Extraction passes + +``` +Pass A — classify corners: O((W+1)(H+1)) + for each lattice corner: compute 4-bit edge mask from the 2x2 labels + (OUTSIDE for out-of-bounds); allocate a node id where popcount >= 3 + +Pass B — trace node-to-node segments: + for each node n, for each present direction d not yet visited: + walk unit edges, at each degree-2 corner continue via the unique other + present edge, until reaching a node; record polyline, start/end nodes, + left/right regions; register both directed views in the node tables + +Pass C — closed rings: + for each unvisited boundary edge (raster order): walk until returning to + the start corner; record as a Segment with start = end = None +``` + +Complexity O(W·H + E); every boundary edge is walked exactly once here and once more during face assembly. + +Corner cases handled: self-loop segments (a lobe outline returning to the same node — open for fitting purposes, endpoint pinned); whole-image single region (no nodes; Pass C finds the border rectangle as a ring against OUTSIDE); single-pixel regions. + +### Successor rule (region kept on the left) + +Given an incoming directed unit edge into corner `c`, tracing region R: + +``` +candidates in priority order: [turn_right(d_in), straight(d_in), turn_left(d_in)] +next = first d such that edge (c,d) is present AND left_pixel(c,d) == R +``` + +Right-first implements the pinch at checkerboard nodes (both right and straight can have R on the left there; right-first stays in the current quadrant, keeping contours simple). At 3/4-label junctions exactly one candidate qualifies. A u-turn is never needed. + +## 2. Face assembly + +Lift the successor rule to whole segments (two directed views per segment, 2-bit usage set): + +``` +for each directed segment s with region R on its left, not yet used: + follow successor at each end node until returning to s → one Contour of R +for each ring r: + left(r) gets [forward], right(r) gets [reversed] (skip OUTSIDE sides) +``` + +**Winding falls out automatically**: interior-always-on-left gives outer contours one orientation and hole contours the opposite. Therefore each region is emitted as a single `` whose `d` concatenates all its contours as subpaths — **no containment/nesting computation is needed**. `nonzero` (rather than `evenodd`) is robust to contours touching at pinch points. + +Debug invariants: every directed segment used exactly once; per-region i64 shoelace area (holes negative) equals the region's pixel count; the global sum equals W·H minus OUTSIDE pixels. + +## 3. Fitting — once per segment, endpoints pinned + +```rust +pub enum FittedGeom { + Polyline(Vec), // pixel / polygon backends + Beziers(Vec<[PointF64; 4]>), // spline backend; consecutive curves share endpoints +} + +pub trait SegmentFitter { + fn fit_open(&self, seg: &Segment) -> FittedSegment; // endpoints pinned to lattice nodes + fn fit_ring(&self, seg: &Segment) -> FittedSegment; // closed loop, no pinned point +} +``` + +Fitted results are cached in a `Vec` indexed by `SegId`; both adjacent faces reference the cache. Reversal happens at composition time and is exact, so shared geometry is bitwise identical — identical f64 values round identically under `path_precision`, and the emitted coordinate text matches on both sides. + +### Backends + +- **PixelFitter** — identity (lattice points as f64). Exact tessellation; the reference implementation for tests. +- **PolygonFitter** — symmetric open Douglas-Peucker with endpoints always kept (own ~40-line implementation). Deliberately **not** `PathSimplify::remove_staircase`: its directional outset would bias every shared boundary toward one of its two neighbors. Plain DP collapses 1-px staircases to the crack midline — centered between the two regions, which is what a mosaic wants. Self-loops split at the farthest point first. +- **SplineFitter** — open-path port of the visioncortex pipeline: + 1. DP(tau) first — staircases must be gone before corner detection, or every stair step reads as a 90° corner. + 2. Corner detection without wraparound; **both endpoints forced as corners** (junction nodes stay pinned). + 3. Open-path 4-point `subdivide_keep_corners` (no modular indexing; corner points are copied, never displaced). + 4. Open-path `find_splice_points` (inflections + accumulated-turn threshold); endpoints forced as splice points. + 5. Per slice: least-squares cubic fit. `SubdivideSmooth::fit_points_with_bezier` is already endpoint-exact (p1/p4 are taken from the input), so pinning survives fitting for free — but its internal error is hardcoded to 10.0, so vtracer-core calls `flo_curves::bezier::Curve::fit_from_points` directly with a configurable `max_error`, recursively splitting a slice at its farthest point when the budget is exceeded. +- **Rings** (islands with no junctions) are fitted once as *closed* paths using the closed-path machinery; the island uses the result forward as its outline, the enclosing region uses it reversed as a hole — same cached object, identical geometry. + +### Deviation budget and overlap tolerance + +Adjacent segments meet only at exact shared node coordinates — gaps are impossible. The remaining risk is a smoothed segment crossing a *different, non-adjacent* segment. Distinct boundary polylines are at least 1 px apart on the lattice, so keeping **maximum deviation < 0.5 px at every stage** (DP tau 0.5, bezier `max_error` 0.5, subdivision defaults well inside that) prevents crossings. This is not formally proven at the Bezier stage (error is sampled), so: + +- default: accept the pragmatic budget — a hairline overlap between two abutting fills is visually harmless and can never produce a gap worse than the budget; +- `--mosaic-strict`: sample each fitted segment (~8 samples/curve), and fall back to the DP polyline for any segment exceeding the budget — restoring the hard guarantee at the cost of local smoothness; +- the pixel backend gives bit-exact tessellation. + +## 4. Composition + +Per region, one ``; the `d` string is built contour by contour, emitting each oriented segment while skipping its first point (identical to the previous segment's last point). T-junction cracks are structurally impossible: segments terminate at nodes, no curve ever spans across one, and all incident curves end at the exact integer node coordinate. + +## 5. Paint-order independence and anti-aliasing + +Geometric coverage is a perfect partition, so rendering is paint-order independent — the defining property of mosaic mode. Antialiasing renderers still blend a hairline along abutting edges (each path is composited independently against the backdrop); that is a renderer artifact of any abutting vector art, not a geometry defect. Optional mitigations: + +1. `--seam-stroke` — stroke each path in its own fill color (`stroke-width` 0.5–1, round joins). Hides AA hairlines; reintroduces mild paint-order sensitivity (cosmetic, documented). +2. `shape-rendering="crispEdges"` output option — kills AA entirely (jaggy but seamless). +3. Stacked mode remains the AA-safe alternative (seams hidden under overdraw); mosaic gives true tessellation semantics — editable, no hidden geometry, order-free. + +## Label-map source + +`LabelMap::from_clusters(&ClustersView)` stamps dense region ids by iterating `clusters_output` → each cluster's pixel indices. It must **not** read `cluster_indices` directly — that maps pixels to base-level clusters, not the hierarchical output set. Unstamped (keyed/transparent) pixels become `OUTSIDE`. + +## Test plan + +Unit tests on hand-built const-grid label maps: + +- 1×1 and full-image single region → one ring against OUTSIDE +- vertical split `A|B` → 2 border junction nodes, 3 segments, correct left/right and windings +- T-junction `A A / B C` → interior degree-3 node; three faces share the exact node coordinate +- checkerboard `A B / B A` with merged diagonal labels → degree-4 node, pinch: two simple contours touching at the point, exact coverage +- nested islands A ⊃ B ⊃ C → rings only; shared cached geometry asserted +- border-touching region, 1-px corridor, single-pixel island, self-loop segment +- reversal exactness: the two SVG coordinate substrings for a shared segment are identical strings + +Property tests (proptest, random maps ≤ 12×12, ≤ 5 labels; label connectivity not required): + +- every undirected boundary edge appears in exactly two directed traversals +- per-region shoelace area == pixel count; total == W·H +- **PixelFitter round-trip: scanline-rasterize the composed faces → byte-identical label map** (the strongest end-to-end guarantee; catches winding/pinch/orientation bugs) +- Polygon/Spline: sampled max deviation ≤ budget; all segment endpoints exactly on node lattice coordinates + +Integration: run on the sample images; snapshot SVGs; rasterize with resvg and assert the color diff against the label map is confined to a ~1-px boundary band. diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md new file mode 100644 index 0000000..7b9ff70 --- /dev/null +++ b/docs/design/roadmap.md @@ -0,0 +1,19 @@ +# Roadmap and Verification + +## Milestones + +Each milestone leaves the repo building and tested. + +1. **Scaffold** — new workspace (`crates/vtracer-core`, `crates/vtracer`); IR + stage traits; port the existing stacked pipeline behind them, behavior-identical; golden-SVG snapshot tests over the sample images; CLI ported to clap 4 (range validation via `value_parser`, no more `panic!`). +2. **Writer + optimizer** — `VectorDoc` writer with relative/shorthand encoding, `QuantizePass`, `SimplifyPass`; byte-size benchmark vs the 0.6.x output; rasterize-and-diff regression (resvg) proving visual equivalence. +3. **Color fitting** — `FixedPalette` (OKLab nearest) + `AutoQuantize` + adjacent-region merge; `--palette` / `--palette-file` CLI. +4. **Mosaic** — boundary-graph module + open-polyline fitting (see [mosaic.md](mosaic.md)); `--hierarchical cutout` switched to the true mosaic; full unit/property test suite. +5. **Bindings** — pyo3 port, `vtracer-wasm`, the npm package under `nodejs/`; delete `webapp/`; CI covers crates.io + PyPI + npm releases. + +## Verification strategy + +- **Unit** — hand-crafted label maps for mosaic (checkerboard, T-junction, nested islands, border-touching, self-loops); fitter round-trips; writer encoding cases. +- **Snapshot** — golden SVGs for the sample images per preset/mode; asserted byte-size budget for the optimizer. +- **Property** (proptest) — mosaic invariants: every boundary edge used exactly twice; shoelace area == pixel counts; PixelFitter rasterize round-trip is byte-identical to the label map; fitted deviation ≤ 0.5 px budget; endpoints exact on lattice nodes. +- **Visual** — rasterize output with resvg; pixel-diff/SSIM against the input (thresholded) and against pre-rewrite output for stacked mode; mosaic diffs confined to a ~1-px boundary band. +- **Targets** — `cargo build --target wasm32-unknown-unknown -p vtracer-core -p vtracer-wasm`; `maturin build` with `python-binding`; `npm test` in `nodejs/`.