Image Tracing
An image layer is a design-time raster reference only — the manufactured panel is made exclusively of vector layers. The Convert to vector… action on the image inspector (see Inspectors → Image) runs a raster through a tracing pipeline and turns it into one or more PathLayers, mapped onto the fixed panel palette. This page documents that pipeline end to end.
Pipeline overview
HTMLImageElement
→ ImageData (trace-pipeline.ts: imageToImageData)
→ SVG string (trace-pipeline.ts: traceToSvg, via @image-tracer-ts/browser)
→ PathLayer[] (svg-to-path-layers.ts: svgToPathLayers) The pipeline is split across two files along a hard boundary: browser-only (needs a real <canvas> 2D context and the ImageData global) vs. pure/DOM-free (parses strings, directly unit-testable).
1. Raster → ImageData
trace-pipeline.ts's imageToImageData() downscales the source image so its longest side is at most 600px (MAX_TRACE_SIDE) — this keeps the live preview responsive regardless of the uploaded image's resolution — then draws it onto an offscreen <canvas>. Before drawing, the canvas is filled opaque white, so a transparent background in the source doesn't get sampled into the traced palette as a phantom color.
2. ImageData → SVG
traceToSvg() calls @image-tracer-ts/browser's ImageTracerBrowser.fromImageData(). Two color modes are available, toggled by the trace dialog's 3-color palette checkbox:
Palette mode (default,
usePalette: true) — quantizes directly to the 3 fixed panel colors (PALETTEfrom@zpd/core, converted to RGB) viaCreatePaletteMode.PALETTE.Free mode (
usePalette: false) — quantizes to a user-chosen 2–8 color count (numberOfColors) instead. Fills are matched to the panel palette afterward in step 3 regardless of which mode produced them.
Two more options tune the trace itself: min shape outline (drops small traced regions below this pixel threshold) and blur radius (a pre-blur pass to smooth noisy source rasters). Both are exposed as sliders in the trace dialog and debounced 250ms so dragging a slider doesn't re-trace on every intermediate value.
Note
@image-tracer-ts/browser reads the global ImageData at its own module top level, not just when called — and jsdom (the test environment) doesn't provide one. trace-pipeline.ts therefore imports it with a dynamic import() inside traceToSvg(), deferring that read until tracing actually runs. A static import would crash every test that transitively imports the dialogs/ folder, since import.meta.glob eager-loads all of dialogs/* (see Extension architecture).
3. SVG → PathLayer[]
svg-to-path-layers.ts's svgToPathLayers(svg, target) is pure — no canvas, no DOM — so it's tested directly against fixture SVG strings. It:
Parses the traced SVG's
<path d="..." fill="...">tags with a couple of targeted regexes rather than a full XML parser — safe here because the input is machine-generated by@image-tracer-ts(never arbitrary/user-authored markup) and its output shape is narrow (one<svg>, flat<path>children).Normalizes each path's
dattribute intoMOVE_TO/LINE_TO/CURVE_TO/CLOSE_PATHcommands viasvg-pathdata, withnormalizeHVZ(false)— required because the default rewrites aZclose-path into a plain line back to the start and drops theCLOSE_PATHcommand entirely, which would silently turn every traced region into an open path.Groups commands into subpaths (split at each
MOVE_TO). A region's compound subpaths — an outer boundary plus any hole/island contours — stay together on one layer (extraSubpaths) so the renderer'sevenoddfill rule (see Rendering & camera) keeps holes as holes instead of filling them in.Maps each path's
fillcolor to the nearest panel palette index (below), and drops any path whose fill can't be parsed at all (notablyfill="none").Scales every point from source-SVG space into the target mm rect — the source image layer's
{x, y, width, height}— using@image-tracer-ts's own dimension source: an explicitwidth/heightattribute on<svg>when present, falling back toviewBox.Caps the result at 300 layers (
MAX_TRACE_LAYERS) — a busy source image can otherwise produce thousands of tiny color regions, more than the editor stays usable with.
Nearest-palette-color matching (OKLab)
nearest-palette-color.ts maps an arbitrary traced fill color to the nearest of the 3 fixed panel colors by OKLab perceptual distance, not raw RGB distance. Plain RGB distance is a poor proxy for perceived closeness — a dark muted gold reads as "nearest black" under raw RGB purely because both are dark. OKLab distance is the same metric used by the upstream pgen tracer pipeline, so traced fills here match what that pipeline would have chosen for the same source.
The match runs in three steps:
parseColor()normalizes#rgb,#rrggbb,rgb()/rgba(), and a couple of named colors (black,white) into a canonical 6-digit hex, or returnsnullfor anything unparseable (includingfill="none", which is deliberately not in the named-color table).hexToOklab()converts sRGB → linear sRGB → CIE XYZ (D65) → OKLab via Björn Ottosson's OKLab matrices.nearestPaletteIndex()returns the palette entry with the smallest squared OKLab distance, ornullif the input color or every palette entry fails to parse.
Committing the result
Back in the trace dialog, Apply builds the traced layers against the source image layer's current geometry, then does the whole swap as one ctx.commit() (one undo entry): the source image layer is kept but marked hidden: true (it stays as a hidden design-time reference, not deleted), the traced PathLayers are inserted directly above it in the stack, and the first traced layer is selected.
Warning
The preview renders the traced SVG through an <img src="data:image/svg+xml;...">, never dangerouslySetInnerHTML/innerHTML. An <img> decodes SVG in "image mode", which never executes embedded scripts — so a hostile trace result (or a hostile source image feeding the tracer) can't run script inside the app.