New design

This commit is contained in:
Chris Tsang
2026-07-23 18:11:19 +01:00
parent fd9cdb08e6
commit 913d0ac7e0
5 changed files with 479 additions and 0 deletions
+46
View File
@@ -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 │
└──────────────────────────────┘
```
+153
View File
@@ -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<Layer>, // 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<u32>, // one label per pixel; u32::MAX = OUTSIDE (keyed/transparent)
pub paints: Vec<Paint>, // 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<Shape> }
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<Segmentation, Error>;
}
pub trait ColorFitter {
fn fit(&self, seg: &mut Segmentation);
}
pub trait CurveFitter {
fn fit_closed(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // stacked outlines, rings
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
pub enum Compositing { Stacked, Mosaic }
pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
impl Pipeline {
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> { /* 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<Color> }` — 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 `<g fill="…">` 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.
+71
View File
@@ -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<string>;
```
- 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.
+190
View File
@@ -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<u32>, 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<SegRef>; 4], // outgoing directed segment per unit direction N,E,S,W
}
pub struct Segment {
pub points: Vec<PointI32>, // lattice polyline; len >= 2; ring: points[0] == points[last]
pub start: Option<NodeId>, // None,None for rings (no junction anywhere on the loop)
pub end: Option<NodeId>, // 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<SegRef>); // cycle; a ring is a 1-element contour
pub struct Face { pub region: RegionId, pub contours: Vec<Contour> }
pub struct BoundaryGraph {
pub nodes: Vec<Node>,
pub segments: Vec<Segment>,
pub faces: Vec<Face>,
}
```
Transient: `corner_mask: Vec<u8>` 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 `<path fill-rule="nonzero">` 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<PointF64>), // 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<FittedSegment>` 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 `<path fill="{color}" fill-rule="nonzero">`; 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.
+19
View File
@@ -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/`.