Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
# Design: `@suaveplan/emblem-solver`
> Non-normative companion to `specs/emblem-solver/spec.md`. Architecture,
> algorithm sketches, file layout, and rationale. The requirements live in
> the spec delta.
## Goals
- Pure, universal-tier TypeScript. Zero browser / Node / React APIs in the
runtime hot path.
- Single public entry point `convert(pixels, width, height, options?)` that
decomposes an opaque-pixel grid into the minimum number of axis-aligned
rectangles, layered with z-index, and serialised to a `<svg>` string.
- Deterministic given inputs + an injected `Rng` (defaults to `Math.random`).
- Tree-shakeable: subpath exports for `./types`, `./errors`, `./grid`,
`./io`, `./svg`.
## Tier discipline
Universal means no `window`, `document`, `navigator`, `fs`, `path`,
`process`. All inputs are JS values (`RGBA[]`, `Uint8Array`, `number[][]`);
output is a `string` + plain data structures. No DOM is created.
## Public surface
Exported from the package root (`@suaveplan/emblem-solver`):
| Symbol | Kind | Purpose |
|---|---|---|
| `convert` | fn | Decompose grid → `ConvertResult` |
| `analyseGrid` | fn | Diagnostic colour/island analysis |
| `parseRGBABuffer` | fn | `Uint8Array` (RGBA bytes) → `RGBA[]` |
| `fromIndexedGrid` | fn | `number[][]` + palette → grid |
| `buildSVG` | fn | `ColourLayer[]` → `<svg>` string |
| `EmblemSolverError` | class | Base error |
| `EMBLEM_SOLVER_CODES` | const | `defineCodes("EMBLEM_SOLVER", …)` |
| `ConvertOptionsSchema` | const | Zod schema for `ConvertOptions` |
| `RGBA`, `Rect`, `Island`, `ColourAnalysis`, `ColourLayer`, `ConvertOptions`, `ConvertResult`, `GridAnalysis` | types | Public type surface |
Subpath exports: `./types`, `./errors`, `./schemas`, `./grid`, `./io`,
`./svg`, `./solver` (for advanced consumers wiring custom orderings).
## File layout
```text
packages/media/image/emblem-solver/
package.json # 0.1.0, sideEffects: false
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts # root barrel (named re-exports)
types/
types.ts # RGBA, Rect, Island, ColourAnalysis, …
types.md
errors/
errors.ts # defineCodes + EmblemSolverError
errors.test.ts
errors.md
index.ts
schemas/
schemas.ts # Zod schemas (ConvertOptionsSchema)
schemas.test.ts
schemas.md
index.ts
helpers/
helpers.ts # pack/unpack/keys/grids/bounds
helpers.test.ts
helpers.md
index.ts
grid/
analyse/
analyse.ts # analyseGrid + flood-fill islands
analyse.test.ts
analyse.md
index.ts
index.ts
io/
parse/
parse.ts # parseRGBABuffer + fromIndexedGrid
parse.test.ts
parse.md
index.ts
index.ts
solver/
greedy/
greedy.ts # enhancedGreedyCover + perIslandCover
greedy.test.ts
greedy.md
index.ts
histogram/
histogram.ts # dualAxisMaximalRects
histogram.test.ts
histogram.md
index.ts
strip/
strip.ts # stripDecomposition (h + v)
strip.test.ts
strip.md
index.ts
ordering/
ordering.ts # permutations + factorial + getStaticOrdering
ordering.test.ts
ordering.md
index.ts
layer/
layer.ts # processLayerMultiStrategy
layer.test.ts
layer.md
index.ts
index.ts
optimizer/
merge/
merge.ts # mergeAdjacentRects
merge.test.ts
merge.md
index.ts
overlap/
overlap.ts # bboxMerge, extendToSubsume, crossReplace
overlap.test.ts
overlap.md
index.ts
redundancy/
redundancy.ts # eliminateRedundant across layers
redundancy.test.ts
redundancy.md
index.ts
index.ts
svg/
build/
build.ts # buildSVG
build.test.ts
build.md
index.ts
index.ts
telemetry/
telemetry.ts # lazy OTel counter + histogram
telemetry.test.ts
telemetry.md
index.ts
fixtures/
fixtures.ts # canonical test grids
fixtures.md
index.ts
converter/
converter.ts # public convert() orchestrator
converter.test.ts # the canonical regression suite
converter.md
index.ts
```
Each non-`.test.ts` `.ts` has a sibling `.md` ≥ 200 words.
## Algorithm overview
Upstream's pipeline, preserved verbatim:
1. **`analyseGrid`** — Walk the RGBA grid. For each opaque pixel
(`a === 1`), record it in `opaquePixels` and bucket by colour key
(`"r,g,b"`). For each colour bucket, flood-fill with 4-connected BFS to
recover `Island[]`, compute bounds, density (`pixels / bbox area`), and
fragmentation (`islands / pixels`).
2. **Ordering search** — pick a z-stacking order over the colours.
- `auto` mode: if `colourCount! ≤ maxPermutations` (default 40320 = 8!),
enumerate all permutations via Heap's algorithm. Otherwise evaluate the
four static heuristics plus up to 1000 random shuffles (using the
injected `Rng`).
- Explicit modes: `area-desc`, `area-asc`, `island-aware`,
`perimeter-desc`.
3. **Per-ordering layer build** — for each colour in the ordering, build an
`available` grid (opaque pixels minus earlier-layer pixels) and a
`required` grid (this colour's pixels). Run four covering strategies:
- **A. Dual-axis histogram → greedy → overlap optimise**:
`dualAxisMaximalRects` enumerates every maximal rect in `available` by
sweeping both axes via the classic "largest rectangle in histogram"
stack algorithm; `enhancedGreedyCover` selects rects with a three-key
score (covered count, efficiency, centroid proximity);
`optimiseOverlap` pairwise-merges bboxes, extends rects to subsume
neighbours, and replaces 3 rects with 2 cross/T/L patterns.
- **B. Per-island cover** (only if >1 island): same greedy, run
independently on each island so spanning rects between disconnected
islands aren't considered.
- **C. Strip decomposition**: maximal horizontal runs merged vertically
when same-extent + adjacent, and the transposed variant; keep whichever
produces fewer rects, but only adopt it if the strips fully cover the
required pixels.
- **D. Adjacent-rect merge pass** on the best result so far.
Best = fewest rects.
4. **Redundancy elimination across layers** — after all layers are built,
composite them top-down into a per-pixel colour grid and remove any rect
that is fully occluded by higher-z rects of any colour or by other rects
in the same layer.
5. **`buildSVG`** — sort layers by `zIndex` ascending and emit
`<svg viewBox="0 0 W H" shape-rendering="crispEdges">…</svg>` with one
`<rect>` per layer-rect, fill = `rgb(r,g,b)`. Bottom layer is drawn
first, so layer 0 is the background and higher z-indexes overdraw.
## Options + validation
```ts
import z from "zod";
export const ConvertOptionsSchema = z
.object({
layerOrder: z
.enum(["auto", "area-desc", "area-asc", "island-aware", "perimeter-desc"])
.default("auto"),
maxPermutations: z.number().int().min(1).default(40320),
rng: z.custom<() => number>((v) => typeof v === "function").optional(),
})
.strict();
```
`convert` calls `ConvertOptionsSchema.safeParse`; failure → `EmblemSolverError(INVALID_OPTIONS, …)`.
Invalid grid dimensions (`width ≤ 0`, `height ≤ 0`, `pixels.length !== width*height`) → `INVALID_GRID`.
## Error codes
```ts
defineCodes("EMBLEM_SOLVER", [
"INVALID_GRID",
"INVALID_OPTIONS",
"INVALID_PIXEL",
] as const);
```
Each code is reachable from a test.
## OTel instrumentation
Lazy-initialised on first `convert()` call (no module-load side effects):
- Counter `emblem_solver.convert.total`
- Histogram `emblem_solver.convert.duration_ms` (unit: `ms`)
## Determinism
The only random site upstream is the `auto`-mode shuffle for >8 colours. We
inject `rng: () => number` (default `Math.random`); tests pass a seeded
Mulberry32 to verify the heuristic branch is reachable + stable.
## Tests
- Port the upstream regression suite verbatim (chessboard 8×8 ≤ 33,
cross 3×3 ≤ 2, cross 6×6 ≤ 2, T-shape ≤ 2, donut 5×5 ≤ 4, circle 16 ≤ 5,
star ratio > 3, flag crosses ≤ 5, multi-island stripes ≤ 3, shield 32×32
ratio > 50, crest 12×12 ratio > 3).
- Edge cases: solid 4×4 = 1, all-transparent = 0, single pixel = 1, SVG
structure contains required tags, `auto` evaluates 3! = 6 orderings on
the 3-colour flag, `auto` beats any single static strategy on the crest.
- Property-based via `fast-check`: any random solid-colour grid satisfies
"every required pixel is covered by at least one rect in its layer and
no rect leaks outside the opaque mask"; SVG output round-trips the
composited layers back to the original pixel grid.
- Schema tests: every error code is reachable; Zod refines `maxPermutations
≥ 1`; unknown options rejected by `.strict()`.
## Out of scope
- PNG decoding: callers feed `RGBA[]` directly or via `parseRGBABuffer`.
- Path-based SVG (curves / `<path d>`): emblems are pixel-grid → axis-aligned
rects only.
- Multi-frame animation.

View File

@@ -0,0 +1,104 @@
# Proposal: Enterprise-grade migration of `@suaveplan/emblem-solver`
- **Change ID**: enterprise-emblem-solver
- **Package**: `@suaveplan/emblem-solver`
- **Status**: PROPOSED
- **Tier**: universal (zero browser/Node/React runtime deps)
- **Category / plugin**: `media` / `image`
- **Target path**: `packages/media/image/emblem-solver/`
- **Design**: ./design.md
- **Spec delta**: ./specs/emblem-solver/spec.md
## Summary
Migrate `@suaveplan/emblem-solver` from upstream
(`/mnt/s/web/suaveplan/packages/universal/emblem-solver`, v1.0.3) onto
`genesis` at enterprise quality, version `0.1.0`. The package decomposes an
RGBA pixel grid (pixel-art sprite emblem) into the minimum number of
axis-aligned coloured rectangles and emits a compact SVG by layering them
with z-index so that overlap optimisations reduce the rect count below what
any non-overlapping cover can achieve.
The actual upstream implementation is a layered, multi-strategy solver — not
a single-pass scan-line — and the migration preserves that algorithmic depth
verbatim while adding Zod input validation, an `EMBLEM_SOLVER.*` error code
registry, injectable `Rng`, and OTel instrumentation.
## Why
Genesis ships no emblem decomposition tool. Downstream consumers
(`packer-browser`, future game-server emblem packing, future client-side
guild/clan badge editors) need an enterprise-grade SVG converter for
pixel-art emblems. Upstream proves the algorithm works against
well-understood game-emblem fixtures (chessboard, donut, cross, T-shape,
circle, star, flag, shield, crest); we port those fixtures verbatim as the
algorithmic regression suite.
Hand-rolling a replacement would be worse than porting. The upstream package
is small (~2k LOC) and self-contained: zero `@suaveplan/*` runtime deps. It
fits cleanly into genesis without dragging a dependency train.
## What changes
A net-new universal-tier package at `packages/media/image/emblem-solver/`.
No upstream consumer exists on genesis (`grep -r "@suaveplan/emblem-solver"
packages/` returns nothing), so this is purely additive.
### Affected dependents
- **None** on genesis today. Downstream packages will start importing the
public surface listed below.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `convert(pixels, w, h, options)` pure entry point | ✅ | ✅ | |
| `analyseGrid` exported | ✅ | ✅ | |
| `parseRGBABuffer` + `fromIndexedGrid` | ✅ | ✅ | |
| `buildSVG` | ✅ | ✅ | |
| 4-connected island flood-fill | ✅ | ✅ | |
| Dual-axis histogram maximal-rect sweep | ✅ | ✅ | |
| Strip decomposition (h+v, picks smaller) | ✅ | ✅ | |
| Enhanced greedy cover (3-key tiebreak) | ✅ | ✅ | |
| Per-island independent cover | ✅ | ✅ | |
| Overlap optimiser (bbox-merge, extend-to-subsume, 3→2 cross replace) | ✅ | ✅ | |
| Adjacent-rect merge pass | ✅ | ✅ | |
| Redundancy elimination across z-stacked layers | ✅ | ✅ | |
| Heap's-algorithm permutations + factorial | ✅ | ✅ | |
| Static orderings (`area-desc` / `area-asc` / `island-aware` / `perimeter-desc`) | ✅ | ✅ | |
| `auto` mode: exhaustive ≤ N!, heuristics+random sampling above | ✅ | ✅ | Random branch now uses injectable `Rng` (default `Math.random`) |
| `ConvertResult.colourAnalysis` exposed | ✅ | ✅ | |
| Solid / fully-transparent / single-pixel edge cases | ✅ | ✅ | |
| Sprite fixtures (chessboard, donut, cross, T, circle, star, flag, shield, crest, stripes) | ✅ | ✅ | Ported verbatim |
| Zod-validated `ConvertOptions` | 🚫 | ⬆️ | Enhanced — runtime schema + `EmblemSolverError(INVALID_OPTIONS)` |
| Structured error hierarchy | 🚫 | ⬆️ | New `EMBLEM_SOLVER.*` registry via `defineCodes` |
| OTel counter + histogram on `convert` | 🚫 | ⬆️ | New |
| Deterministic RNG injection | 🚫 | ⬆️ | New — replaces the lone `Math.random` shuffle site |
| Property-based tests via `fast-check` | 🚫 | ⬆️ | New on geometric kernels (rect cover correctness, SVG round-trip) |
| Co-located `.md` per impl file | partial | ✅ | Per CLAUDE.md doc rule |
| Subpath exports (`./types`, `./errors`, `./svg`, `./io`, `./grid`) | 🚫 | ⬆️ | New for tree-shaking |
No upstream feature is dropped.
## Impact
- **Code**: ~1.8k LOC pure TypeScript in `packages/media/image/emblem-solver/src/`.
- **Build**: Standard universal-tier `vite build` + dts emit.
- **Tests**: 100/100/100/100 via `provider: 'istanbul'`. Upstream's snapshot
fixtures (`chessboard8x8`, `cross3x3`, `cross6x6`, `tShape`, `donut5x5`,
`circle16`, `flagCrosses`, `multiIslandStripes`, `shieldEmblem32`,
`crest12x12`, `star`) ported verbatim as the deterministic regression set.
- **Docs**: README at package root + sibling `.md` per impl file.
- **STATUS.md**: flips `@suaveplan/emblem-solver` `–` → `✅`. Counter
`289 → 290` migrated; `204 → 203` pending.
- **Manifest**: regenerated via `bun scripts/folder-reorg/generate-manifest.ts`.
## Risks & mitigation
| Risk | Mitigation |
|---|---|
| `auto` mode blows up for >8 colours | Hard `maxPermutations` cap (default 40320 = 8!), heuristic fallback identical to upstream; Zod refines `maxPermutations ≥ 1` |
| Non-determinism from `Math.random` | Injectable `Rng` (defaults to `Math.random` for back-compat); tests pass a seeded fake to lock the random-shuffle branch |
| Coverage gaps in the heuristic branch | Dedicated tests construct >8-colour fixtures to exercise the heuristic + sampling path |
| Floating-point density / score determinism | All scoring is deterministic given inputs; no `Date.now` / module-scope `Math.random` |

View File

@@ -0,0 +1,441 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: emblem-solver (emblem-solver)
**Package**: `@suaveplan/emblem-solver`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Stable error code registry
The package SHALL expose `EMBLEM_SOLVER_CODES` created via
`defineCodes("EMBLEM_SOLVER", [...] as const)` containing exactly the codes
`INVALID_GRID`, `INVALID_OPTIONS`, `INVALID_PIXEL`. The registry MUST be
frozen and each value MUST be the namespaced string
(`EMBLEM_SOLVER.INVALID_GRID`, …).
#### Scenario: registry shape is frozen
- **WHEN** a consumer reads `EMBLEM_SOLVER_CODES`
- **THEN** the returned object MUST contain all three expected keys with
namespaced string values and MUST be `Object.isFrozen`.
**Verified by:** `src/errors/errors.test.ts::"EMBLEM_SOLVER_CODES exposes all three codes"`
---
### Requirement: EmblemSolverError extends SuaveplanError
The package SHALL export `EmblemSolverError extends SuaveplanError<EmblemSolverCode>`.
Constructing `new EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID, "msg")`
MUST yield an instance whose `.code === "EMBLEM_SOLVER.INVALID_GRID"`, whose
`.message === "msg"`, and which satisfies `instanceof EmblemSolverError`
AND `instanceof Error`. A `cause` passed in options MUST be preserved.
#### Scenario: round-trips through cause chain
- **WHEN** an `EmblemSolverError` is constructed with `cause: new Error("inner")`
- **THEN** `error.cause` MUST return the original error and
`error.code` MUST preserve the namespaced code.
**Verified by:** `src/errors/errors.test.ts::"EmblemSolverError preserves code and cause"`
---
### Requirement: ConvertOptions Zod validation
The package SHALL expose `ConvertOptionsSchema` (Zod) accepting
`layerOrder` (enum: `auto | area-desc | area-asc | island-aware | perimeter-desc`,
default `auto`), `maxPermutations` (integer ≥ 1, default `40320`), and
optional `rng` (`() => number`). The schema MUST reject unknown keys
(strict). `convert()` MUST call `safeParse` and on failure throw
`EmblemSolverError(INVALID_OPTIONS)` whose message embeds the Zod issue list.
#### Scenario: rejects unknown option
- **WHEN** `convert(pixels, w, h, { wibble: 1 } as never)` is invoked
- **THEN** the call MUST throw `EmblemSolverError` whose `.code` equals
`EMBLEM_SOLVER.INVALID_OPTIONS`.
#### Scenario: rejects non-positive maxPermutations
- **WHEN** `convert(pixels, w, h, { maxPermutations: 0 })` is invoked
- **THEN** the call MUST throw `EmblemSolverError(INVALID_OPTIONS)`.
**Verified by:** `src/schemas/schemas.test.ts::"ConvertOptionsSchema rejects unknown keys"` AND `src/converter/converter.test.ts::"throws INVALID_OPTIONS on bad option"`
---
### Requirement: Grid input validation
The `convert()` entry point SHALL throw
`EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID)` when `width ≤ 0`,
`height ≤ 0`, or `pixels.length !== width * height`. The
`parseRGBABuffer()` entry point SHALL throw the same error when the supplied
`Uint8Array` length is not exactly `width * height * 4`.
#### Scenario: width/height mismatch
- **WHEN** `convert([], 2, 2)` is invoked with empty pixels
- **THEN** the call MUST throw `EmblemSolverError` whose `.code` equals
`EMBLEM_SOLVER.INVALID_GRID`.
#### Scenario: buffer length mismatch
- **WHEN** `parseRGBABuffer(new Uint8Array(3), 2, 2)` is invoked
- **THEN** the call MUST throw `EmblemSolverError(INVALID_GRID)`.
**Verified by:** `src/converter/converter.test.ts::"throws INVALID_GRID on dimension mismatch"` AND `src/io/parse/parse.test.ts::"throws INVALID_GRID on buffer length mismatch"`
---
### Requirement: Public type surface
The package SHALL export the following types verbatim from the package root:
`RGBA`, `Rect`, `Island`, `ColourAnalysis`, `ColourLayer`, `ConvertOptions`,
`ConvertResult`, `GridAnalysis`, `Rng`, `EmblemSolverCode`. The shapes MUST
match the upstream contract (`RGBA.a: 0 | 1`, `Rect: {x,y,w,h}`,
`Island.pixels: Set<number>`, `ColourAnalysis` includes
`density`/`fragmentation`/`bounds`/`islands`).
#### Scenario: type re-exports are present
- **WHEN** a TypeScript consumer imports the type set above
- **THEN** type-checking MUST succeed against the documented shapes.
**Verified by:** `src/types/types.md` AND `src/converter/converter.test.ts::"ConvertResult exposes all documented fields"`
---
### Requirement: Pixel-grid analysis with 4-connected islands
The package SHALL expose `analyseGrid(pixels, width, height)` which produces
a `GridAnalysis` containing `width`, `height`, `opaquePixels` (a
`Set<number>` of packed indices), `colourGroups` (a `Map<string, Set<number>>`
keyed by `"r,g,b"`), and `colourAnalysis` (a `Map<string, ColourAnalysis>`)
where each entry reports `totalPixels`, `bounds`, `density = pixels / bbox area`,
`fragmentation = islands / pixels`, and the full `islands` list discovered via
4-connected BFS flood-fill.
#### Scenario: detects two disjoint islands
- **WHEN** `analyseGrid` runs on a grid where one colour appears in two
blocks separated by transparency
- **THEN** that colour's `islands.length` MUST equal `2`.
#### Scenario: diagonals are not connected
- **WHEN** `analyseGrid` runs on a `[[1,0],[0,1]]` grid
- **THEN** the only colour's `islands.length` MUST equal `2`.
**Verified by:** `src/grid/analyse/analyse.test.ts::"detects two islands separated by transparency"` AND `src/grid/analyse/analyse.test.ts::"treats diagonal pixels as separate islands (4-connected)"`
---
### Requirement: RGBA buffer + indexed grid IO
The package SHALL expose `parseRGBABuffer(buf, w, h)` returning `RGBA[]` of
length `w*h` where `a` is quantised to `0 | 1` at the threshold `byte ≥ 128`.
It SHALL also expose `fromIndexedGrid(grid, palette)` returning
`{ pixels, width, height }` from a `number[][]` palette map. Index `0` MUST
map to a fully-transparent pixel (`a === 0`). Indices not present in the
palette MUST also produce a transparent pixel.
#### Scenario: alpha quantisation rounds half-up
- **WHEN** `parseRGBABuffer` reads a byte with `a = 128`
- **THEN** the resulting `RGBA.a` MUST equal `1`; for `a = 127` it MUST be `0`.
#### Scenario: indexed grid maps palette correctly
- **WHEN** `fromIndexedGrid([[1,0],[0,2]], {1:r, 2:g})` is called
- **THEN** result `pixels[0]` MUST be the `r`-colour with `a===1`,
`pixels[1]` MUST be transparent, and `pixels[3]` MUST be the `g`-colour.
**Verified by:** `src/io/parse/parse.test.ts::"parseRGBABuffer quantises alpha"` AND `src/io/parse/parse.test.ts::"fromIndexedGrid maps palette indices to RGBA pixels"`
---
### Requirement: Dual-axis histogram maximal-rectangles
The package SHALL expose a `dualAxisMaximalRects(available, rows, cols)`
solver that enumerates every distinct maximal-rectangle candidate in the
opaque-pixel grid by combining a normal histogram sweep and a transposed
sweep. Duplicates MUST be deduplicated by their `(x,y,w,h)` key.
#### Scenario: solid 3x3 produces the full rect
- **WHEN** the input is a 3×3 all-`true` grid
- **THEN** the returned set MUST include `{x:0,y:0,w:3,h:3}`.
**Verified by:** `src/solver/histogram/histogram.test.ts::"includes the full grid rect for a solid grid"`
---
### Requirement: Strip decomposition picks the smaller axis
The package SHALL expose `stripDecomposition(required, rows, cols)` that
computes maximal horizontal strips (and the transposed vertical variant)
where same-extent + adjacent runs merge, and returns whichever orientation
yields fewer rects.
#### Scenario: tall vertical strip beats horizontal runs
- **WHEN** the required mask is a single column of height `5`
- **THEN** the returned strips MUST contain exactly one rect of width `1` and height `5`.
**Verified by:** `src/solver/strip/strip.test.ts::"single column produces one vertical strip"`
---
### Requirement: Enhanced greedy set-cover with three-key tiebreak
The package SHALL expose `enhancedGreedyCover(candidates, required, w, h)`
that selects rects to cover the required mask using a deterministic score
of `covered*1000 + efficiency*100 + proximity*10`. When no rect overlaps the
remaining uncovered pixels, the function MUST fall back to emitting 1×1 rects
for the residual.
#### Scenario: empty required returns no rects
- **WHEN** all entries of `required` are `false`
- **THEN** `enhancedGreedyCover` MUST return an empty array.
**Verified by:** `src/solver/greedy/greedy.test.ts::"returns empty array when nothing is required"`
---
### Requirement: Per-island independent cover
The package SHALL expose `perIslandCover(islands, candidates, w, h)` that
runs `enhancedGreedyCover` independently on each island's local required
mask, restricting candidates to those that touch the island.
#### Scenario: independent covers don't span islands
- **WHEN** two 2×2 islands are separated by transparency
- **THEN** `perIslandCover` MUST produce at least two rects, one per island,
and no rect MUST span the transparent gap.
**Verified by:** `src/solver/greedy/greedy.test.ts::"covers two islands without spanning the gap"`
---
### Requirement: Layer-ordering search
The package SHALL expose `getStaticOrdering(analysis, strategy)` for the
strategies `area-desc`, `area-asc`, `island-aware`, `perimeter-desc`, and
`permutations<T>(arr)` implementing Heap's algorithm yielding `n!`
permutations including the identity, and `factorial(n)` returning the
mathematical factorial with `factorial(0) === factorial(1) === 1`.
#### Scenario: permutations yields n! arrays in total
- **WHEN** `permutations(["a","b","c"])` is fully iterated
- **THEN** the iteration MUST emit exactly `6` arrays.
#### Scenario: factorial agrees with the definition
- **WHEN** `factorial(5)` is called
- **THEN** the return value MUST equal `120`.
**Verified by:** `src/solver/ordering/ordering.test.ts::"permutations yields n! arrays"` AND `src/solver/ordering/ordering.test.ts::"factorial matches the standard definition"`
---
### Requirement: Multi-strategy layer processor
The package SHALL expose `processLayerMultiStrategy(key, ownPixels,
analysis, earlierPixels)` that runs the four covering strategies
(dual-axis-histogram + greedy, per-island, strip decomposition where it
fully covers, and a merge pass on the best so far) and returns the rect
list with the smallest cardinality.
#### Scenario: returns a valid cover
- **WHEN** the layer processor runs on the upstream `cross3x3` fixture's
red pixels
- **THEN** the returned rect list MUST cover every required red pixel and
MUST contain at most `2` rects.
**Verified by:** `src/solver/layer/layer.test.ts::"cross3x3 produces at most 2 rects"`
---
### Requirement: Adjacent-rect merge optimisation
The package SHALL expose `mergeAdjacentRects(rects, available)` which
combines edge-sharing same-extent rect pairs into a single larger rect
provided the merged span is fully `available`. The function MUST iterate
until no further merge is possible.
#### Scenario: two adjacent unit squares merge
- **WHEN** `mergeAdjacentRects([{0,0,1,1},{1,0,1,1}], all-available)` runs
- **THEN** the output MUST be exactly `[{0,0,2,1}]`.
**Verified by:** `src/optimizer/merge/merge.test.ts::"merges two horizontally adjacent unit squares"`
---
### Requirement: Overlap optimiser
The package SHALL expose `optimiseOverlap(rects, required, available, rows,
cols)` that (1) merges pairs whose bbox is fully available and whose union
covers no more than the original required pixels, (2) extends a rect to
subsume another while remaining inside `available`, and (3) replaces a
triple of rects with a 2-rect cross/T/L pattern when one exists.
#### Scenario: cross 3x3 reduces to two rects
- **WHEN** `convert` runs on the upstream `cross3x3` fixture
- **THEN** `ConvertResult.totalRects` MUST be at most `2`.
**Verified by:** `src/optimizer/overlap/overlap.test.ts::"reduces a 3-rect L to 2 rects"` AND `src/converter/converter.test.ts::"cross 3x3 reduces to <=2 rects"`
---
### Requirement: Cross-layer redundancy elimination
The package SHALL expose `eliminateRedundant(layers, width, height)` which,
given a list of `ColourLayer` objects, removes any rect whose pixels are
either fully occluded by higher-z layers (of any colour) or already covered
by sibling rects in the same layer.
#### Scenario: fully-occluded rect is removed
- **WHEN** layer 0 contains a 4×4 red rect at (0,0) and layer 1 contains a
4×4 blue rect at (0,0)
- **THEN** after `eliminateRedundant` layer 0 MUST have zero rects.
**Verified by:** `src/optimizer/redundancy/redundancy.test.ts::"removes a fully-occluded rect"`
---
### Requirement: SVG emission
The package SHALL expose `buildSVG(layers, width, height)` returning a string
that begins with `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 W H"
shape-rendering="crispEdges">` and ends with `</svg>`, contains one `<rect>`
per layer-rect with `fill="rgb(r,g,b)"`, and renders layers in `zIndex`
ascending order.
#### Scenario: empty layer list produces an empty viewBox svg
- **WHEN** `buildSVG([], 4, 4)` is invoked
- **THEN** the result MUST contain `viewBox="0 0 4 4"` and zero `<rect>` tags.
**Verified by:** `src/svg/build/build.test.ts::"empty layers yields a rect-free svg"`
---
### Requirement: Public `convert()` orchestrator
The package SHALL expose `convert(pixels, width, height, options?)`. The
function MUST (1) parse `options` via `ConvertOptionsSchema`, (2) call
`analyseGrid`, (3) early-return an empty result for fully-transparent input,
(4) explore orderings (exhaustive for `colourCount! ≤ maxPermutations`;
heuristics + ≤ 1000 RNG-driven samples otherwise), (5) pick the ordering
with the smallest `totalRects`, (6) emit the SVG via `buildSVG`, and (7)
return `{ svg, layers, totalRects, naiveRects, ratio, orderingUsed,
orderingsEvaluated, colourAnalysis }`.
#### Scenario: chessboard 8x8 ≤ 33 rects
- **WHEN** `convert` runs on the upstream `chessboard8x8` fixture
- **THEN** `result.totalRects ≤ 33`.
#### Scenario: solid grid produces exactly one rect
- **WHEN** `convert` runs on a 4×4 solid-red grid
- **THEN** `result.totalRects === 1`.
#### Scenario: fully transparent grid produces zero rects
- **WHEN** `convert` runs on a 4×4 fully-transparent grid
- **THEN** `result.totalRects === 0` and `result.layers` MUST be empty.
#### Scenario: shield emblem 32x32 compresses > 50x
- **WHEN** `convert` runs on the upstream `shieldEmblem32` fixture
- **THEN** `result.ratio > 50`.
**Verified by:** `src/converter/converter.test.ts::"chessboard 8x8: ≤33 rects"` AND `src/converter/converter.test.ts::"solid colour 4x4 = 1 rect"` AND `src/converter/converter.test.ts::"fully transparent = 0 rects"` AND `src/converter/converter.test.ts::"shield emblem 32x32 ratio > 50"`
---
### Requirement: Injectable Rng for deterministic auto-ordering
`convert()` SHALL accept an optional `rng: () => number` in its options. The
heuristics-plus-sampling branch (entered when `colourCount! > maxPermutations`)
MUST consume the injected `rng` for every Fisher–Yates shuffle step, falling
back to `Math.random` only when no `rng` is supplied. The same seeded `rng`
MUST yield byte-identical `ConvertResult.svg` across runs.
#### Scenario: seeded rng yields deterministic output
- **WHEN** `convert` runs twice on the same > 8-colour fixture with the same
seeded mulberry32 `rng`
- **THEN** the two `ConvertResult.svg` strings MUST be identical.
**Verified by:** `src/converter/converter.test.ts::"seeded rng yields deterministic auto output"`
---
### Requirement: OpenTelemetry instrumentation
`convert()` SHALL emit an OTel counter
`emblem_solver.convert.total` (incremented once per call) and a histogram
`emblem_solver.convert.duration_ms` (one observation per call, unit `ms`).
Instruments MUST be created lazily on first call (no module-load side
effects) using `metrics.getMeter("@suaveplan/emblem-solver")`.
#### Scenario: no instrument is registered before the first call
- **WHEN** the module is imported but `convert` has not been called
- **THEN** internal `counter`/`histogram` references MUST remain `undefined`.
#### Scenario: first call registers both instruments
- **WHEN** `convert` is invoked once
- **THEN** the internal `counter` and `histogram` MUST be defined and the
counter MUST have been incremented by `1`.
**Verified by:** `src/telemetry/telemetry.test.ts::"instruments are lazily registered"` AND `src/telemetry/telemetry.test.ts::"convert increments counter and records duration"`
---
### Requirement: Tree-shakeable subpath exports
The package's `package.json` SHALL expose `./types`, `./errors`,
`./schemas`, `./grid`, `./io`, `./svg`, `./solver`, and `./optimizer`
subpath exports in addition to the root entry, each pointing at a feature
barrel. `sideEffects` MUST be `false`.
#### Scenario: each subpath is importable
- **WHEN** a consumer imports `@suaveplan/emblem-solver/errors`
- **THEN** the import MUST resolve and expose `EmblemSolverError` and
`EMBLEM_SOLVER_CODES`.
**Verified by:** `src/errors/errors.test.ts::"errors subpath barrel re-exports the public surface"`
---
### Requirement: Property-based coverage correctness
A property test SHALL assert that for arbitrary single-colour pixel grids
generated via `fast-check`, the `convert()` output satisfies: (a) every
opaque pixel in the input is covered by at least one rect in the
corresponding layer, and (b) no rect's footprint extends beyond the grid
bounds.
#### Scenario: random solid-colour grid round-trip
- **WHEN** `fast-check` generates 50 random grids of width/height in `[1, 12]`
- **THEN** every required pixel MUST be covered by at least one rect.
**Verified by:** `src/converter/converter.test.ts::"property: every opaque pixel is covered"`

View File

@@ -0,0 +1,101 @@
# Tasks: enterprise-emblem-solver
**Change ID**: enterprise-emblem-solver
**Package**: `@suaveplan/emblem-solver`
**Target path**: `packages/media/image/emblem-solver/`
## 0. Pre-flight
- [x] 0.1 `git status` clean; `git worktree list` reviewed; worktree synced to `EXPECTED_BASE`.
- [x] 0.2 Read upstream reference at `/mnt/s/web/suaveplan/packages/universal/emblem-solver/`.
- [x] 0.3 Read enterprise audit at `/mnt/s/web/suaveplan/enterprise/emblem-solver.md`.
## 1. Scaffold
- [x] 1.1 Create `packages/media/image/emblem-solver/` and directory tree per `design.md`.
- [x] 1.2 Write `package.json` (v `0.1.0`, type module, sideEffects false, subpath exports).
- [x] 1.3 Write `tsconfig.lib.json` extending `tsconfig.base.json`.
- [x] 1.4 Write `vite.config.ts` with externalised deps + preserveModules.
- [x] 1.5 Write `vitest.config.ts` (istanbul, 100% thresholds, forks pool).
## 2. Types
- [x] 2.1 Write `src/types/types.ts` — public RGBA/Rect/Island/etc.
- [x] 2.2 Write `src/types/types.md`.
## 3. Errors
- [x] 3.1 Write `src/errors/errors.ts` — `defineCodes("EMBLEM_SOLVER", […])` + `EmblemSolverError`.
- [x] 3.2 Write `src/errors/errors.test.ts` exercising every code.
- [x] 3.3 Write `errors.md` + `index.ts` barrel.
## 4. Schemas
- [x] 4.1 Write `src/schemas/schemas.ts` — `ConvertOptionsSchema` (Zod, strict).
- [x] 4.2 Write `src/schemas/schemas.test.ts`.
- [x] 4.3 Write `schemas.md` + `index.ts`.
## 5. Helpers
- [x] 5.1 Write `src/helpers/helpers.ts`.
- [x] 5.2 Write `src/helpers/helpers.test.ts`.
- [x] 5.3 Write `helpers.md` + `index.ts`.
## 6. Grid analysis
- [x] 6.1 Write `src/grid/analyse/analyse.ts`.
- [x] 6.2 Write `src/grid/analyse/analyse.test.ts`.
- [x] 6.3 Write `.md` + barrels.
## 7. IO
- [x] 7.1 Write `src/io/parse/parse.ts`.
- [x] 7.2 Write tests + `.md` + barrels.
## 8. Solver kernels
- [x] 8.1 histogram + tests/.md/barrel.
- [x] 8.2 strip + tests/.md/barrel.
- [x] 8.3 greedy + tests/.md/barrel.
- [x] 8.4 ordering + tests/.md/barrel.
- [x] 8.5 layer + tests/.md/barrel.
- [x] 8.6 `src/solver/index.ts`.
## 9. Optimizers
- [x] 9.1 merge + tests/.md/barrel.
- [x] 9.2 overlap + tests/.md/barrel.
- [x] 9.3 redundancy + tests/.md/barrel.
- [x] 9.4 `src/optimizer/index.ts`.
## 10. SVG + telemetry + fixtures + converter
- [x] 10.1 svg/build + tests/.md/barrel.
- [x] 10.2 telemetry + tests/.md/barrel.
- [x] 10.3 fixtures port.
- [x] 10.4 converter orchestrator.
- [x] 10.5 converter tests (baseline + edges + auto + heuristic + fast-check property).
- [x] 10.6 converter.md.
## 11. Root barrel + docs
- [x] 11.1 `src/index.ts` explicit named re-exports.
- [x] 11.2 `README.md`.
## 12. Verification
- [x] 12.1 lint green.
- [x] 12.2 typecheck green.
- [x] 12.3 test green.
- [x] 12.4 build green.
- [x] 12.5 coverage 100/100/100/100.
## 13. Status + manifest
- [x] 13.1 Regenerate `MANIFEST.md`.
- [x] 13.2 STATUS.md flip + counter adjust.
## 14. Archive
- [x] 14.1 Fold spec delta into `openspec/specs/emblem-solver/spec.md`.
- [x] 14.2 Move change folder to `openspec/archive/2026-05-25-enterprise-emblem-solver/`.