Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
# `converter/converter.ts` — public `convert()` orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the public-facing entry point of `@suaveplan/emblem-solver`.
It coordinates the entire pipeline — schema validation, grid validation,
grid analysis, ordering search, per-layer covering, cross-layer redundancy
elimination, SVG serialisation, and OpenTelemetry instrument recording —
into a single deterministic call: `convert(pixels, width, height,
options?)`.
## Features
- Zod-validated options with strict-mode unknown-key rejection.
- Grid validation (`width > 0`, `height > 0`, `pixels.length === w*h`).
- Two orderings paths: exhaustive permutation search when
`factorial(colourCount) ≤ maxPermutations`, otherwise four static
heuristics plus up to 1000 RNG-driven random samples.
- Injectable `rng` for deterministic auto-ordering on > 8-colour inputs.
- OTel counter + histogram recorded around the algorithmic core.
- Early-return for fully-transparent input — emits a viewBox-only SVG and
zero layers.
## Usage: Basic
```ts
import { convert, fromIndexedGrid } from "@suaveplan/emblem-solver";
const { pixels, width, height } = fromIndexedGrid(
[
[0, 1, 0],
[1, 1, 1],
[0, 1, 0],
],
new Map([[1, { r: 255, g: 0, b: 0 }]])
);
const result = convert(pixels, width, height);
console.log(result.totalRects, "rects;", result.svg.length, "bytes");
```
## Usage: Advanced
```ts
import { convert } from "@suaveplan/emblem-solver";
// Seeded RNG → deterministic auto-mode output on >8-colour inputs.
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const a = convert(pixels, w, h, {
layerOrder: "auto",
maxPermutations: 16,
rng: mulberry32(123),
});
const b = convert(pixels, w, h, {
layerOrder: "auto",
maxPermutations: 16,
rng: mulberry32(123),
});
console.assert(a.svg === b.svg, "deterministic round-trip");
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `convert(pixels, w, h, options?)` | fn | Returns `ConvertResult` |
| Throws | When |
|---|---|
| `EmblemSolverError(INVALID_OPTIONS)` | Zod schema rejects `options` |
| `EmblemSolverError(INVALID_GRID)` | non-positive dims or `pixels.length` mismatch |
## Implementation Notes
The orchestrator uses `Date.now()` for the duration histogram —
millisecond resolution is sufficient for OTel and the clock is universal
across Bun, Node, Deno, and browsers.
The fisher-Yates shuffle uses the injected `rng` directly so a seeded
source is byte-stable across runs. The heuristic branch is deliberate
about its sample budget: `min(maxPermutations - 4, 1000)` because the
four static strategies are evaluated first, and beyond 1000 samples the
upstream measurements showed diminishing returns. `eliminateRedundant`
mutates `bestLayers` in place inside `processOrdering`, which is safe
because `processOrdering` returns a freshly-allocated array per
invocation.

View File

@@ -0,0 +1,83 @@
# `errors/errors.ts` — typed error hierarchy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes the typed error surface for `@suaveplan/emblem-solver`.
It defines the stable, namespaced code registry `EMBLEM_SOLVER_CODES` and
the `EmblemSolverError` class that extends `SuaveplanError`. Every failure
path inside the package — schema validation, grid validation, IO checks —
funnels through this hierarchy so callers can pattern-match on `error.code`
to distinguish user input mistakes from defensive guards.
## Features
- Codes are produced by `defineCodes("EMBLEM_SOLVER", [...] as const)`,
yielding the namespaced strings `EMBLEM_SOLVER.INVALID_GRID`,
`EMBLEM_SOLVER.INVALID_OPTIONS`, and `EMBLEM_SOLVER.INVALID_PIXEL`.
- The registry is frozen — consumers can rely on identity equality across
the module.
- `EmblemSolverError` extends `SuaveplanError<EmblemSolverCode>`, so
`error.code` narrows the union and `instanceof EmblemSolverError`
discriminates the throw site.
- Supports the full `SuaveplanErrorOptions` bag: `cause`, `context`,
`traceId`, and `clock` for testing.
## Usage: Basic
```ts
import {
EMBLEM_SOLVER_CODES,
EmblemSolverError,
} from "@suaveplan/emblem-solver/errors";
if (badInput) {
throw new EmblemSolverError(
EMBLEM_SOLVER_CODES.INVALID_GRID,
"emblem-solver.invalid_grid"
);
}
```
## Usage: Advanced
```ts
try {
convert(pixels, w, h, options);
} catch (err) {
if (err instanceof EmblemSolverError) {
switch (err.code) {
case EMBLEM_SOLVER_CODES.INVALID_OPTIONS:
return reportInputProblem(err);
case EMBLEM_SOLVER_CODES.INVALID_GRID:
return reportShapeProblem(err);
case EMBLEM_SOLVER_CODES.INVALID_PIXEL:
return reportPixelProblem(err);
}
}
throw err;
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `EMBLEM_SOLVER_CODES` | const | Frozen `{ INVALID_GRID, INVALID_OPTIONS, INVALID_PIXEL }` map |
| `EmblemSolverCode` | type | Union of the namespaced strings |
| `EmblemSolverError` | class | `extends SuaveplanError<EmblemSolverCode>` |
## Implementation Notes
`defineCodes` runs at module load and `Object.freeze`s the registry,
guaranteeing identity stability — consumers that import via the root barrel
or the `./errors` subpath see the same object. The class constructor is
deliberately thin: it forwards to `SuaveplanError`'s `protected` constructor
and sets `name = "EmblemSolverError"` so stack frames disambiguate. The
`INVALID_PIXEL` code is reserved for future use (it appears in the code
registry so consumers can switch on it ahead of any algorithmic change that
introduces an unrepresentable RGBA value); the test suite exercises it via
the round-trip code coverage check.

View File

@@ -0,0 +1,73 @@
# `fixtures/fixtures.ts` — canonical regression grids
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the canonical test-fixture surface for the package. Each
exported function returns a deterministic `RGBA[]` grid with known
algorithmic properties — chessboards, donuts, crosses, T-shapes, stars,
flags, shields, and crests. Together they form the regression suite that
locks the solver's quality numbers in place: any change that regresses
the rect count on one of these fixtures fails the test gate.
## Features
- Twelve fixtures total: `chessboard8x8`, `cross3x3`, `cross6x6`,
`tShape`, `donut5x5`, `circle16`, `flagCrosses`, `multiIslandStripes`,
`shieldEmblem32`, `crest12x12`, `star`, plus the added
`nineColours` (>8 distinct colours, used to exercise the auto-ordering
heuristic branch).
- Every fixture is purely declarative — built from a `number[][]` palette
index map and `fromIndexedGrid`. No randomness, no `Date.now`, no
module-load side effects.
- Index `0` is reserved for transparency; every fixture follows that
convention.
## Usage: Basic
```ts
import { chessboard8x8 } from "@suaveplan/emblem-solver";
const { pixels, width, height } = chessboard8x8();
```
## Usage: Advanced
```ts
import { convert, crest12x12 } from "@suaveplan/emblem-solver";
const { pixels, width, height } = crest12x12();
const result = convert(pixels, width, height, { layerOrder: "auto" });
console.log(`crest compressed to ${result.totalRects} rects`);
```
## API Reference
| Symbol | Returns |
|---|---|
| `chessboard8x8()` | 8x8 alternating black/white grid |
| `cross3x3()` / `cross6x6()` | Plus-sign crosses |
| `tShape()` | 5x4 T-shape |
| `donut5x5()` | Frame with transparent centre |
| `circle16()` | 16-px pixel circle |
| `flagCrosses()` | 16x16 layered three-colour flag |
| `multiIslandStripes()` | 10x2 alternating two-colour stripes |
| `shieldEmblem32()` | 32x32 three-colour shield |
| `crest12x12()` | 12x12 four-colour crest |
| `star()` | Irregular star outline |
| `nineColours()` | 4x4 grid with nine distinct colours |
## Implementation Notes
The fixtures are exported as zero-argument functions rather than constants
so each test call builds a fresh pixel array — the solver does not
mutate its input, but defensive copies keep test isolation strong. The
`nineColours` grid exists specifically to push `factorial(colourCount) >
maxPermutations` so the heuristic + sampling branch in `convert()` is
exercised; without it that branch would be unreachable and coverage would
drop. Fixtures are excluded from the package's coverage threshold
(`coverage.exclude` in `vitest.config.ts`) because they are test
material, not production code.

View File

@@ -0,0 +1,75 @@
# `grid/analyse/analyse.ts` — colour groups, islands, and shape stats
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `analyseGrid`, the diagnostic phase of the conversion
pipeline. It walks an RGBA grid once, buckets opaque pixels by colour, runs
a 4-connected flood-fill on each bucket to recover the islands, and
computes per-colour `density` (`pixels / bbox area`) and `fragmentation`
(`islands / pixels`). The result feeds the layer-ordering heuristics and
the per-island cover strategy in the solver pipeline.
## Features
- Single linear pass over the grid to populate `opaquePixels` and the
colour buckets.
- 4-connected BFS flood-fill — diagonals are treated as separate islands,
matching the upstream reference and the convention used by every
pixel-art tool.
- Density and fragmentation are pure functions of the bucket and its
bounding box; the solver uses both to score the `island-aware` ordering.
- Output is structurally shared with the rest of the package via
`GridAnalysis` — solver kernels consume `colourGroups`/`colourAnalysis`
directly.
## Usage: Basic
```ts
import { analyseGrid } from "@suaveplan/emblem-solver";
const { colourGroups, colourAnalysis } = analyseGrid(pixels, w, h);
console.log("colours:", colourGroups.size);
```
## Usage: Advanced
```ts
import { analyseGrid } from "@suaveplan/emblem-solver";
function pickBackgroundColour(pixels, w, h) {
const a = analyseGrid(pixels, w, h);
let best = "";
let bestScore = -Infinity;
for (const [key, info] of a.colourAnalysis) {
const score = info.totalPixels * info.density;
if (score > bestScore) {
best = key;
bestScore = score;
}
}
return best;
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `analyseGrid(pixels, w, h)` | fn | Returns `GridAnalysis` |
## Implementation Notes
The flood-fill uses an explicit stack (`pop()`) rather than recursion to
avoid blowing the call stack on large emblems. We `add` to `visited`
*before* enqueueing the neighbour, which guarantees each pixel is touched
at most twice (once when discovered, once when its packed index is popped).
The neighbour set is computed as a small inline array of `-1`-sentinel
checks for the four cardinal directions; benchmarks against a `for (const
[dx,dy] …)` form showed the inline form measurably faster in V8 and Bun.
The `Map<string, Set<number>>` colour-group layout is intentional: the
string key is the canonical colour identity, while the packed-index `Set`
gives O(1) `has`/`add`/`delete` for the cover strategies.

View File

@@ -0,0 +1,85 @@
# `helpers/helpers.ts` — packing, keys, and grid primitives
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the shared kernel of utility functions used by the analyser,
the solver strategies, and the optimisers. It owns the canonical
representation of a pixel index (a packed `y * width + x` integer), the
canonical colour key (`"r,g,b"`), the `computeBounds` reduction over a
packed-pixel set, and the boolean-grid builders that drive the
maximal-rectangle sweep and per-layer covering.
## Features
- `pack` / `unpackX` / `unpackY` give the package a single, branch-free way
to convert between `(x, y)` and a single integer. Using a `Set<number>`
of packed indices is dramatically cheaper than a `Set<string>` of
`"x,y"`.
- `colourKey` / `parseKey` round-trip an RGBA without committing to alpha;
the solver does not need alpha for keying.
- `computeBounds` is the only place we touch unpack-loop machinery for
bounds computation; everywhere else delegates here.
- `isFullyAvailable`, `rectCoversAnyRequired`, and `countRequired` are the
three predicates the optimiser relies on; they share an early-exit
pattern that keeps the solver O(area × candidates) rather than
O(area² × candidates).
- `buildAvailableGrid` / `buildRequiredGrid` allocate the 2-D `boolean[][]`
masks used by the cover strategies.
## Usage: Basic
```ts
import { pack, unpackX, unpackY } from "@suaveplan/emblem-solver";
const w = 8;
const idx = pack(3, 2, w); // 19
unpackX(idx, w); // 3
unpackY(idx, w); // 2
```
## Usage: Advanced
```ts
import {
buildAvailableGrid,
buildRequiredGrid,
isFullyAvailable,
} from "@suaveplan/emblem-solver";
const opaque = new Set<number>([0, 1, 2, 3]);
const earlier = new Set<number>();
const avail = buildAvailableGrid(2, 2, opaque, earlier);
const req = buildRequiredGrid(opaque, 2, 2);
isFullyAvailable({ x: 0, y: 0, w: 2, h: 2 }, avail); // true
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `pack(x, y, w)` | fn | Row-major packed index |
| `unpackX(i, w)` | fn | Recover `x` |
| `unpackY(i, w)` | fn | Recover `y` |
| `colourKey(rgba)` | fn | `"r,g,b"` |
| `parseKey(str)` | fn | `RGBA` with `a:1` |
| `computeBounds(set, w)` | fn | Tight bounding `Rect` |
| `isFullyAvailable(rect, grid)` | fn | All cells `true`? |
| `rectCoversAnyRequired(rect, req)` | fn | Any overlap? |
| `countRequired(rect, req)` | fn | Overlap count |
| `buildAvailableGrid(w, h, opaque, earlier)` | fn | 2-D opaque mask minus earlier layers |
| `buildRequiredGrid(set, w, h)` | fn | 2-D mask from packed pixel set |
## Implementation Notes
The functions are deliberately monomorphic over `boolean[][]`/`number` for
predictable JIT performance. The bounding-box reduction uses `±Infinity`
sentinels so it works for any non-empty set. The available-grid builder
skips earlier-layer pixels in O(1) per packed index — important when the
solver iterates orderings exhaustively over a colour set. None of these
helpers allocate beyond the grids they explicitly build; the predicates
return as soon as their early-exit condition is met, so worst-case work is
bounded by `rect.w * rect.h`.

View File

@@ -0,0 +1,71 @@
# `io/parse/parse.ts` — RGBA buffer + indexed-grid IO
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the two import paths into the solver: `parseRGBABuffer`
turns a raw RGBA `Uint8Array` (e.g. the output of `<canvas>.getImageData`,
a `sharp` raw extraction, or a network frame) into the `RGBA[]` the
solver consumes, and `fromIndexedGrid` builds the same `RGBA[]` from a
`number[][]` colour-index map plus a palette. The latter is the path used
by tests and by tooling that authors emblems programmatically.
## Features
- Alpha quantisation at the standard half-byte threshold (`byte ≥ 128`
→ opaque). The solver does not reason about partial alpha; this matches
the upstream contract.
- Hard `EmblemSolverError(INVALID_GRID)` on dimension mismatch instead of
silently emitting fewer/more pixels.
- Indexed-grid form: index `0` is reserved for transparent, and any index
missing from the palette also produces a transparent pixel — a forgiving
convention that lets callers omit the background entry from their
palettes.
- Output is a fresh `RGBA[]` with no shared references to the input.
## Usage: Basic
```ts
import { parseRGBABuffer } from "@suaveplan/emblem-solver";
const pixels = parseRGBABuffer(new Uint8Array(w * h * 4), w, h);
```
## Usage: Advanced
```ts
import { fromIndexedGrid, convert } from "@suaveplan/emblem-solver";
const { pixels, width, height } = fromIndexedGrid(
[
[0, 1, 0],
[1, 1, 1],
[0, 1, 0],
],
new Map([[1, { r: 255, g: 0, b: 0 }]])
);
const result = convert(pixels, width, height);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `parseRGBABuffer(buf, w, h)` | fn | `RGBA[]` of length `w*h`, alpha quantised |
| `fromIndexedGrid(grid, palette)` | fn | `{ pixels, width, height }` |
Both throw `EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID)` on
non-positive dimensions or buffer-length mismatch.
## Implementation Notes
`parseRGBABuffer` does a tight 4-stride loop over the input, reading via
the optional-chaining default of `0` so a truncated buffer is rejected
before it can produce a malformed pixel. The `idx === 0` short-circuit in
`fromIndexedGrid` is deliberate: many palette authors reserve `0` for
transparency, so we evaluate this branch before the `Map.get` lookup. The
return shape `{ pixels, width, height }` matches the upstream contract
exactly so existing fixture code ports verbatim.

View File

@@ -0,0 +1,67 @@
# `optimizer/merge/merge.ts` — adjacent-rect merge pass
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `mergeAdjacentRects`, the post-processing pass that
combines pairs of edge-sharing same-extent rectangles into a single larger
rectangle, provided the merged span lies entirely inside the available
mask. It is the cheapest of the optimiser passes and runs after every
covering strategy in the layer pipeline.
## Features
- Merges along both axes: two rects with the same `y` and `h` merge
horizontally when adjacent; two rects with the same `x` and `w` merge
vertically when adjacent.
- Iterates until a full pass produces no merges, so chains of three or
more rects collapse to one in `O(n²)` per pass and `O(k · n²)` overall
for `k` passes (bounded by the rect count).
- Available-mask check (`isFullyAvailable`) guarantees the merged span
never paints transparent cells.
## Usage: Basic
```ts
import { mergeAdjacentRects } from "@suaveplan/emblem-solver";
const merged = mergeAdjacentRects(
[
{ x: 0, y: 0, w: 1, h: 1 },
{ x: 1, y: 0, w: 1, h: 1 },
],
fullyAvailableGrid
);
// merged === [{ x: 0, y: 0, w: 2, h: 1 }]
```
## Usage: Advanced
```ts
import { mergeAdjacentRects } from "@suaveplan/emblem-solver";
let rects = greedyCover;
rects = mergeAdjacentRects(rects, available);
rects = mergeAdjacentRects(rects, available); // idempotent — no further change
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `mergeAdjacentRects(rects, available)` | fn | Greedily merge edge-sharing same-extent rects |
## Implementation Notes
The inner `tryMerge` first tests the horizontal merge (same `y`, same
`h`, sharing an edge in `x`), then the vertical merge (same `x`, same
`w`, sharing an edge in `y`). Either form checks the combined rectangle
against `available` before accepting; this avoids painting through
transparent pixels. The outer loop terminates when a full pass produces
zero merges, so each call is idempotent. We deliberately do not try
*non*-same-extent merges here — that is the job of `optimiseOverlap`,
which can produce overlapping output and is therefore a strictly stronger
(but more expensive) operation.

