zudo-panel-designer docs
GitHub repository

Type to search...

to open search from anywhere

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 (PALETTE from @zpd/core, converted to RGB) via CreatePaletteMode.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:

  1. 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).

  2. Normalizes each path's d attribute into MOVE_TO/LINE_TO/CURVE_TO/CLOSE_PATH commands via svg-pathdata, with normalizeHVZ(false) — required because the default rewrites a Z close-path into a plain line back to the start and drops the CLOSE_PATH command entirely, which would silently turn every traced region into an open path.

  3. 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's evenodd fill rule (see Rendering & camera) keeps holes as holes instead of filling them in.

  4. Maps each path's fill color to the nearest panel palette index (below), and drops any path whose fill can't be parsed at all (notably fill="none").

  5. 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 explicit width/height attribute on <svg> when present, falling back to viewBox.

  6. 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:

  1. parseColor() normalizes #rgb, #rrggbb, rgb()/rgba(), and a couple of named colors (black, white) into a canonical 6-digit hex, or returns null for anything unparseable (including fill="none", which is deliberately not in the named-color table).

  2. hexToOklab() converts sRGB → linear sRGB → CIE XYZ (D65) → OKLab via Björn Ottosson's OKLab matrices.

  3. nearestPaletteIndex() returns the palette entry with the smallest squared OKLab distance, or null if 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.