diff --git a/DECISIONS.md b/DECISIONS.md index 3fc3639..fcf0c83 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1520,3 +1520,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 16.4 - **No save migration for new locations**: CR-016 §9 asks for one that adds the three places as undiscovered, but the save (since CR-014) stores only discovered places, so there is nothing to add. Bumping the save version for no data change would only make older builds refuse newer saves for no reason; the test shows an older save opens correctly instead. + +## Step 16.5 + +- **The manifest declares how many rocks the sheet holds** (`pieces: 6`), and the script checks it: the loader needs to know the file names without reading the folder, and a changed sheet then fails loudly instead of silently loading the wrong set. +- **Slicing uses alpha ≥ 16 and drops everything else around a rock**: the user's rocks have a soft glow that reaches toward their neighbours; below 16/255 it is invisible on the map, and keeping it would put slivers of other rocks' glow in each sprite. +- **Rocks are exported at 64 and 128 px** (the CR's sizes), named `belt-rocks-@1x/@2x`, in reading order of the sheet. diff --git a/apps/web/public/assets/map-gen/belt-rocks-1@1x.png b/apps/web/public/assets/map-gen/belt-rocks-1@1x.png new file mode 100644 index 0000000..b8a7005 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-1@1x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-1@2x.png b/apps/web/public/assets/map-gen/belt-rocks-1@2x.png new file mode 100644 index 0000000..ab6e946 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-1@2x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-2@1x.png b/apps/web/public/assets/map-gen/belt-rocks-2@1x.png new file mode 100644 index 0000000..d320e0e Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-2@1x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-2@2x.png b/apps/web/public/assets/map-gen/belt-rocks-2@2x.png new file mode 100644 index 0000000..583dff6 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-2@2x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-3@1x.png b/apps/web/public/assets/map-gen/belt-rocks-3@1x.png new file mode 100644 index 0000000..e7ffb52 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-3@1x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-3@2x.png b/apps/web/public/assets/map-gen/belt-rocks-3@2x.png new file mode 100644 index 0000000..2974e79 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-3@2x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-4@1x.png b/apps/web/public/assets/map-gen/belt-rocks-4@1x.png new file mode 100644 index 0000000..89c31f2 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-4@1x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-4@2x.png b/apps/web/public/assets/map-gen/belt-rocks-4@2x.png new file mode 100644 index 0000000..6adc6e1 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-4@2x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-5@1x.png b/apps/web/public/assets/map-gen/belt-rocks-5@1x.png new file mode 100644 index 0000000..3414267 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-5@1x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-5@2x.png b/apps/web/public/assets/map-gen/belt-rocks-5@2x.png new file mode 100644 index 0000000..4ebe32b Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-5@2x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-6@1x.png b/apps/web/public/assets/map-gen/belt-rocks-6@1x.png new file mode 100644 index 0000000..5887319 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-6@1x.png differ diff --git a/apps/web/public/assets/map-gen/belt-rocks-6@2x.png b/apps/web/public/assets/map-gen/belt-rocks-6@2x.png new file mode 100644 index 0000000..310b525 Binary files /dev/null and b/apps/web/public/assets/map-gen/belt-rocks-6@2x.png differ diff --git a/apps/web/public/assets/map/belt-asteroids.png b/apps/web/public/assets/map/belt-asteroids.png new file mode 100644 index 0000000..5e93723 Binary files /dev/null and b/apps/web/public/assets/map/belt-asteroids.png differ diff --git a/apps/web/scripts/map-art.mjs b/apps/web/scripts/map-art.mjs index cdda4a1..d987f9b 100644 --- a/apps/web/scripts/map-art.mjs +++ b/apps/web/scripts/map-art.mjs @@ -8,13 +8,22 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { sliceSheet } from "./slice.mjs"; const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const manifest = JSON.parse(readFileSync(join(webRoot, "src/map/art/manifest.json"), "utf8")); /** Long-side sizes in px for the 1x and 2x variants of each sprite kind. */ -export const SPRITE_SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] }; +export const SPRITE_SIZES = { + body: [256, 512], + badge: [256, 512], + ship: [128, 256], + // Each rock sliced from a sheet (CR-016 §5). + rocks: [64, 128], +}; export const BACKGROUND_MAX = 2048; +/** A seamless strip (the belt's haze, CR-016 §5) is kept within this size. */ +export const STRIP_MAX = { width: 4096, height: 256 }; export const DEFAULT_OUT_DIR = join(webRoot, "public/assets/map-gen"); /** Every file the script writes for the manifest, relative to the output folder. */ @@ -23,7 +32,14 @@ export function expectedOutputs() { for (const [id, asset] of Object.entries(manifest.assets)) { if (asset.file === null) continue; if (asset.kind === "background") outputs.push({ id, file: `${id}.webp`, kind: asset.kind }); - else { + else if (asset.kind === "strip") outputs.push({ id, file: `${id}.png`, kind: asset.kind }); + else if (asset.kind === "rocks") { + const [one, two] = SPRITE_SIZES.rocks; + for (let i = 1; i <= asset.pieces; i++) { + outputs.push({ id, file: `${id}-${i}@1x.png`, kind: asset.kind, longSide: one }); + outputs.push({ id, file: `${id}-${i}@2x.png`, kind: asset.kind, longSide: two }); + } + } else { const [one, two] = SPRITE_SIZES[asset.kind]; outputs.push({ id, file: `${id}@1x.png`, kind: asset.kind, longSide: one }); outputs.push({ id, file: `${id}@2x.png`, kind: asset.kind, longSide: two }); @@ -54,18 +70,42 @@ async function generate(sharp, outDir) { .toFile(join(outDir, `${id}.webp`)); continue; } + if (asset.kind === "strip") { + await sharp(source) + .resize({ ...STRIP_MAX, fit: "inside", withoutEnlargement: true }) + .png(png) + .toFile(join(outDir, `${id}.png`)); + continue; + } + if (asset.kind === "rocks") { + // A sheet of separate rocks: one sprite per rock (CR-016 §5). + const pieces = await sliceSheet(sharp, source); + if (pieces.length !== asset.pieces) { + throw new Error( + `map-art: ${asset.file} has ${pieces.length} separate sprites; the manifest says ${asset.pieces}`, + ); + } + for (const [i, piece] of pieces.entries()) { + await writeSizes(sharp, piece, SPRITE_SIZES.rocks, join(outDir, `${id}-${i + 1}`)); + } + continue; + } // Trim transparent padding once, then export each size from the trimmed image. const trimmed = await sharp(source).trim().png().toBuffer(); - const [one, two] = SPRITE_SIZES[asset.kind]; - for (const [suffix, size] of [ - ["1x", one], - ["2x", two], - ]) { - await sharp(trimmed) - .resize({ width: size, height: size, fit: "inside" }) - .png(png) - .toFile(join(outDir, `${id}@${suffix}.png`)); - } + await writeSizes(sharp, trimmed, SPRITE_SIZES[asset.kind], join(outDir, id)); + } +} + +/** Writes `@1x.png` and `@2x.png` with the given long sides. */ +async function writeSizes(sharp, image, [one, two], base) { + for (const [suffix, size] of [ + ["1x", one], + ["2x", two], + ]) { + await sharp(image) + .resize({ width: size, height: size, fit: "inside" }) + .png(png) + .toFile(`${base}@${suffix}.png`); } } diff --git a/apps/web/scripts/slice.mjs b/apps/web/scripts/slice.mjs new file mode 100644 index 0000000..5289214 --- /dev/null +++ b/apps/web/scripts/slice.mjs @@ -0,0 +1,88 @@ +// Splits a sheet of separate sprites (e.g. the belt's rocks, CR-016) into one +// image per sprite, by connected regions of visible pixels. Read-only on the source. + +/** Pixels at least this opaque belong to a sprite; fainter glow is dropped. */ +export const MIN_ALPHA = 16; +/** Regions smaller than this share of the sheet are stray specks, not sprites. */ +export const MIN_AREA_SHARE = 0.002; + +/** + * Labels the connected regions (4-neighbour) of pixels with alpha ≥ MIN_ALPHA. + * Returns the label per pixel (0 = none) and each kept region's bounding box, + * in reading order (by row of their top edge, then left to right). + */ +export function findRegions(alpha, width, height) { + const labels = new Int32Array(width * height); + const minArea = Math.max(1, Math.floor(width * height * MIN_AREA_SHARE)); + const regions = []; + let next = 1; + for (let start = 0; start < width * height; start++) { + if (labels[start] !== 0 || alpha[start] < MIN_ALPHA) continue; + const label = next++; + let area = 0; + let [left, top, right, bottom] = [width, height, -1, -1]; + const stack = [start]; + labels[start] = label; + while (stack.length > 0) { + const p = stack.pop(); + area++; + const x = p % width; + const y = (p - x) / width; + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + for (const q of [p - 1, p + 1, p - width, p + width]) { + if (q < 0 || q >= width * height) continue; + if ((q === p - 1 || q === p + 1) && Math.floor(q / width) !== y) continue; + if (labels[q] === 0 && alpha[q] >= MIN_ALPHA) { + labels[q] = label; + stack.push(q); + } + } + } + if (area >= minArea) { + regions.push({ label, left, top, width: right - left + 1, height: bottom - top + 1, area }); + } + } + // Reading order: rows of sprites (tops within half the smallest sprite's height), then x. + const rowTolerance = Math.max(1, Math.min(...regions.map((r) => r.height)) / 2); + regions.sort((a, b) => + Math.abs(a.top - b.top) <= rowTolerance ? a.left - b.left : a.top - b.top, + ); + return { labels, regions }; +} + +/** + * Cuts each sprite out of the sheet as its own trimmed PNG buffer (RGBA): the + * region's bounding box, with every pixel that isn't part of that region made + * fully transparent (so a neighbour's glow never comes along). + */ +export async function sliceSheet(sharp, source) { + const { data, info } = await sharp(source) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const { width, height } = info; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < width * height; i++) alpha[i] = data[i * 4 + 3]; + const { labels, regions } = findRegions(alpha, width, height); + const pieces = []; + for (const region of regions) { + const out = Buffer.alloc(region.width * region.height * 4); + for (let y = 0; y < region.height; y++) { + for (let x = 0; x < region.width; x++) { + const src = (region.top + y) * width + region.left + x; + const dst = (y * region.width + x) * 4; + if (labels[src] !== region.label) continue; + data.copy(out, dst, src * 4, src * 4 + 4); + } + } + pieces.push( + await sharp(out, { raw: { width: region.width, height: region.height, channels: 4 } }) + .png() + .toBuffer(), + ); + } + return pieces; +} diff --git a/apps/web/src/map/art/load.ts b/apps/web/src/map/art/load.ts index 2848b7d..d52b201 100644 --- a/apps/web/src/map/art/load.ts +++ b/apps/web/src/map/art/load.ts @@ -1,4 +1,4 @@ -import { artManifest } from "./manifest"; +import { artManifest, pieceIds } from "./manifest"; /** Where `scripts/map-art.mjs` output is served from (`public/assets/map-gen`). */ export const ART_BASE_URL = "/assets/map-gen"; @@ -10,10 +10,18 @@ export function artBundle(devicePixelRatio: number): Record { const bundle: Record = {}; for (const [id, asset] of Object.entries(artManifest.assets)) { if (asset.file === null) continue; + if (asset.kind === "rocks") { + for (const piece of pieceIds(id, asset)) { + bundle[piece] = `${ART_BASE_URL}/${piece}@${density}.png`; + } + continue; + } bundle[id] = asset.kind === "background" ? `${ART_BASE_URL}/${id}.webp` - : `${ART_BASE_URL}/${id}@${density}.png`; + : asset.kind === "strip" + ? `${ART_BASE_URL}/${id}.png` + : `${ART_BASE_URL}/${id}@${density}.png`; } return bundle; } diff --git a/apps/web/src/map/art/manifest.json b/apps/web/src/map/art/manifest.json index 92da9e5..ab66c7d 100644 --- a/apps/web/src/map/art/manifest.json +++ b/apps/web/src/map/art/manifest.json @@ -68,6 +68,15 @@ "ship-clipper": { "kind": "ship", "file": "ship-clipper.png" + }, + "belt-rocks": { + "kind": "rocks", + "file": "belt-asteroids.png", + "pieces": 6 + }, + "belt-haze": { + "kind": "strip", + "file": null } } } diff --git a/apps/web/src/map/art/manifest.ts b/apps/web/src/map/art/manifest.ts index d1fa599..15ffb70 100644 --- a/apps/web/src/map/art/manifest.ts +++ b/apps/web/src/map/art/manifest.ts @@ -5,12 +5,27 @@ import manifestJson from "./manifest.json"; * user's map assets folder. A `null` file means the asset is expected but * missing, so the map draws its placeholder (decision 0.5.41). */ -export type ArtKind = "background" | "body" | "badge" | "ship"; +export type ArtKind = "background" | "body" | "badge" | "ship" | "rocks" | "strip"; + +export interface ArtAsset { + kind: ArtKind; + file: string | null; + /** + * `rocks` only (CR-016): how many separate sprites the sheet holds. The + * script slices it into `-1` … `-` and checks the count. + */ + pieces?: number; +} export interface ArtManifest { /** Source folder, relative to `apps/web`. Never written to. */ sourceDir: string; - assets: Record; + assets: Record; +} + +/** The texture ids a sliced sheet loads as (`belt-rocks-1` …). */ +export function pieceIds(id: string, asset: ArtAsset): string[] { + return Array.from({ length: asset.pieces ?? 0 }, (_, i) => `${id}-${i + 1}`); } export const artManifest = manifestJson as ArtManifest; diff --git a/apps/web/test/map-art.test.ts b/apps/web/test/map-art.test.ts index 48f0285..3ae4ea3 100644 --- a/apps/web/test/map-art.test.ts +++ b/apps/web/test/map-art.test.ts @@ -6,14 +6,21 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { artManifest } from "../src/map/art/manifest"; +import { artManifest, pieceIds } from "../src/map/art/manifest"; +// @ts-expect-error: a plain .mjs build script, tested directly. +import { MIN_ALPHA, sliceSheet } from "../scripts/slice.mjs"; const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const script = join(webRoot, "scripts/map-art.mjs"); sharp.cache(false); /** Reads through a buffer so sharp never holds the temp files open (Windows). */ const image = (path: string) => sharp(readFileSync(path)); -const SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] } as const; +const SIZES = { + body: [256, 512], + badge: [256, 512], + ship: [128, 256], + rocks: [64, 128], +} as const; const run = (outDir: string) => execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" }); @@ -42,13 +49,23 @@ describe("map art optimization script (CR-003)", () => { expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048); continue; } + if (asset.kind === "strip") { + const meta = await image(join(first, `${id}.png`)).metadata(); + expect(meta.width!).toBeLessThanOrEqual(4096); + expect(meta.height!).toBeLessThanOrEqual(256); + continue; + } + // A sliced sheet writes one sprite per piece (CR-016). + const bases = asset.kind === "rocks" ? pieceIds(id, asset) : [id]; const [one, two] = SIZES[asset.kind]; - for (const [suffix, longSide] of [ - ["1x", one], - ["2x", two], - ] as const) { - const meta = await image(join(first, `${id}@${suffix}.png`)).metadata(); - expect(Math.max(meta.width!, meta.height!), `${id}@${suffix}`).toBe(longSide); + for (const base of bases) { + for (const [suffix, longSide] of [ + ["1x", one], + ["2x", two], + ] as const) { + const meta = await image(join(first, `${base}@${suffix}.png`)).metadata(); + expect(Math.max(meta.width!, meta.height!), `${base}@${suffix}`).toBe(longSide); + } } } }); @@ -101,3 +118,70 @@ describe("map art optimization script (CR-003)", () => { } }); }); + +/** A transparent sheet with an opaque square at each `[x, y, size]`, plus faint glow and specks. */ +async function sheet(squares: [number, number, number][]): Promise { + const width = 200; + const height = 120; + const data = Buffer.alloc(width * height * 4); + const paint = (x: number, y: number, a: number) => { + const i = (y * width + x) * 4; + data[i] = 180; + data[i + 1] = 160; + data[i + 2] = 140; + data[i + 3] = Math.max(data[i + 3]!, a); + }; + for (const [left, top, size] of squares) { + // A faint halo bridging toward the neighbours, below the slicing threshold. + for (let y = top - 6; y < top + size + 6; y++) { + for (let x = left - 6; x < left + size + 6; x++) { + if (x >= 0 && y >= 0 && x < width && y < height) paint(x, y, MIN_ALPHA - 6); + } + } + for (let y = top; y < top + size; y++) + for (let x = left; x < left + size; x++) paint(x, y, 255); + } + // A stray speck: far too small to be a sprite. + paint(195, 115, 255); + return sharp(data, { raw: { width, height, channels: 4 } }) + .png() + .toBuffer(); +} + +describe("slicing a sprite sheet (Step 16.5)", () => { + it("cuts three separate rocks into exactly three trimmed sprites", async () => { + const pieces: Buffer[] = await sliceSheet( + sharp, + await sheet([ + [10, 10, 30], + [80, 20, 20], + [140, 60, 40], + ]), + ); + expect(pieces).toHaveLength(3); + const sizes = await Promise.all(pieces.map((piece) => sharp(piece).metadata())); + // Reading order (top row left to right), each trimmed to its own square. + expect(sizes.map((m) => [m.width, m.height])).toEqual([ + [30, 30], + [20, 20], + [40, 40], + ]); + }); + + it("keeps a single rock as one sprite", async () => { + expect(await sliceSheet(sharp, await sheet([[60, 30, 50]]))).toHaveLength(1); + }); + + it("drops a neighbour's glow from each sprite", async () => { + const [piece] = await sliceSheet( + sharp, + await sheet([ + [10, 10, 30], + [44, 10, 30], + ]), + ); + const { data } = await sharp(piece).raw().toBuffer({ resolveWithObject: true }); + // Every pixel is either the rock (opaque) or cleared, never the faint halo. + for (let i = 3; i < data.length; i += 4) expect([0, 255]).toContain(data[i]); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 0cff7d1..03a30df 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -397,7 +397,7 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as - [x] **Step 16.2 — Content: goods.** New goods: Atmospheric Data Cores (High-Value tier 2, Venus → Earth), Refined Sulfur (Heavy, Io → Earth, Mars, Venus), Volcanic Isotopes (High-Value tier 3, Io → Earth), Subsurface Ocean Samples (High-Value tier 3, Priority, Europa → Earth); Cloud-Grown Botanicals came in 16.1. Water Ice and Liquid Oxygen are also made at Europa, Industrial Chemicals at Venus; Water Ice goes to Io and Venus, Liquid Oxygen to Io, Habitat Prefabs to Io and Europa, and Packaged Food, Consumer Goods, Mail & Parcels, Medical Supplies, Electronics and Machine Parts to all three new places. Balance medians: first purchase 92.3, discovery 48.9, loan 160.5, four ships 147 vs 156. Tests (`packages/content/test/goods.test.ts`): the five new goods with their classes, tiers and routes (Sulfur has no tier of its own); every location makes a Standard good and wants at least one; each new place is a destination for at least five goods; the new producers on existing goods. - [x] **Step 16.3 — Content tuning: reach.** No tuning needed: with Phase 15's tanks every cell of CR-015 §4 and of CR-016 §3 already holds (Clipper ≈ 252M km reaches Venus at ≈ 200 but not Mars from Venus at ≈ 332; Freighter ≈ 460 reaches Mars from Venus and Io/Europa from Ceres at ≈ 393 but not Ceres from Venus at ≈ 520, nor Io from Mars at ≈ 592 × 1.15 short; Bulk Hauler ≈ 1,251 covers Venus–Jupiter at ≈ 885). Tests (`reach-table.test.ts`, CR-016 §3): one test per new cell (the Jupiter-system loop for Courier, Lighter, Clipper and Freighter; Earth↔Venus and Moon→Venus for Clipper and Freighter, not Courier or Lighter; Venus→Mars only from the Freighter up; Ceres↔Io/Europa for the Freighter; nothing from the inner system to the Jupiter system short of the Bulk Hauler, which flies every leg between all eight); the Bulk Hauler's full-tank reach ≥ 900M km; a Courier at the platform flies to Io and Europa and back without stranding. The no-strand test already covers every leg between all eight places. - [x] **Step 16.4 — Engine: save migration and boards.** No save migration and no save-version change: the save lists only discovered places, so a place that is in content but not in that list is undiscovered, and a save from before CR-016 opens with the three new places undiscovered and nothing else changed. Boards needed no change either: first contacts and fit slots already draw from undiscovered places in reach. Tests (`packages/engine/test/new-places.test.ts`): a pre-CR-016 save imported through the engine shows eight places with Venus, Io and Europa undiscovered, and its save version, discoveries and company are unchanged; 200 Earth boards for the deal's Clipper and veteran include first contacts to Venus; a Clipper delivering a first contact to Venus discovers it (the logbook says so) and Venus then has its own board, every contract on it a good made at Venus. -- [ ] Step 16.5 — Assets: sprites and belt rocks +- [x] **Step 16.5 — Assets: sprites and belt rocks.** (The Venus, Io and Europa sprites came in 16.1.) Two new art kinds: `rocks` (a sheet of separate sprites; the manifest's `belt-rocks` entry names `belt-asteroids.png` and says it holds `pieces: 6`) and `strip` (`belt-haze`, file `null` until the user provides one). `scripts/slice.mjs`: `findRegions` labels connected regions of pixels with alpha ≥ 16 (fainter glow and specks under 0.2 % of the sheet are dropped) in reading order; `sliceSheet` cuts each region out with every other pixel cleared, so a neighbour's glow never comes along. `map-art.mjs` writes `belt-rocks-1` … `belt-rocks-6` at 64 and 128 px (long side) and refuses a sheet whose piece count doesn't match the manifest; a haze strip would be kept within 4096 × 256. The loader registers each rock piece and the strip. The source image is only read. Tests: `apps/web/test/map-art.test.ts` (a fixture sheet with three separate squares and faint bridging glow slices into exactly three sprites, trimmed, in reading order; one square gives one; no faint glow survives in a piece; the real sheet's six pieces are written at the right sizes, deterministically and matching the committed output; the transparency check covers the belt image). - [ ] Step 16.6 — Map: belt haze - [ ] Step 16.7 — Map: belt rocks - [ ] Step 16.8 — Map: new bodies and Jupiter-system layout