View File

@@ -0,0 +1,73 @@
# `optimizer/overlap/overlap.ts` — overlap-based reductions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `optimiseOverlap`, the deepest cover-reduction pass in
the package. It looks for three patterns that the greedy cover cannot
exploit on its own: (1) pairs whose bounding-box union is fully available
and covers exactly the required pixels of the originals, (2) one rect
extended to subsume a neighbour while staying inside available, and (3)
triples of rects replaceable by two overlapping rects forming a cross,
T-junction, or L-shape.
## Features
- Pass 1 — pairwise bbox merge with required-set inclusion check.
- Pass 2 — `tryExtendToSubsume` grows one rect in the four cardinal
directions toward another, accepting only steps that improve coverage
of `bRequired` and keep the candidate inside `available`.
- Pass 3 — `tryCrossReplace` enumerates horizontal slice positions inside
the bbox of a 3-rect group, tests whether a single horizontal slice
plus a remainder vertical slice covers every required pixel, and emits
the 2-rect replacement when it does.
- All passes are iterative: a successful merge restarts the search so
the rect list converges to a local optimum.
## Usage: Basic
```ts
import { optimiseOverlap } from "@suaveplan/emblem-solver";
const reduced = optimiseOverlap(
greedyCover,
requiredGrid,
availableGrid,
rows,
cols
);
```
## Usage: Advanced
```ts
import { optimiseOverlap } from "@suaveplan/emblem-solver";
let rects = greedyCover;
do {
const next = optimiseOverlap(rects, required, available, rows, cols);
if (next.length === rects.length) break;
rects = next;
} while (true);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `optimiseOverlap(rects, required, available, rows, cols)` | fn | Apply pairwise, extend, and cross-replace reductions |
## Implementation Notes
The pass 1 inclusion check uses `Set<number>` of packed pixel indices so
the comparison is O(k) where `k` is the cell count of the union. Pass 2
limits itself to `max(rows, cols)` extension attempts so we cannot loop
forever on a pathological input. Pass 3 enumerates all `(hy, hh)` slice
positions inside the bbox; this is O(bboxH²) per triple, acceptable
because the rect list is small after pass 1. We deliberately do not
attempt 4-rect reductions — the upstream measurements show diminishing
returns beyond the cross/T/L family and the worst-case complexity scales
unfavourably.

View File

@@ -0,0 +1,61 @@
# `optimizer/redundancy/redundancy.ts` — cross-layer redundancy elimination
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `eliminateRedundant`, the final cross-layer optimisation
the converter runs after every layer's cover has been built. It removes
rectangles that produce no visible output: either because higher-z
rectangles of any colour overdraw them, or because sibling rectangles in
the same layer already cover the same cells. The pass operates in place
on the layer list and is the only pass that mutates `layer.rects`.
## Features
- Composites every layer into a per-cell `target` grid first (lower-z to
higher-z), then walks rect by rect deciding whether removal would
change the composited output.
- A rect is removed iff *every* one of its required cells is either
covered by a sibling rect at the same z-index or overdrawn by a higher-z
rect of any colour.
- In-place mutation via `Array.prototype.splice` — the converter calls
this once per ordering, so the in-place form keeps allocations bounded.
## Usage: Basic
```ts
import { eliminateRedundant } from "@suaveplan/emblem-solver";
eliminateRedundant(layers, width, height);
```
## Usage: Advanced
```ts
import { eliminateRedundant } from "@suaveplan/emblem-solver";
const before = layers.reduce((s, l) => s + l.rects.length, 0);
eliminateRedundant(layers, width, height);
const after = layers.reduce((s, l) => s + l.rects.length, 0);
console.log(`pruned ${before - after} redundant rects`);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `eliminateRedundant(layers, width, height)` | fn | In-place prune of fully-occluded rects |
## Implementation Notes
The compositing step builds a `(string | null)[][]` grid keyed by the
canonical colour key — using a string rather than a packed integer makes
the per-cell comparison a single `===` against the layer's fill string,
which V8 deduplicates aggressively. The two-pass design (composite first,
then walk rect by rect) avoids the quadratic blow-up of repeatedly
re-compositing after each removal. The removable indices are pushed in
ascending order and spliced in reverse, so we never break the index
correspondence mid-removal.

View File

@@ -0,0 +1,79 @@
# `schemas/schemas.ts` — Zod options schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the runtime validation surface for `@suaveplan/emblem-solver`.
It exposes `ConvertOptionsSchema`, a Zod schema that mirrors the static
`ConvertOptions` interface, applies sensible defaults, and rejects unknown
keys. `convert()` runs every caller-supplied options object through
`safeParse` so typos or out-of-range values fail loudly with
`EmblemSolverError(INVALID_OPTIONS)` rather than silently degrading the
solver.
## Features
- `layerOrder` defaults to `"auto"` and is validated as a closed enum over
the five supported strategies.
- `maxPermutations` defaults to `40320 = 8!` — the threshold beyond which
exhaustive permutation search is abandoned in favour of heuristics +
random sampling. The schema enforces `int().min(1)` so a zero or negative
value is rejected at the boundary.
- `rng` is an optional `() => number` validated with `z.custom<…>()`. It is
the only injection point for the package — supplying it makes the
heuristic + sampling path fully deterministic.
- `.strict()` rejects unknown keys, so misspelled options never silently
succeed.
## Usage: Basic
```ts
import { ConvertOptionsSchema } from "@suaveplan/emblem-solver/schemas";
const parsed = ConvertOptionsSchema.parse({ layerOrder: "area-desc" });
parsed.maxPermutations; // 40320 (default applied)
```
## Usage: Advanced
```ts
import { ConvertOptionsSchema } from "@suaveplan/emblem-solver/schemas";
import { EMBLEM_SOLVER_CODES, EmblemSolverError } from
"@suaveplan/emblem-solver/errors";
function safeOptions(raw: unknown) {
const r = ConvertOptionsSchema.safeParse(raw);
if (!r.success) {
throw new EmblemSolverError(
EMBLEM_SOLVER_CODES.INVALID_OPTIONS,
`bad options: ${r.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`
);
}
return r.data;
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `ConvertOptionsSchema` | `z.ZodObject` | Strict schema for `ConvertOptions` |
| `ConvertOptionsParsed` | type | Post-defaults shape of the parsed result |
## Implementation Notes
The schema lives in its own module so the rest of the package does not pay
the Zod cost at import time when consumers do not need validation (e.g.
hot-loop callers that constructed options at startup). `convert()` always
runs through this schema, but downstream tooling can call
`ConvertOptionsSchema.parse` ahead of time and pass the parsed result into
its own caching layer. We use `z.custom<() => number>` rather than
`z.function()` because Zod 4 deprecated runtime function validation in
favour of a custom predicate; this matches the catalog `zod ^4.x`. The
`rng` field stays optional (rather than defaulting to `Math.random`) so the
test harness can detect whether a caller injected a deterministic source.

View File

@@ -0,0 +1,72 @@
# `solver/greedy/greedy.ts` — deterministic greedy set-cover
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides the greedy selector that drives the primary covering
strategy. `enhancedGreedyCover` picks rectangles from a candidate pool
using a deterministic three-key score (covered count, efficiency, centroid
proximity). `perIslandCover` runs the same selector independently on each
4-connected island so the algorithm never wastes budget on rectangles that
span the transparent gap between two disjoint islands.
## Features
- Three-key score: `covered * 1000 + efficiency * 100 + proximity * 10`.
The factors are deliberately spaced so a more-covering rect always beats
a more-efficient one, and ties are broken by centroid proximity.
- Falls back to emitting `1×1` rectangles for any residual uncovered
pixels — guarantees the cover is complete even when no candidate
intersects the residual.
- `perIslandCover` filters the candidate pool per island so the selector
never considers a spanning rect; this halves selection work on
multi-island colours.
## Usage: Basic
```ts
import {
dualAxisMaximalRects,
enhancedGreedyCover,
} from "@suaveplan/emblem-solver/solver";
const candidates = dualAxisMaximalRects(available, rows, cols);
const cover = enhancedGreedyCover(candidates, required, cols, rows);
```
## Usage: Advanced
```ts
import {
analyseGrid,
perIslandCover,
dualAxisMaximalRects,
} from "@suaveplan/emblem-solver";
const a = analyseGrid(pixels, w, h);
const info = a.colourAnalysis.get("255,0,0")!;
const candidates = dualAxisMaximalRects(available, h, w);
const cover = perIslandCover(info.islands, candidates, w, h);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `enhancedGreedyCover(candidates, required, w, h)` | fn | Greedy cover with three-key score |
| `perIslandCover(islands, candidates, w, h)` | fn | Independent cover per island |
## Implementation Notes
The centroid term is computed against the running set of *uncovered*
required pixels, not the original set — this keeps selections clustered
even as the residual shrinks. We use a precomputed `maxDist =
sqrt(w² + h²)` so the proximity term is normalised to `[0, 1]`; without
this, the third tiebreaker could dominate the second on large grids.
`perIslandCover` materialises a fresh `boolean[][]` per island; this is
acceptable because the typical island count is small (≤ 10 on the
upstream fixtures) and the allocation cost is dominated by the cover work
inside.

View File

@@ -0,0 +1,67 @@
# `solver/histogram/histogram.ts` — dual-axis maximal rectangles
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module enumerates every distinct maximal-rectangle candidate inside an
opaque-pixel grid. It is the candidate pool consumed by
`enhancedGreedyCover` and the layer-pipeline. The implementation runs the
classic "largest rectangle in histogram" stack sweep on both the original
grid and its transpose, deduplicates by `(x, y, w, h)` key, and returns the
union. Sweeping both axes is what makes the cover competitive on
tall/narrow patterns the single-axis sweep misses.
## Features
- O(rows × cols) histogram sweep per axis, O(rows × cols) transposition,
O(unique-rects) merge into the final set.
- Emits every nested maximal rectangle, not just the largest — the greedy
cover needs the full pool to make sensible trade-offs.
- No allocation per cell beyond the heights table; the rect set uses string
keys so identical rects from both axes collapse to one.
## Usage: Basic
```ts
import { dualAxisMaximalRects } from "@suaveplan/emblem-solver/solver";
const available = [
[true, true, false],
[true, true, false],
];
const rects = dualAxisMaximalRects(available, 2, 3);
```
## Usage: Advanced
```ts
import {
dualAxisMaximalRects,
enhancedGreedyCover,
} from "@suaveplan/emblem-solver/solver";
const candidates = dualAxisMaximalRects(available, rows, cols);
candidates.sort((a, b) => b.w * b.h - a.w * a.h);
const cover = enhancedGreedyCover(candidates, required, cols, rows);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `dualAxisMaximalRects(available, rows, cols)` | fn | Deduplicated maximal rects across both sweep axes |
## Implementation Notes
The internal `histogramSweep` builds a column-by-column heights table and
then walks each row with a monotonic stack. When the stack pops, every
height ≥ the popped column's height yields a distinct maximal rect of
width `x - leftBound` and height `rectH`. We emit *every* nested height so
the greedy cover can pick a smaller-but-better-positioned rect when the
maximal-tall one would force an awkward residual. The transposed sweep is
implemented as a fresh `boolean[][]` rather than an index permutation
because the inner loop's access pattern is row-major and we want to keep
cache locality on the hot path.

View File

@@ -0,0 +1,84 @@
# `solver/layer/layer.ts` — multi-strategy per-layer cover
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `processLayerMultiStrategy`, the function the converter
calls once per layer in the current ordering. It runs every covering
strategy the package implements (dual-axis histogram + greedy, per-island
cover, strip decomposition, adjacent-rect merge) and returns the result
with the fewest rectangles. Running every strategy is what makes the
upstream algorithm robust — no single strategy wins on every emblem.
## Features
- Strategy A (dual-axis histogram → greedy → overlap optimise) is always
evaluated; it is the workhorse.
- Strategy B (per-island cover) only runs when the colour has more than
one island — pure optimisation, since a single-island colour would
produce the same answer.
- Strategy C (strip decomposition) is only adopted when the strips fully
cover the required pixels; the layer pipeline does not stitch in a
partial strip cover.
- Strategy D (adjacent-rect merge) runs against the best-so-far so we
never reject a free merge.
- The final winner is the smallest of all four (or three when B is
skipped).
## Usage: Basic
```ts
import {
analyseGrid,
processLayerMultiStrategy,
} from "@suaveplan/emblem-solver";
const a = analyseGrid(pixels, w, h);
const ownPixels = a.colourGroups.get("255,0,0")!;
const rects = processLayerMultiStrategy(
"255,0,0",
ownPixels,
a,
new Set<number>()
);
```
## Usage: Advanced
```ts
import { processLayerMultiStrategy } from "@suaveplan/emblem-solver";
const earlier = new Set<number>();
for (const key of orderedKeys) {
const ownPixels = analysis.colourGroups.get(key)!;
const rects = processLayerMultiStrategy(
key,
ownPixels,
analysis,
earlier
);
layers.push({ key, rects /* ... */ });
for (const p of ownPixels) earlier.add(p);
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `processLayerMultiStrategy(key, ownPixels, analysis, earlier)` | fn | Smallest rect cover across all strategies |
## Implementation Notes
The strip strategy only contributes when it fully covers the required
mask. We compute coverage by intersecting each valid strip with the
`required` grid (via `pack`) and comparing the resulting count to a
single pre-tallied `totalRequired`. The adjacent-rect merge pass runs
*after* the strategies have produced their winners; running it earlier
would force a merge on results that would have been pruned anyway. The
final reduce uses a strictly-less-than comparison (`a.length <= b.length`)
so the first result wins on ties — this preserves deterministic ordering
across runs.

View File

@@ -0,0 +1,74 @@
# `solver/ordering/ordering.ts` — layer-ordering search primitives
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides the layer-ordering machinery the converter consults
when it has to decide which colour sits on the bottom of the z-stack and
which paints on top. `getStaticOrdering` returns one deterministic
ordering per named strategy (`area-desc`, `area-asc`, `island-aware`,
`perimeter-desc`). `permutations` lazily yields every n! ordering via
Heap's algorithm — used by `convert()` in `auto` mode when
`factorial(colourCount) ≤ maxPermutations`. `factorial` is the standard
mathematical factorial used to pick between the exhaustive and heuristic
branches.
## Features
- Four named static strategies, each O(n log n) over the colour count.
- `permutations` is a generator yielding fresh arrays — safe to break out
of early without materialising the full n! list.
- `factorial` short-circuits at `n ≤ 1` so it handles the trivial
edge cases without recursion.
## Usage: Basic
```ts
import {
getStaticOrdering,
permutations,
factorial,
} from "@suaveplan/emblem-solver/solver";
const order = getStaticOrdering(analysis, "area-desc");
factorial(5); // 120
```
## Usage: Advanced
```ts
import { permutations } from "@suaveplan/emblem-solver/solver";
let best = Infinity;
let bestPerm: string[] | undefined;
for (const perm of permutations(["r", "g", "b"])) {
const score = scoreOrdering(perm);
if (score < best) {
best = score;
bestPerm = [...perm];
}
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `getStaticOrdering(analysis, strategy)` | fn | Sorted colour keys for a named strategy |
| `permutations<T>(arr)` | gen | Yields every permutation (Heap's algorithm) |
| `factorial(n)` | fn | `factorial(0) === factorial(1) === 1` |
## Implementation Notes
`islandAwareScore` mixes four signals: coverage (how much of the grid the
colour occupies), occlusion benefit (the visual mass it removes from
higher layers), island penalty (more islands ⇒ more rectangles needed),
and hole ratio (bbox area / pixel count — large hole ratios suggest the
colour sits below another). The coefficients (`2.0`, `3.0`, `1.5`, `0.5`)
are inherited verbatim from the upstream implementation; they were tuned
empirically against the canonical sprite fixtures. `permutations` uses
Heap's algorithm with an explicit counter array rather than recursion;
this keeps stack pressure bounded for the worst-case 8! enumeration.

View File

@@ -0,0 +1,64 @@
# `solver/strip/strip.ts` — horizontal/vertical strip decomposition
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module implements `stripDecomposition`, a complementary cover strategy
that ignores the maximal-rect candidate pool entirely. It finds maximal
horizontal runs of required pixels, merges vertically-adjacent runs that
share both `x` and `length`, and runs the transposed variant (vertical runs
merged horizontally). The smaller of the two orientations wins. This
strategy is what gives `convert()` the right answer on tall thin shapes
that the histogram + greedy combo fragments.
## Features
- O(rows × cols) per axis; tail-call-free `while` loops.
- Two orientations evaluated, smaller wins — deterministic.
- Produces non-overlapping rects (unlike the overlap optimiser); the layer
pipeline only adopts the strip result if it covers the entire required
mask, otherwise it falls back to the greedy cover.
## Usage: Basic
```ts
import { stripDecomposition } from "@suaveplan/emblem-solver/solver";
const required = [
[true, true, true],
[true, true, true],
];
const rects = stripDecomposition(required, 2, 3);
```
## Usage: Advanced
```ts
import {
stripDecomposition,
isFullyAvailable,
} from "@suaveplan/emblem-solver";
const strips = stripDecomposition(required, rows, cols);
const valid = strips.filter((r) => isFullyAvailable(r, available));
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `stripDecomposition(required, rows, cols)` | fn | Smaller of horizontal-strips / vertical-strips |
## Implementation Notes
`mergeRuns` sorts runs by their primary coordinate, then their length, then
their secondary coordinate. The merge loop walks the sorted list once and
collapses consecutive entries that share `(x, length)` (horizontal) or
`(y, length)` (vertical) into a single taller/wider rect. Because the sort
is stable on JavaScript's `Array.prototype.sort` and we break on a length
mismatch, the merge terminates in O(n). The dual-orientation evaluation
ensures we never make the cover worse by picking the wrong axis — the
chess-board test exercises this branch heavily.

View File

@@ -0,0 +1,61 @@
# `svg/build/build.ts` — SVG serialiser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `buildSVG`, the deterministic SVG serialiser the
converter uses to produce its final `<svg>` string. It accepts a list of
`ColourLayer` objects (already z-stacked) and emits one `<rect>` per
rectangle in z-ascending order. The output is byte-stable across calls
given the same layers — the converter relies on this for the deterministic
`auto`-ordering round-trip test.
## Features
- `viewBox="0 0 W H"` matches the input grid dimensions exactly so the
SVG scales 1:1 with the source pixel grid.
- `shape-rendering="crispEdges"` instructs the renderer not to
anti-alias rect edges; this is essential for pixel-art emblems where
smoothing would blur the silhouette.
- Fills use the `rgb(r,g,b)` form (rather than hex) so they round-trip
identically across renderers that disagree about uppercase hex.
- Two-space indent on each `<rect>` line; trailing `</svg>` newline-free
so the output is `Buffer.from(out).length` bytes regardless of platform
line endings.
## Usage: Basic
```ts
import { buildSVG } from "@suaveplan/emblem-solver";
const svg = buildSVG(layers, width, height);
```
## Usage: Advanced
```ts
import { buildSVG } from "@suaveplan/emblem-solver";
const reordered = layers.map((l, i) => ({ ...l, zIndex: i }));
const svg = buildSVG(reordered, width, height);
console.log(svg.length, "bytes");
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `buildSVG(layers, w, h)` | fn | Serialise z-stacked layers to an SVG string |
## Implementation Notes
The function sorts a copy of the layer list (never the caller's reference)
so callers can pass `result.layers` without losing the original ordering
metadata. The string is assembled via an array `join("\n")` rather than
incremental concatenation — V8 benchmarks show this is faster for the
sub-100-rect outputs typical of emblems. We deliberately do not gzip or
base64 here; the package's job is to produce the canonical SVG bytes and
let downstream tooling apply transport-level compression.

View File

@@ -0,0 +1,69 @@
# `telemetry/telemetry.ts` — OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instruments emitted by `convert()`. It
exposes a counter (`emblem_solver.convert.total`) and a histogram
(`emblem_solver.convert.duration_ms`), both lazily initialised on first
use so importing the package does not register instruments unless they
are actually needed.
## Features
- Lazy registration — no instruments are created at module load; the
first call to `getConvertCounter()` or `getConvertDuration()` reaches
into `metrics.getMeter("@suaveplan/emblem-solver")` and caches the
result.
- Instruments are independent objects so a downstream OTel SDK can
configure views per instrument.
- Test helpers (`__resetTelemetryForTests`, `__peekTelemetryForTests`)
let the test suite assert lazy-registration behaviour without forcing
the production code to expose internals.
- Histogram unit is `ms`; emitting an OTel view that converts to seconds
is the consumer's responsibility.
## Usage: Basic
```ts
import {
getConvertCounter,
getConvertDuration,
} from "@suaveplan/emblem-solver";
getConvertCounter().add(1);
getConvertDuration().record(12.5);
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { getConvertCounter } from "@suaveplan/emblem-solver";
metrics.setGlobalMeterProvider(myProvider);
// the next call lazily creates the counter against `myProvider`'s meter:
getConvertCounter().add(1, { layerOrder: "auto" });
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `getConvertCounter()` | fn | Lazy `Counter` for `emblem_solver.convert.total` |
| `getConvertDuration()` | fn | Lazy `Histogram` for `emblem_solver.convert.duration_ms` |
| `__resetTelemetryForTests()` | fn | Test-only: drop cached instruments |
| `__peekTelemetryForTests()` | fn | Test-only: inspect cached instruments |
## Implementation Notes
The `metrics.getMeter` call is deferred until first use so that a host
that swaps the global meter provider after import still sees its
provider's instruments. Without this, instruments would be bound to the
no-op default and silently discard recordings. The test helpers are
exported from the module (rather than placed in a separate `__internal`
file) because they need to reach the module-scope `let` bindings; keeping
them here avoids exposing those bindings through a different surface.

View File

@@ -0,0 +1,92 @@
# `types/types.ts` — public type contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single source of truth for the public type surface of
`@suaveplan/emblem-solver`. It defines the immutable data shapes that flow
through the entire conversion pipeline (`RGBA`, `Rect`, `Island`,
`ColourAnalysis`, `ColourLayer`, `ConvertOptions`, `ConvertResult`,
`GridAnalysis`, `LayerOrderStrategy`, `Rng`), so every consumer can pin
strongly-typed bindings without depending on internal implementation
modules.
Types live in `types/` (per CLAUDE.md feature-layout exemption) because
they are shared across grid analysis, the solver kernels, the optimisers,
and the SVG emitter. Keeping them in one file avoids cycles and lets
TypeScript inline them aggressively.
## Features
- Pure data; zero runtime, zero side effects.
- `RGBA.a` is narrowed to `0 | 1` — pixels are opaque or transparent; the
upstream solver never reasoned about partial alpha and we preserve that
contract.
- `Rect` uses `{x, y, w, h}` (not `width/height`) to match the upstream
on-the-wire layout.
- `Island.pixels` is a `Set<number>` of packed indices to keep flood-fill
inexpensive.
- `ConvertOptions.rng` is the injectable random source used by the
`auto`-ordering heuristic branch.
## Usage: Basic
```ts
import type { RGBA, ConvertOptions } from "@suaveplan/emblem-solver";
const px: RGBA = { r: 255, g: 0, b: 0, a: 1 };
const opts: ConvertOptions = { layerOrder: "area-desc" };
```
## Usage: Advanced
```ts
import type {
ColourAnalysis,
ColourLayer,
GridAnalysis,
Rng,
} from "@suaveplan/emblem-solver";
function pickBottomLayer(analysis: GridAnalysis): ColourAnalysis | undefined {
return [...analysis.colourAnalysis.values()].sort(
(a, b) => b.totalPixels - a.totalPixels
)[0];
}
const seeded: Rng = (() => {
let s = 1;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 0x1_0000_0000;
};
})();
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `RGBA` | interface | `{ r, g, b, a: 0 \| 1 }` |
| `Rect` | interface | `{ x, y, w, h }` |
| `Island` | interface | `{ pixels: Set<number>, bounds, size }` |
| `ColourAnalysis` | interface | Per-colour analysis (density, fragmentation, islands) |
| `ColourLayer` | interface | A z-stacked layer with `rects` + `analysis` |
| `LayerOrderStrategy` | union | `"auto" \| "area-desc" \| "area-asc" \| "island-aware" \| "perimeter-desc"` |
| `Rng` | type | `() => number` in `[0, 1)` |
| `ConvertOptions` | interface | Public input to `convert()` |
| `ConvertResult` | interface | Public output of `convert()` |
| `GridAnalysis` | interface | Diagnostic data returned by `analyseGrid` |
## Implementation Notes
Every interface marked `readonly` enforces the immutability contract at the
type level — solver internals deliberately mutate the working sets they
allocate; the public types do not advertise mutability. `ColourLayer` keeps
`rects` mutable because the cross-layer redundancy eliminator splices it
in place. The types deliberately avoid Zod schemas at this layer; the
runtime schema lives in `schemas/` so consumers who do not need validation
do not pay the cost of importing Zod.