diff --git a/.changeset/bake-look-yours-skill.md b/.changeset/bake-look-yours-skill.md new file mode 100644 index 00000000..a2743f0e --- /dev/null +++ b/.changeset/bake-look-yours-skill.md @@ -0,0 +1,5 @@ +--- +"@typeonce/ritmo": patch +--- + +The `ritmo-pixel-art` skill says that `pixel bake` leaves the look to the agent as well as the design: tones per facing, lines between parts, pictures on faces, and a map for a script to restyle the art. diff --git a/.changeset/bake-look-yours.md b/.changeset/bake-look-yours.md new file mode 100644 index 00000000..e2ed0355 --- /dev/null +++ b/.changeset/bake-look-yours.md @@ -0,0 +1,5 @@ +--- +"@typeonce/ritmo-tools": minor +--- + +`ritmo-tools pixel bake` leaves the look to you, not only the design. A part takes a ramp step for each way its surfaces face (`"steps": { "top": 2, "right": 1, "left": 0 }`) and a `line` where it meets a part behind it (`true` for the outline's color, `"step"` for one step darker, or a color). A `face` takes `rows`, a picture drawn flat that the bake lays on it and turns with the model. `"map": true` writes `.map.json`, with each pixel's part, face, facing, depth, and place on its face, so a script can restyle the PNG. The camera can be given in a game's own terms, `{ "across": [1, -1, 0], "down": [0.5, 0.5, -1] }` for 2:1, in place of `yaw`, `elevation`, `scale`, and `heightScale`. The outline no longer breaks at a sharp tip such as a roof's eave: the lone-pixel cleanup used to remove an outline pixel that met the rest only at a corner. diff --git a/packages/core/PIXEL-ART.md b/packages/core/PIXEL-ART.md index 291060a6..29f402ed 100644 --- a/packages/core/PIXEL-ART.md +++ b/packages/core/PIXEL-ART.md @@ -211,6 +211,7 @@ The skill says to use `pixel bake` for something with a solid shape, not for cha - Vasseur, on a 3D pipeline for Dead Cells ([link][deadcells]), quoted under One light: the same reason on the light's side, no shadow drawn by hand on every frame. - **Ritmo:** tested blind on 2026-11-07, on the eight buildings of a game, from one written brief with no references: one agent with a first version of the command, one with only what ships today, free to write its own scripts. The maintainer judged the pictures on the game's real board without knowing which was which, and preferred the baked set with Opus 5.5 High ("especially in terms of 3d volume"), the beacon aside, and with ChatGPT 6.1 Sol High ("more solid and with less perceptive errors"), all eight. One run per side per model. The Opus agent without the command wrote its own renderer anyway, as that game's own scripts had. - **Ritmo:** the design stays open because of a direction from the maintainer: the tool is for consistent and solid results, and must not hold back unique designs. So a model can be any shape in any color, proportions included, with hand-placed pixels on top; what is odd about one comes back as a report; and a piece can leave the model to be finished by hand. +- **Ritmo:** "so is the look" from two tests on 2026-11-11, each one agent given a written brief and no references: an isometric room from rules taken from a pixel artist's making-of, and five buildings of a game from their role and size. Both agents tried the command, judged its shading worse than their own (a box's top took the same ramp step as a side; parts under 4 pixels deep merged; a door on a face came out a blot), and wrote their own projection script to choose tones per face, lines between parts, and details drawn flat on faces. The maintainer liked both results and preferred the five buildings to the game's own reviewed ones. So the command keeps the geometry and leaves those choices to the model, and its map hands the geometry to a script instead of a second renderer. `across` and `down` because both agents had to work a game's own projection back into yaw, elevation, and scale. "Finish by hand the feature that names the piece" from the rerun with these fields: an agent that baked every piece and finished none by hand won the stilts and the quarry (a slab on posts, a stepped crag) and lost the house, the granary, and the ruin, whose features are a curved cap, a gable with its door, and small pillars, to the earlier run that placed those pixels by hand. That agent reported a cone's rim drawn as a V and a roof box's gable ends in roof material. - **Ritmo:** not for characters from the maintainer's rejection of every character sprite tried on 2026-11-02. "Under about 24 pixels across, draw a sprite source" from the same game's buildings baked at half size (18 across), which lost what told them apart, and from a fresh agent given only the docs on 2026-11-08, whose small log ends read only once painted by hand. [saint-consistency]: https://saint11.art/blog/consistency/ diff --git a/packages/core/skills/ritmo-pixel-art/SKILL.md b/packages/core/skills/ritmo-pixel-art/SKILL.md index 30921901..ceda28f9 100644 --- a/packages/core/skills/ritmo-pixel-art/SKILL.md +++ b/packages/core/skills/ritmo-pixel-art/SKILL.md @@ -67,7 +67,7 @@ A `pixel.config.json` at the game's root (`pixel init -p ` writes one) ### Pick the case - **Drawing a sprite with code.** Write a sprite source (below), then `pixel render sprite.txt --scale 8`: it writes `.png`, `@8x.png`, `.svg`, and `.preview.png`, and lints the art. Fix every error in the source, never in the PNG. The preview shows the pixels; whether the sprite reads is judged in a game capture ([Judging](#judging)). -- **Something with a solid shape** (a building, a vehicle, a prop, a turret), above all one of a set or seen from several angles. Describe it in 3D as `.bake.json` and run `pixel bake`: the project's one camera, light, and ramps (`bake` in `pixel.config.json`) keep the set one set, and the design is yours, as exaggerated or odd as the style wants. Change the model, not the PNG; to finish a piece by hand, carry on from its `.sprite.txt` (it then no longer bakes). Not for characters, anything soft, or under about 24 pixels across: draw those as a sprite source. Reference: the package's README, Baking. +- **Something with a solid shape** (a building, a vehicle, a prop, a turret), above all one of a set or seen from several angles. Describe it in 3D as `.bake.json` and run `pixel bake`: the project's one camera, light, and ramps (`bake` in `pixel.config.json`) keep the set one set, and the design is yours, as exaggerated or odd as the style wants. So is the look: the light only fills in what you leave open. Give a part its tones per facing (`steps`) and a `line` where it meets what is behind it, draw a door or a dial flat as a face's `rows`, and with `"map": true` restyle the PNG from a script of your own instead of writing your own renderer. A game that already says where a point lands gives the camera as `across` and `down`. Bake the masses, then finish by hand the feature that names the piece wherever it is curved or a few pixels small (a cap, a door, a gable): a bake draws flat and angular shapes evenly and loses the rest. Change the model, not the PNG; to finish a piece by hand, carry on from its `.sprite.txt` (it then no longer bakes). Not for characters, anything soft, or under about 24 pixels across: draw those as a sprite source. Reference: the package's README, Baking. - **A picture from an image generator.** `pixel check pic.png` first. `fixable` → `pixel fix pic.png` writes the true-size `.png`, `.svg`, a JSON map, a `.sprite.txt` (to edit by text), its `.recipe.json`, and a `.preview.png` (the source and its grid beside the fix). Add `--colors N` when there is no palette, `--remove-background` for a sprite, `--cleanup 1` against noise specks. - **An animation sheet from an image generator** (frames side by side in boxes, often labelled). `pixel sheet sheet.png --colors 16` clears the background and labels, finds the frames, fixes them on one grid and palette, lines them up (by default on the ground: sideways drift removed, hops kept; `--anchor feet` pins the feet to one row), and writes the sheet `.png` and its `.json` (Pixi, Phaser, Aseprite), a `.gif` and an `.onion.png` per animation, `.frames/`, the recipe, and a `.preview.png`. Each row of frames is one animation (`animation-1`, ... in reading order; `animation.animations` in the report lists each one's frame numbers): name them and keep the ones the game uses with `--animations "walk-down=1-4;throw-down=5-8;explode=29-38"` (every frame in one; a row that is not one animation is split or joined here) and `--only walk-down,explode`, then play them by name from the JSON's `animations`. Open the preview: each frame's box must hold the whole frame, and each animation's boxes share a color. Pass `--boxes "x,y,w,h;..."` when one is cut, `--skip 3` to leave a bad frame out, `--anchor center` for things that float, `--fps` and `--pingpong` for the timing, and `--columns N` for rows. diff --git a/packages/tools/README.md b/packages/tools/README.md index f5c1d269..9d658518 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -626,8 +626,10 @@ all one of a set or one seen from several angles. You describe it in 3D, and the tool draws it through the project's one camera and light, so every sprite shares the angle, the shading on the palette's ramps, clean edges, and one outline. The design stays yours: any shapes, any colors, proportions as exaggerated as the -style wants, and hand-placed pixels for what geometry cannot say. What is odd -about a model comes back in the report, never as a refusal. +style wants, and hand-placed pixels for what geometry cannot say. The look can +be yours too: a part can choose its own tones and lines, a face can carry its +own picture, and `map` hands a script of yours what every pixel shows. What is +odd about a model comes back in the report, never as a refusal. Not for characters or anything soft. Below about 24 pixels across, a bake loses what tells things apart: draw those as a sprite source (`pixel render`), from a @@ -688,6 +690,13 @@ Every field has that default except `scale` (1), `outline`, and `materials`. game whose view is not a true projection. A point lands at `X = x cos(yaw) - y sin(yaw)`, `Y = x sin(yaw) + y cos(yaw)`, then across `X * scale` and down `(Y * sin(elevation) - z * heightScale * cos(elevation)) * scale`. + A game that already says where a point lands gives that instead, as `across` + and `down`: art pixels across and down for one unit of x, y, and z. A 2:1 view + is `{ "across": [1, -1, 0], "down": [0.5, 0.5, -1] }`, and a three-quarter + view whose point lands at `x` across and `y * 0.75 - z * 0.5` down is + `{ "across": [1, 0, 0], "down": [0, 0.75, -0.5] }`. Then the four above are + left out, `headings` turn the model before it, and `sun` keeps the picture's + axes. - `light`: `sun` points toward the sun in the screen's axes whatever the `yaw` (`[0.7, 0.05, 0.7]` is the picture's upper right), and stays put as a model turns. A ramp's steps fall evenly from `dark` to `bright` on how directly a @@ -711,6 +720,7 @@ Every field has that default except `scale` (1), `outline`, and `materials`. | `cleanup` | `false` keeps lone pixels. | | `normals` | `true` writes `.normal.png`, from the real surfaces, to light the art in a game; `pixel review` shows it under a light. | | `shadow` | `true` writes `.shadow.png`, the model flattened along the sun, for a game to draw under it. | +| `map` | `true` writes `.map.json`, what every pixel shows: below. | | `paint` | Hand-placed pixels, below. | **Parts.** What is nearest the camera shows, as in 3D, so their order does not @@ -722,7 +732,7 @@ walls around a floor. | `box` | `from` and `to`, two opposite corners. `top: [a, b]` scales its top along x and y: `[1, 0]` is a roof with its ridge along x, `[0, 1]` along y, `[0, 0]` a point, `[0.5, 0.5]` a frustum. `lean: [dx, dy]` moves its top. | | `cylinder` | `from` and `to`, the corners of the box around it. `axis`: `"z"` (default), `"x"`, or `"y"`. `sides` (20; 8 or 10 look faceted). `taper` scales its far end: `0.05` is a cone. | | `sphere` | `from` and `to`, the corners of the box around it. | -| `face` | `corners`: a flat convex polygon, `[x, y, z]` going around its edge. It shows over a surface it lies exactly on: doors, windows, stripes, trim, or one side of a part in its own material. | +| `face` | `corners`: a flat convex polygon, `[x, y, z]` going around its edge. It shows over a surface it lies exactly on: doors, windows, stripes, trim, or one side of a part in its own material. `rows` gives it its own picture, below; then it needs no `material`. | | `mesh` | `points`, and `faces` as lists of positions in `points`. Any shape at all. Each face has to be flat and convex: split one that is not, and the report says which. | Any part, and any group (`"parts": [...]` in place of `shape`) takes these. A @@ -739,6 +749,23 @@ own. A part is animated, turned, then moved, and then its group does the same: | `animate` | `{ "spin": { "rotate": { "axis": "x", "degrees": 90 }, "move": [0, 0, 2], "wave": false } }`: where it gets to at the animation's end, which is never drawn, so a loop closes cleanly. `wave` swings out to it, back through rest, and out the other way: a bob or a sway. | | `detail` | `true` counts it three times when pixels are decided, and keeps its lone pixels. Under about two pixels it still comes out a lone pixel: paint that instead. | | `step` | Holds a ramp on one of its steps, 0 the darkest. | +| `steps` | A step for each way its surfaces face: `{ "top": 2, "right": 1, "left": 0 }`. A top is anything tilted less than 45 degrees from flat; a side faces left or right on the picture. A facing left out takes the light. | +| `line` | Where it meets a part behind it, a line on that part: `true` in the outline's color, `"step"` one step darker, or a color. A roof from its walls, a lid from its box. `false` turns off one its group set. | + +A part with `step`, `steps`, or `rows` keeps the tones it chose: `edges` and +`castShadows` leave it alone. + +**A face's own picture.** A door, a window, planks, a clock's dial: draw it flat, +as seen from the front, and the bake lays it on the face, so it turns with the +model and its steps follow the face's slant. `rows` are letters, each a +one-color material, and `.` shows what is behind the face. The rows are spread +over the whole face, the first at its top and the first letter at its left as +seen from the viewer's side; on a top, rows run from -y to +y and letters from +-x to +x. As many letters as the face is pixels wide keeps one letter a pixel. + +```json +{ "name": "door", "shape": "face", "corners": [[-2, 5, 0], [2, 5, 0], [2, 5, 6], [-2, 5, 6]], "rows": ["WWW", "WYW", "WWW"] } +``` **Paint** is for what geometry cannot say: a flame, a flag, a face, the one pixel that makes a shape read. It bakes with the model every time. @@ -754,6 +781,24 @@ the origin in the PNG, so the PNG's pixel at column `c` and row `r` is leaves what is there, and `_` clears it. The outline goes around paint; `"after": true` puts it over the outline instead. +**The map.** With `"map": true`, `.map.json` says what every pixel of the +PNG shows, laid out as the PNG is (a sheet's frames included), so a script of +yours can give the art its own look: recolor by part and facing, draw lines +where faces meet, light it its own way. Pixel `i` is at column `i % width` and +row `Math.floor(i / width)`. + +| Field | What it is | +| --- | --- | +| `image`, `width`, `height` | The PNG it maps. | +| `parts` | Every drawn copy of a part: its `path` in the model, `name`, and `material`. | +| `facing` | One row of text per row of pixels: `T` top, `L` left, `R` right, `.` nothing. | +| `part` | Each pixel's place in `parts`, or -1. | +| `face` | Which flat face of the model, so two faces of one part can be told apart. | +| `depth` | Art pixels toward the viewer from the model's origin: more is nearer. | +| `u`, `v` | Where on its face, from 0 to 1: `u` from its left, `v` from its top, as a face's `rows` are laid. | + +The map is the geometry: the outline, `line`, and `paint` are not in it. + **What it writes, and the report.** `.png`, `.preview.png`, and for a still `.sprite.txt`. Several frames make a sheet instead: `.json` in the format `pixel sheet` writes (each frame's `anchor` is the model's origin, and diff --git a/packages/tools/src/pixel/cli/commands.ts b/packages/tools/src/pixel/cli/commands.ts index ba74aa1c..528eebc8 100644 --- a/packages/tools/src/pixel/cli/commands.ts +++ b/packages/tools/src/pixel/cli/commands.ts @@ -606,6 +606,12 @@ const staleBakeFiles = Effect.fn("staleBakeFiles")(function* (base: string, mode if (!isWritten(sprite) && (yield* exists(sprite)) && (yield* text(sprite)).includes(`Baked from ${model}:`)) stale.push(sprite) const shadow = `${base}.shadow.png` if (!isWritten(shadow) && (yield* exists(shadow))) stale.push(shadow) + const map = `${base}.map.json` + if (!isWritten(map) && (yield* exists(map))) { + const raw = yield* text(map) + const image = yield* Effect.try((): unknown => (JSON.parse(raw) as { image?: unknown } | null)?.image).pipe(Effect.orElseSucceed(() => undefined)) + if (image === `${name}.png`) stale.push(map) + } const normal = `${base}.normal.png` if (!isWritten(normal) && (yield* exists(normal)) && !(yield* exists(`${base}.normals.json`))) stale.push(normal) const dir = path.dirname(base) @@ -661,6 +667,38 @@ const bake = Command.make( files.normal = yield* writePng(`${base}.normal.png`, { width: atlas.art.width, height: atlas.art.height, data }) } if (model.shadow) files.shadow = yield* writePng(`${base}.shadow.png`, toRgba(atlasPick((frame) => frame.shadow).art)) + if (model.map) { + // The geometry under every pixel of the PNG, laid out as it is, for a script that gives the art its own look. + const width = atlas.art.width + const pixelsOf = (fill: number) => new Array(width * atlas.art.height).fill(fill) + const part = pixelsOf(-1) + const face = pixelsOf(-1) + const depth = pixelsOf(0) + const u = pixelsOf(0) + const v = pixelsOf(0) + const facing = Array.from({ length: atlas.art.height }, () => new Array(width).fill(".")) + const placed = result.clips.flatMap((clip) => clip.frames) + atlas.rects.forEach((rect, i) => { + const map = placed[i]!.map + for (let y = 0; y < rect.h; y++) { + for (let x = 0; x < rect.w; x++) { + const from = y * rect.w + x + if (map.part[from]! < 0) continue + const to = (rect.y + y) * width + rect.x + x + part[to] = map.part[from]! + face[to] = map.face[from]! + depth[to] = map.depth[from]! + u[to] = map.u[from]! + v[to] = map.v[from]! + facing[rect.y + y]![rect.x + x] = ".TLR"[map.facing[from]!]! + } + } + }) + files.map = yield* writeText( + `${base}.map.json`, + `${JSON.stringify({ image: `${name}.png`, width, height: atlas.art.height, parts: result.partsShown, facing: facing.map((row) => row.join("")), part, face, depth, u, v })}\n` + ) + } const moving = clips.filter((clip) => clip.frames.length > 1) if (moving.length > 0) { const gifs: Array = [] @@ -712,7 +750,7 @@ const bake = Command.make( }) ).pipe( Command.withDescription( - "Draws a model of 3D shapes (`.bake.json`) through the project's one camera and light: `.png`, `.sprite.txt` for a still or `.json` for a sheet, and `.preview.png`, with `.normal.png` and `.shadow.png` when the model asks for them. For things with a solid shape, seen from any angle; the design is the model's." + "Draws a model of 3D shapes (`.bake.json`) through the project's one camera and light: `.png`, `.sprite.txt` for a still or `.json` for a sheet, and `.preview.png`, with `.normal.png`, `.shadow.png`, and `.map.json` (what every pixel shows) when the model asks for them. For things with a solid shape, seen from any angle; the design is the model's." ) ) diff --git a/packages/tools/src/pixel/core/bake.ts b/packages/tools/src/pixel/core/bake.ts index b5c2897b..4ea9523a 100644 --- a/packages/tools/src/pixel/core/bake.ts +++ b/packages/tools/src/pixel/core/bake.ts @@ -37,6 +37,12 @@ export interface BakeSetup { readonly scale: number /** Heights are multiplied by this, for a game whose view is not a true projection. */ readonly heightScale: number + /** + * The camera in a game's own terms, in place of the four above: how many art + * pixels across and down one unit of x, y, and z moves a point. A 2:1 view + * is `{ across: [1, -1, 0], down: [0.5, 0.5, -1] }`. + */ + readonly axes: { readonly across: Vec3; readonly down: Vec3 } | undefined } readonly light: { /** Toward the sun, in the screen's axes. */ @@ -57,7 +63,7 @@ export interface BakeSetup { } export const defaultBakeSetup: BakeSetup = { - camera: { yaw: 45, elevation: 30, scale: 1, heightScale: 1 }, + camera: { yaw: 45, elevation: 30, scale: 1, heightScale: 1, axes: undefined }, light: { sun: [-0.7, 0.05, 0.7], dark: 0, bright: 0.6, castShadows: false }, outline: undefined, edges: 4, @@ -87,6 +93,12 @@ export interface PartMotion { readonly wave: boolean } +/** Which way a surface faces in the picture: up, or the side that faces left or right. */ +export type Facing = "top" | "left" | "right" + +/** A line where a part meets what is behind it: the outline's color, one step darker, or a color. */ +export type PartLine = true | "step" | string + interface NodeBase { /** Where it is in the file, for the report. */ readonly path: string @@ -94,6 +106,10 @@ interface NodeBase { readonly material: string | undefined readonly detail: boolean readonly step: number | undefined + /** A ramp's step for each way its surfaces face, in place of the light. */ + readonly steps: Readonly>> | undefined + /** False for none, undefined for its group's. */ + readonly line: PartLine | false | undefined readonly move: Vec3 | undefined readonly rotate: Rotation | undefined readonly repeat: { readonly count: number; readonly move: Vec3 | undefined; readonly rotate: Rotation | undefined; readonly materials: ReadonlyArray | undefined } | undefined @@ -105,7 +121,14 @@ export type BakeNode = | (NodeBase & { readonly kind: "box"; readonly from: Vec3; readonly to: Vec3; readonly top: readonly [number, number]; readonly lean: readonly [number, number] }) | (NodeBase & { readonly kind: "cylinder"; readonly from: Vec3; readonly to: Vec3; readonly axis: "x" | "y" | "z"; readonly sides: number; readonly taper: number }) | (NodeBase & { readonly kind: "sphere"; readonly from: Vec3; readonly to: Vec3 }) - | (NodeBase & { readonly kind: "mesh"; readonly points: ReadonlyArray; readonly faces: ReadonlyArray>; readonly lifted: boolean }) + | (NodeBase & { + readonly kind: "mesh" + readonly points: ReadonlyArray + readonly faces: ReadonlyArray> + readonly lifted: boolean + /** A face's own picture: rows of letters laid over it, the first row at its top and the first letter at its left. */ + readonly rows: ReadonlyArray | undefined + }) export interface BakePaint { /** Art pixels from the anchor to its first row's first pixel, x right and y down. */ @@ -127,6 +150,8 @@ export interface BakeModel { readonly cleanup: boolean readonly normals: boolean readonly shadow: boolean + /** Writes `.map.json`: what every pixel shows, for a script that gives the art its own look. */ + readonly map: boolean readonly parts: ReadonlyArray readonly paint: ReadonlyArray } @@ -148,8 +173,9 @@ const isCount = (value: unknown): value is number => isNumber(value) && Number.i class Reader { readonly problems: Array = [] - fail(path: string, message: string): void { + fail(path: string, message: string): undefined { this.problems.push({ path, message }) + return undefined } fields(raw: unknown, path: string, allowed: ReadonlyArray, hints: Readonly> = {}): Fields { @@ -246,19 +272,33 @@ export const parseBakeSetup = (raw: unknown): Parsed => { if (raw === undefined) return { ok: true, value: defaultBakeSetup } const read = new Reader() const fields = read.fields(raw, "bake", ["camera", "light", "outline", "edges", "materials"]) - const camera = read.fields(fields.camera ?? {}, "bake.camera", ["yaw", "elevation", "scale", "heightScale"]) + const camera = read.fields(fields.camera ?? {}, "bake.camera", ["yaw", "elevation", "scale", "heightScale", "across", "down"]) const light = read.fields(fields.light ?? {}, "bake.light", ["sun", "dark", "bright", "castShadows"]) const base = defaultBakeSetup const positive = (n: number) => (n > 0 ? undefined : "must be above 0") const sun = light.sun === undefined ? base.light.sun : read.vector(light.sun, "bake.light.sun") if (sun !== undefined && sun[2] <= 0) read.fail("bake.light.sun", "must be above the ground: its z above 0") const edges = fields.edges === false ? false : read.number(fields.edges, "bake.edges", base.edges as number, positive) + let axes: BakeSetup["camera"]["axes"] + if (camera.across !== undefined || camera.down !== undefined) { + const turned = ["yaw", "elevation", "scale", "heightScale"].filter((key) => camera[key] !== undefined) + if (turned.length > 0) read.fail("bake.camera", `gives \`across\` and \`down\` with ${turned.map((key) => `\`${key}\``).join(", ")}: use one or the other`) + const across = camera.across === undefined ? read.fail("bake.camera.across", "must be given with `down`: art pixels across for one unit of x, y, and z, like [1, -1, 0]") : read.vector(camera.across, "bake.camera.across") + const down = camera.down === undefined ? read.fail("bake.camera.down", "must be given with `across`: art pixels down for one unit of x, y, and z, like [0.5, 0.5, -1]") : read.vector(camera.down, "bake.camera.down") + if (across !== undefined && down !== undefined) { + if (Math.hypot(...cross(across, down)) < 1e-9) read.fail("bake.camera", "has `across` and `down` along one line: the picture would be flat") + else if (Math.hypot(across[0], across[1]) < 1e-9) read.fail("bake.camera.across", "must move a point across the picture along the ground: its x or y not 0") + else if (down[2] >= 0) read.fail("bake.camera.down", "must move a point up the picture as z grows: its z below 0, like -1") + axes = { across, down } + } + } const value: BakeSetup = { camera: { yaw: read.number(camera.yaw, "bake.camera.yaw", base.camera.yaw), elevation: read.number(camera.elevation, "bake.camera.elevation", base.camera.elevation, (n) => (n > 0 && n <= 90 ? undefined : "must be above 0 and up to 90 degrees")), scale: read.number(camera.scale, "bake.camera.scale", base.camera.scale, positive), - heightScale: read.number(camera.heightScale, "bake.camera.heightScale", base.camera.heightScale, positive) + heightScale: read.number(camera.heightScale, "bake.camera.heightScale", base.camera.heightScale, positive), + axes }, light: { sun: sun ?? base.light.sun, @@ -275,13 +315,17 @@ export const parseBakeSetup = (raw: unknown): Parsed => { } const SHAPES = ["box", "cylinder", "sphere", "face", "mesh"] as const -const COMMON = ["name", "material", "detail", "step", "move", "rotate", "repeat", "animate"] +const COMMON = ["name", "material", "detail", "step", "steps", "line", "move", "rotate", "repeat", "animate"] +const FACINGS: ReadonlyArray = ["top", "left", "right"] const MISTAKES: Readonly> = { at: "is not a field: give `from` and `to`, two opposite corners", size: "is not a field: give `from` and `to`, two opposite corners", color: "is not a field: use `material`, a color like \"#ef7d57\" or a material's name", flat: "is not a field: use `step`, which keeps a ramp on one of its steps", - spin: "is not a field: use `animate`, with a motion for each animation's name" + spin: "is not a field: use `animate`, with a motion for each animation's name", + lines: "is not a field: use `line`", + outline: "is not a field on a part: use `line`, where it meets what is behind it", + paint: "is not a field on a part: give a `face` its own `rows`, or paint the model's pixels with the model's `paint`" } const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]] @@ -356,12 +400,33 @@ const parseNode = (read: Reader, raw: unknown, path: string, material: boolean, : shape === "sphere" ? ["shape", "from", "to"] : shape === "face" - ? ["shape", "corners"] + ? ["shape", "corners", "rows"] : ["shape", "points", "faces"] const fields = read.fields(raw, path, [...own, ...COMMON], MISTAKES) if (fields.material !== undefined) knownMaterial(read, fields.material, `${path}.material`, context) if (fields.name !== undefined && typeof fields.name !== "string") read.fail(`${path}.name`, "must be text") - if (fields.step !== undefined && !(isNumber(fields.step) && Number.isInteger(fields.step) && fields.step >= 0)) read.fail(`${path}.step`, "must be a ramp's step: 0 is its darkest") + const isStep = (n: unknown): n is number => isNumber(n) && Number.isInteger(n) && n >= 0 + if (fields.step !== undefined && !isStep(fields.step)) read.fail(`${path}.step`, "must be a ramp's step: 0 is its darkest") + let steps: NodeBase["steps"] + if (fields.steps !== undefined) { + const raw = read.fields(fields.steps, `${path}.steps`, FACINGS) + const chosen: Partial> = {} + for (const facing of FACINGS) { + const n = raw[facing] + if (n === undefined) continue + if (isStep(n)) chosen[facing] = n + else read.fail(`${path}.steps.${facing}`, "must be a ramp's step: 0 is its darkest") + } + steps = chosen + } + let line: NodeBase["line"] + if (fields.line !== undefined) { + const raw = fields.line + const rgb = typeof raw === "string" && raw !== "step" ? parseHex(raw) : undefined + if (raw === true || raw === false || raw === "step") line = raw + else if (rgb !== undefined) line = toHex(rgb) + else read.fail(`${path}.line`, 'must be true (the outline\'s color), "step" (one step darker), a color like "#1a1c2c", or false for none') + } let repeat: NodeBase["repeat"] if (fields.repeat !== undefined) { const r = read.fields(fields.repeat, `${path}.repeat`, ["count", "move", "rotate", "materials"]) @@ -395,6 +460,8 @@ const parseNode = (read: Reader, raw: unknown, path: string, material: boolean, material: typeof fields.material === "string" ? fields.material : undefined, detail: read.boolean(fields.detail, `${path}.detail`, false), step: isNumber(fields.step) ? fields.step : undefined, + steps, + line, move: fields.move === undefined ? undefined : read.vector(fields.move, `${path}.move`), rotate: fields.rotate === undefined ? undefined : read.rotation(fields.rotate, `${path}.rotate`), repeat, @@ -409,7 +476,7 @@ const parseNode = (read: Reader, raw: unknown, path: string, material: boolean, const parts = fields.parts.flatMap((part, i) => parseNode(read, part, `${path}.parts[${i}]`, painted, context) ?? []) return { ...base, kind: "group", parts } } - if (!painted) read.fail(`${path}.material`, 'is missing: a color like "#ef7d57", a material\'s name, or one set on its group') + if (!painted && !(shape === "face" && fields.rows !== undefined)) read.fail(`${path}.material`, 'is missing: a color like "#ef7d57", a material\'s name, or one set on its group') if (shape === "face" || shape === "mesh") { const list = shape === "face" ? fields.corners : fields.points const name = shape === "face" ? "corners" : "points" @@ -423,7 +490,19 @@ const parseNode = (read: Reader, raw: unknown, path: string, material: boolean, if (shape === "face") { const wrong = polygonProblem(points) if (wrong !== undefined) read.fail(`${path}.corners`, wrong) - return { ...base, kind: "mesh", points, faces: [points.map((_, i) => i)], lifted: true } + let rows: ReadonlyArray | undefined + if (fields.rows !== undefined) { + const lines = Array.isArray(fields.rows) && fields.rows.length > 0 && fields.rows.every((row: unknown) => typeof row === "string" && row.length > 0) ? (fields.rows as ReadonlyArray) : undefined + if (lines === undefined) read.fail(`${path}.rows`, 'must be rows of text laid over the face, one letter per cell: "." shows what is behind it, and any other letter is a one-color material of that name') + else { + if (lines.some((row) => [...row].length !== [...lines[0]!].length)) read.fail(`${path}.rows`, "must all be as long as the first row") + for (const letter of new Set(lines.join(""))) { + if (letter !== "." && context.known[letter]?.length !== 1) read.fail(`${path}.rows`, `uses "${letter}", which needs a one-color material of that name in \`materials\`, like { "${letter}": "#ffcd75" }`) + } + rows = lines + } + } + return { ...base, kind: "mesh", points, faces: [points.map((_, i) => i)], lifted: true, rows } } if (!Array.isArray(fields.faces)) { read.fail(`${path}.faces`, "must be a list of faces, each a list of positions in `points`") @@ -439,7 +518,7 @@ const parseNode = (read: Reader, raw: unknown, path: string, material: boolean, if (wrong !== undefined) read.fail(`${path}.faces[${i}]`, wrong) faces.push(face) }) - return { ...base, kind: "mesh", points, faces, lifted: false } + return { ...base, kind: "mesh", points, faces, lifted: false, rows: undefined } } const from = read.vector(fields.from, `${path}.from`) const to = read.vector(fields.to, `${path}.to`) @@ -475,7 +554,7 @@ export const MAX_BAKE_SIDE = 1024 /** A model whose frame is past `MAX_BAKE_SIDE`: said before anything is drawn, which would run out of memory. */ export class BakeTooLarge extends Error {} -const MODEL_FIELDS = ["headings", "animations", "frame", "materials", "outline", "cleanup", "normals", "shadow", "parts", "paint"] +const MODEL_FIELDS = ["headings", "animations", "frame", "materials", "outline", "cleanup", "normals", "shadow", "map", "parts", "paint"] /** Reads a model, with every problem found. `setup` holds the materials it can name. */ export const parseBakeModel = (text: string, setup: BakeSetup): Parsed => { @@ -553,6 +632,7 @@ export const parseBakeModel = (text: string, setup: BakeSetup): Parsed readonly detail: boolean readonly step: number | undefined + readonly steps: Readonly>> | undefined + readonly line: PartLine | undefined readonly lifted: boolean + /** A face's own picture: per row, per letter, its step in `ramp`, or -1 to show what is behind. */ + readonly cells: ReadonlyArray> | undefined } +/** A part's tones are its own: the light, cast shadows, and `edges` leave them alone. */ +const chosenTones = (piece: Piece): boolean => piece.step !== undefined || piece.steps !== undefined || piece.cells !== undefined + interface Moment { readonly animation: string | undefined /** How far through the animation: 0 up to, not including, 1. */ @@ -694,38 +785,69 @@ const nodeMatrix = (node: BakeNode, moment: Moment): Matrix => { interface View { readonly triangles: ReadonlyArray readonly pieces: ReadonlyArray + /** Every flat face's corners, by the number its triangles carry. */ + readonly faces: ReadonlyArray> } /** Every triangle of a model at one moment, in model coordinates with heights scaled. */ const flatten = (model: BakeModel, moment: Moment, resolve: (name: string) => ReadonlyArray, heightScale: number): View => { const triangles: Array = [] const pieces: Array = [] - // A group's material, detail, and step go to its parts that have none of their own. - const visit = (node: BakeNode, parent: Matrix, material: string | undefined, detail: boolean, step: number | undefined): void => { + const faces: Array> = [] + interface Inherited { + readonly material: string | undefined + readonly detail: boolean + readonly step: number | undefined + readonly steps: Piece["steps"] + readonly line: PartLine | false | undefined + } + // A group's material, detail, step, steps, and line go to its parts that have none of their own. + const visit = (node: BakeNode, parent: Matrix, inherited: Inherited): void => { const count = node.repeat?.count ?? 1 for (let copy = 0; copy < count; copy++) { let placed = parent if (node.repeat?.move !== undefined) placed = multiply(placed, translation(mul(node.repeat.move, copy))) if (node.repeat?.rotate !== undefined) placed = multiply(placed, rotationMatrix(node.repeat.rotate, copy)) placed = multiply(placed, nodeMatrix(node, moment)) - const chosen = node.repeat?.materials?.[copy % node.repeat.materials.length] ?? node.material ?? material + const own: Inherited = { + material: node.repeat?.materials?.[copy % node.repeat.materials.length] ?? node.material ?? inherited.material, + detail: node.detail || inherited.detail, + step: node.step ?? inherited.step, + steps: node.steps ?? inherited.steps, + line: node.line ?? inherited.line + } if (node.kind === "group") { - for (const part of node.parts) visit(part, placed, chosen, node.detail || detail, node.step ?? step) + for (const part of node.parts) visit(part, placed, own) continue } const part = pieces.length - pieces.push({ source: node.path, name: node.name, ramp: resolve(chosen!), detail: node.detail || detail, step: node.step ?? step, lifted: node.kind === "mesh" && node.lifted }) + const rows = node.kind === "mesh" ? node.rows : undefined + const letters = rows === undefined ? [] : [...new Set(rows.join(""))].filter((letter) => letter !== ".") + pieces.push({ + source: node.path, + name: node.name, + material: rows === undefined ? own.material : undefined, + ramp: rows === undefined ? resolve(own.material!) : letters.map((letter) => resolve(letter)[0]!), + detail: own.detail, + step: own.step, + steps: own.steps, + line: own.line === false ? undefined : own.line, + lifted: node.kind === "mesh" && node.lifted, + cells: rows?.map((row) => [...row].map((letter) => letters.indexOf(letter))) + }) for (const polygon of polygonsOf(node)) { const points = polygon.map((point) => { const p = apply(placed, point) return [p[0], p[1], p[2] * heightScale] as Vec3 }) - for (let i = 1; i + 1 < points.length; i++) triangles.push({ a: points[0]!, b: points[i]!, c: points[i + 1]!, part }) + const face = faces.length + faces.push(points) + for (let i = 1; i + 1 < points.length; i++) triangles.push({ a: points[0]!, b: points[i]!, c: points[i + 1]!, part, face }) } } } - for (const node of model.parts) visit(node, IDENTITY, undefined, false, undefined) - return { triangles, pieces } + for (const node of model.parts) visit(node, IDENTITY, { material: undefined, detail: false, step: undefined, steps: undefined, line: undefined }) + return { triangles, pieces, faces } } // --------------------------------------------------------------------------- @@ -736,6 +858,7 @@ export interface BakedFrame { /** The directions, as `pixel normals` writes them: x right, y up, z to the viewer. */ readonly normal: RgbaImage readonly shadow: PixelArt + readonly map: FrameMap } export interface BakedClip { @@ -757,6 +880,8 @@ export interface BakeResult { /** How many art pixels the art reaches past a frame the model set. */ readonly cut: { readonly left: number; readonly right: number; readonly up: number; readonly below: number } readonly parts: number + /** Every drawn copy of a part, in the order `FrameMap.part` counts them. */ + readonly partsShown: ReadonlyArray<{ readonly path: string; readonly name: string | undefined; readonly material: string | undefined }> } /** Samples per art pixel across. Enough that a part's edge lands within a pixel's vote. */ @@ -767,7 +892,7 @@ const CLEANUP_PASSES = 4 /** How far, in art pixels of depth, a `face` draws in front of what it lies on. */ const FACE_LIFT = 0.12 -const fillTriangle = (a: Vec3, b: Vec3, c: Vec3, width: number, height: number, put: (x: number, y: number, depth: number) => void): void => { +const fillTriangle = (a: Vec3, b: Vec3, c: Vec3, width: number, height: number, put: (x: number, y: number, depth: number, wa: number, wb: number, wc: number) => void): void => { const area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) if (Math.abs(area) < 1e-9) return const x0 = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]))) @@ -782,7 +907,7 @@ const fillTriangle = (a: Vec3, b: Vec3, c: Vec3, width: number, height: number, const w1 = ((c[0] - px) * (a[1] - py) - (c[1] - py) * (a[0] - px)) / area const w2 = 1 - w0 - w1 if (w0 < -1e-6 || w1 < -1e-6 || w2 < -1e-6) continue - put(x, y, w0 * a[2] + w1 * b[2] + w2 * c[2]) + put(x, y, w0 * a[2] + w1 * b[2] + w2 * c[2], w0, w1, w2) } } } @@ -802,9 +927,65 @@ interface Camera { readonly toView: (n: Vec3) => Vec3 /** The point in rotated coordinates under art pixels from the anchor, at a depth. */ readonly fromScreen: (x: number, y: number, depth: number) => Vec3 + /** Toward the viewer, in rotated coordinates. */ + readonly view: Vec3 +} + +/** About how many art pixels one unit of the model covers: what depths in art pixels are measured by. */ +const unitPixels = (setup: BakeSetup): number => { + const axes = setup.camera.axes + if (axes === undefined) return setup.camera.scale + return Math.max(Math.hypot(axes.across[0], axes.down[0]), Math.hypot(axes.across[1], axes.down[1])) +} + +/** + * The sun in the coordinates a camera draws from. It is given in the picture's + * axes (+x right, +y toward the viewer, +z up), which the turned camera draws + * from already; `axes` draws from the model's, so it is turned to match. + */ +const sunOf = (setup: BakeSetup): Vec3 => { + const sun = unit(setup.light.sun) + const axes = setup.camera.axes + if (axes === undefined) return sun + const right = unit([axes.across[0], axes.across[1], 0]) + const toward = cross(axes.across, axes.down) + let near: Vec3 = [-right[1], right[0], 0] + if (dot(near, toward[2] < 0 ? mul(toward, -1) : toward) < 0) near = mul(near, -1) + return unit(add(add(mul(right, sun[0]), mul(near, sun[1])), [0, 0, sun[2]])) +} + +/** Heights a model's points are multiplied by before the camera: `axes` carries its own. */ +const heightScaleOf = (setup: BakeSetup): number => (setup.camera.axes === undefined ? setup.camera.heightScale : 1) + +/** The inverse of three rows, as a function. */ +const solve3 = (r0: Vec3, r1: Vec3, r2: Vec3): ((b: Vec3) => Vec3) => { + const c0 = cross(r1, r2) + const c1 = cross(r2, r0) + const c2 = cross(r0, r1) + const det = dot(r0, c0) + return (b) => mul(add(add(mul(c0, b[0]), mul(c1, b[1])), mul(c2, b[2])), 1 / det) } const cameraOf = (setup: BakeSetup, heading: number, headings: number): Camera => { + const axes = setup.camera.axes + if (axes !== undefined) { + // Each heading turns the model about z before the game's own projection. + const turn = ((360 * heading) / headings) * (Math.PI / 180) + const cosT = Math.cos(turn) + const sinT = Math.sin(turn) + const toward = cross(axes.across, axes.down) + const view = unit(toward[2] < 0 ? mul(toward, -1) : toward) + const right = unit(sub(axes.across, mul(view, dot(axes.across, view)))) + const up = unit(mul(sub(axes.down, mul(view, dot(axes.down, view))), -1)) + const back = solve3(axes.across, axes.down, view) + return { + toRotated: (p) => [p[0] * cosT - p[1] * sinT, p[0] * sinT + p[1] * cosT, p[2]], + project: (p) => ({ x: dot(axes.across, p), y: dot(axes.down, p), depth: dot(view, p) }), + toView: (n) => [dot(n, right), dot(n, up), dot(n, view)], + fromScreen: (x, y, depth) => back([x, y, depth]), + view + } + } const yaw = ((setup.camera.yaw + (360 * heading) / headings) * Math.PI) / 180 const elevation = (setup.camera.elevation * Math.PI) / 180 const cosYaw = Math.cos(yaw) @@ -816,7 +997,8 @@ const cameraOf = (setup: BakeSetup, heading: number, headings: number): Camera = toRotated: (p) => [p[0] * cosYaw - p[1] * sinYaw, p[0] * sinYaw + p[1] * cosYaw, p[2]], project: (p) => ({ x: p[0] * scale, y: (p[1] * sinE - p[2] * cosE) * scale, depth: p[1] * cosE + p[2] * sinE }), toView: (n) => [n[0], n[2] * cosE - n[1] * sinE, n[1] * cosE + n[2] * sinE], - fromScreen: (x, y, depth) => [x / scale, (y / scale) * sinE + depth * cosE, depth * sinE - (y / scale) * cosE] + fromScreen: (x, y, depth) => [x / scale, (y / scale) * sinE + depth * cosE, depth * sinE - (y / scale) * cosE], + view: [0, cosE, sinE] } } @@ -863,12 +1045,68 @@ const castMap = (triangles: ReadonlyArray, sun: Vec3, scale: number): } } +/** + * A flat face's own grid, seen from the viewer's side: which way it faces, and + * where a point sits on it, from 0 at its left to 1 at its right (`u`) and from + * 0 at its top to 1 at its bottom (`v`). A side's top is up; a top's rows run + * along the model's y from -y, and its letters along x from -x, so its + * picture turns with the model. + */ +interface FaceGrid { + readonly facing: Facing + readonly u: (p: Vec3) => number + readonly v: (p: Vec3) => number +} + +const faceGridOf = (points: ReadonlyArray, camera: Camera): FaceGrid => { + let normal: Vec3 = [0, 0, 0] + for (let i = 0; i < points.length; i++) normal = add(normal, cross(points[i]!, points[(i + 1) % points.length]!)) + let n = unit(normal) + if (dot(n, camera.view) < 0) n = mul(n, -1) + const top = n[2] > Math.hypot(n[0], n[1]) + const facing: Facing = top ? "top" : camera.toView(n)[0] < 0 ? "left" : "right" + let across: Vec3 + let downward: Vec3 + if (top) { + across = camera.toRotated([1, 0, 0]) + downward = camera.toRotated([0, 1, 0]) + } else { + const up = unit(sub([0, 0, 1], mul(n, n[2]))) + across = cross(n, up) + downward = mul(up, -1) + } + const us = points.map((p) => dot(p, across)) + const vs = points.map((p) => dot(p, downward)) + const u0 = Math.min(...us) + const v0 = Math.min(...vs) + const uSpan = Math.max(...us) - u0 || 1 + const vSpan = Math.max(...vs) - v0 || 1 + return { facing, u: (p) => (dot(p, across) - u0) / uSpan, v: (p) => (dot(p, downward) - v0) / vSpan } +} + +/** What a baked frame shows at each pixel, before its outline, lines, and paint. */ +export interface FrameMap { + /** Which part, by its place in `BakeResult.partsShown`, or -1 for none. */ + readonly part: Int32Array + /** Which flat face, counted across the model, or -1. */ + readonly face: Int32Array + /** 0 for none, then top, left, right. */ + readonly facing: Uint8Array + /** Art pixels toward the viewer from the model's origin. */ + readonly depth: Float32Array + readonly u: Float32Array + readonly v: Float32Array +} + +const FACING_CODE: Readonly> = { top: 1, left: 2, right: 3 } + /** Draws every view of a model. */ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: ReadonlyArray): BakeResult => { const materials = { ...setup.materials, ...model.materials } const resolve = (name: string): ReadonlyArray => materials[name] ?? [toHex(parseHex(name)!)] - const heightScale = setup.camera.heightScale - const sun = unit(setup.light.sun) + const heightScale = heightScaleOf(setup) + const pixelsPerUnit = unitPixels(setup) + const sun = sunOf(setup) const moments: ReadonlyArray<{ readonly clip: BakeAnimation | undefined; readonly frame: number; readonly moment: Moment }> = model.animations.length === 0 ? [{ clip: undefined, frame: 0, moment: { animation: undefined, time: 0 } }] @@ -962,6 +1200,8 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl // Every color first, so the darkest is settled and is the same in every frame. for (const { moment } of moments) for (const piece of viewOf(moment).pieces) for (const color of piece.ramp) indexOf(color) const outlineColor = outlineSetting === false ? undefined : outlineSetting !== undefined ? indexOf(outlineSetting) : darkestOf(paletteColors !== undefined && paletteColors.length > 0 ? paletteColors : palette) + // `line: true` takes the outline's color; with no outline, the darkest. + const lineColor = outlineColor ?? darkestOf(paletteColors !== undefined && paletteColors.length > 0 ? paletteColors : palette) const sampleWidth = width * SAMPLES const sampleHeight = height * SAMPLES const shown = new Map() @@ -969,12 +1209,16 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl const draw = (heading: number, clip: BakeAnimation | undefined, frame: number, moment: Moment): BakedFrame => { const camera = cameraOf(setup, heading, model.headings) const view = viewOf(moment) - const rotated = view.triangles.map((t) => ({ a: camera.toRotated(t.a), b: camera.toRotated(t.b), c: camera.toRotated(t.c), part: t.part })) - const casts = setup.light.castShadows ? castMap(rotated, sun, setup.camera.scale) : undefined + const rotated = view.triangles.map((t) => ({ a: camera.toRotated(t.a), b: camera.toRotated(t.b), c: camera.toRotated(t.c), part: t.part, face: t.face })) + const casts = setup.light.castShadows ? castMap(rotated, sun, pixelsPerUnit) : undefined const depth = new Float32Array(sampleWidth * sampleHeight).fill(-Infinity) const owner = new Int32Array(sampleWidth * sampleHeight).fill(-1) const stepAt = new Uint8Array(sampleWidth * sampleHeight) const normalAt = new Float32Array(sampleWidth * sampleHeight * 3) + const faceAt = new Int32Array(sampleWidth * sampleHeight).fill(-1) + const uAt = new Float32Array(sampleWidth * sampleHeight) + const vAt = new Float32Array(sampleWidth * sampleHeight) + const grids = view.faces.map((points) => faceGridOf(points.map(camera.toRotated), camera)) const toSample = (p: Vec3): Vec3 => { const q = camera.project(p) return [(q.x + left) * SAMPLES, (q.y + up) * SAMPLES, q.depth] @@ -988,17 +1232,37 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl facing = mul(facing, -1) } const piece = view.pieces[t.part]! + const grid = grids[t.face]! const light = dot(world, sun) - const shade = Math.min(piece.ramp.length - 1, piece.step ?? stepOfLight(light, piece.ramp.length, setup.light.dark, setup.light.bright)) + const chosen = piece.steps?.[grid.facing] ?? piece.step + const shade = Math.min(piece.ramp.length - 1, chosen ?? stepOfLight(light, piece.ramp.length, setup.light.dark, setup.light.bright)) // A face on a surface shows over it: a door drawn exactly on its wall needs no gap. - const lift = piece.lifted ? FACE_LIFT / setup.camera.scale : 0 - fillTriangle(toSample(t.a), toSample(t.b), toSample(t.c), sampleWidth, sampleHeight, (x, y, d) => { + const lift = piece.lifted ? FACE_LIFT / pixelsPerUnit : 0 + const ua = grid.u(t.a) + const ub = grid.u(t.b) + const uc = grid.u(t.c) + const va = grid.v(t.a) + const vb = grid.v(t.b) + const vc = grid.v(t.c) + const cells = piece.cells + fillTriangle(toSample(t.a), toSample(t.b), toSample(t.c), sampleWidth, sampleHeight, (x, y, d, wa, wb, wc) => { const i = y * sampleWidth + x if (d + lift <= depth[i]!) return + const u = wa * ua + wb * ub + wc * uc + const v = wa * va + wb * vb + wc * vc + let step = shade + if (cells !== undefined) { + const row = cells[Math.min(cells.length - 1, Math.max(0, Math.floor(v * cells.length)))]! + const cell = row[Math.min(row.length - 1, Math.max(0, Math.floor(u * row.length)))]! + // "." shows what is behind the face. + if (cell < 0) return + step = cell + } else if (casts !== undefined && !chosenTones(piece) && light > 0.02 && casts(camera.fromScreen((x + 0.5) / SAMPLES - left, (y + 0.5) / SAMPLES - up, d), t.part)) step = Math.max(0, step - 1) depth[i] = d + lift owner[i] = t.part - let step = shade - if (casts !== undefined && piece.step === undefined && light > 0.02 && casts(camera.fromScreen((x + 0.5) / SAMPLES - left, (y + 0.5) / SAMPLES - up, d), t.part)) step = Math.max(0, step - 1) + faceAt[i] = t.face + uAt[i] = u + vAt[i] = v stepAt[i] = step normalAt[i * 3] = facing[0] normalAt[i * 3 + 1] = facing[1] @@ -1012,7 +1276,15 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl const pixelStep = new Uint8Array(pixels) const pixelDepth = new Float32Array(pixels) const pixelNormal = new Float32Array(pixels * 3) - const votes = new Map() + const map: FrameMap = { + part: new Int32Array(pixels).fill(-1), + face: new Int32Array(pixels).fill(-1), + facing: new Uint8Array(pixels), + depth: new Float32Array(pixels), + u: new Float32Array(pixels), + v: new Float32Array(pixels) + } + const votes = new Map() for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { votes.clear() @@ -1027,7 +1299,7 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl const key = part * 256 + stepAt[s]! let vote = votes.get(key) if (vote === undefined) { - vote = { weight: 0, count: 0, depth: 0, x: 0, y: 0, z: 0 } + vote = { weight: 0, count: 0, depth: 0, x: 0, y: 0, z: 0, face: faceAt[s]!, u: 0, v: 0, onFace: 0 } votes.set(key, vote) } vote.weight += weight @@ -1036,6 +1308,12 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl vote.x += normalAt[s * 3]! vote.y += normalAt[s * 3 + 1]! vote.z += normalAt[s * 3 + 2]! + // Where on its face: the first face the vote saw, so two faces of one part never average. + if (faceAt[s] === vote.face) { + vote.u += uAt[s]! + vote.v += vAt[s]! + vote.onFace++ + } } } if (covered * 2 < SAMPLES * SAMPLES) continue @@ -1056,6 +1334,12 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl pixelNormal[i * 3] = n[0] pixelNormal[i * 3 + 1] = n[1] pixelNormal[i * 3 + 2] = n[2] + map.part[i] = pixelOwner[i]! + map.face[i] = vote.face + map.facing[i] = FACING_CODE[grids[vote.face]!.facing] + map.depth[i] = Math.round(pixelDepth[i] * pixelsPerUnit * 100) / 100 + map.u[i] = Math.round((vote.u / vote.onFace) * 1000) / 1000 + map.v[i] = Math.round((vote.v / vote.onFace) * 1000) / 1000 } } @@ -1075,8 +1359,29 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl if (edges !== false) { for (let i = 0; i < pixels; i++) { const part = pixelOwner[i]! - if (part < 0 || view.pieces[part]!.step !== undefined) continue - if (neighbours(i).some((n) => pixelOwner[n]! >= 0 && (pixelDepth[n]! - pixelDepth[i]!) * setup.camera.scale > edges)) finalStep[i] = Math.max(0, pixelStep[i]! - 1) + if (part < 0 || chosenTones(view.pieces[part]!)) continue + if (neighbours(i).some((n) => pixelOwner[n]! >= 0 && (pixelDepth[n]! - pixelDepth[i]!) * pixelsPerUnit > edges)) finalStep[i] = Math.max(0, pixelStep[i]! - 1) + } + } + + // A part's `line` falls where it meets what is behind it, on the pixels behind: as an outline falls outside a shape. + const lineAt = new Int32Array(pixels).fill(-1) + const darker = new Uint8Array(pixels) + const tie = 0.5 / pixelsPerUnit + for (let i = 0; i < pixels; i++) { + const behind = pixelOwner[i]! + if (behind < 0) continue + for (const n of neighbours(i)) { + const front = pixelOwner[n]! + if (front < 0 || front === behind) continue + const line = view.pieces[front]!.line + if (line === undefined) continue + const gap = pixelDepth[n]! - pixelDepth[i]! + // Level with each other, the line goes on the part without one, or on the later part. + const across = gap > tie || (gap >= -tie && (view.pieces[behind]!.line === undefined || behind > front)) + if (!across) continue + if (line === "step") darker[i] = 1 + else lineAt[i] = line === true ? lineColor : indexOf(line) } } @@ -1086,7 +1391,7 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl const part = pixelOwner[i]! if (part < 0) continue const piece = view.pieces[part]! - indices[i] = indexOf(piece.ramp[Math.min(piece.ramp.length - 1, finalStep[i]!)]!) + indices[i] = lineAt[i]! >= 0 ? lineAt[i]! : indexOf(piece.ramp[Math.max(0, Math.min(piece.ramp.length - 1, finalStep[i]!) - darker[i]!)]!) if (piece.detail) protectedPixel[i] = 1 shown.set(piece.source, (shown.get(piece.source) ?? 0) + 1) } @@ -1121,6 +1426,7 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl painted(false) // One outline around whatever now stands there. + const outlined = new Uint8Array(pixels) if (outlineColor !== undefined) { const before = indices.slice() for (let i = 0; i < pixels; i++) { @@ -1128,6 +1434,7 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl const near = neighbours(i).filter((n) => before[n] !== TRANSPARENT) if (near.length === 0) continue indices[i] = outlineColor + outlined[i] = 1 const n = unit(near.reduce((sum, at) => add(sum, [pixelNormal[at * 3]!, pixelNormal[at * 3 + 1]!, pixelNormal[at * 3 + 2]!]), [0, 0, 0])) pixelNormal[i * 3] = n[0] pixelNormal[i * 3 + 1] = n[1] @@ -1136,10 +1443,10 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl } painted(true) - // The lint's lone pixels go, by the rule it reads them by, except what was placed on purpose. + // The lint's lone pixels go, by the rule it reads them by, except what was placed on purpose and the outline. if (model.cleanup) { for (let pass = 0; pass < CLEANUP_PASSES; pass++) { - const strays = strayPixels(indices, width, height).filter((i) => protectedPixel[i] === 0) + const strays = strayPixels(indices, width, height).filter((i) => protectedPixel[i] === 0 && (outlined[i] === 0 || indices[i] !== outlineColor)) if (strays.length === 0) break for (const i of strays) { const x = i % width @@ -1159,6 +1466,24 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl if (top !== undefined) indices[i] = top[0] } } + // An outline pixel that meets the rest only at a corner (a roof's eave, a spire's tip) is joined to it + // through the empty corner, so the outline neither breaks nor leaves a lone pixel. + if (outlineColor !== undefined) { + for (const i of strayPixels(indices, width, height)) { + if (indices[i] !== outlineColor || outlined[i] === 0) continue + const x = i % width + const y = Math.floor(i / width) + for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]] as const) { + const nx = x + dx + const ny = y + dy + if (nx < 0 || ny < 0 || nx >= width || ny >= height || indices[ny * width + nx] !== outlineColor) continue + const corner = [y * width + nx, ny * width + x].find((c) => indices[c] === TRANSPARENT) + if (corner === undefined) continue + indices[corner] = outlineColor + break + } + } + } } const normal = new Uint8ClampedArray(pixels * 4) @@ -1195,7 +1520,8 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl return { art: { width, height, palette, indices }, normal: { width, height, data: normal }, - shadow: { width, height, palette, indices: shadowIndices } + shadow: { width, height, palette, indices: shadowIndices }, + map } } @@ -1219,7 +1545,9 @@ export const bake = (model: BakeModel, setup: BakeSetup, paletteColors?: Readonl const sources = new Map() for (const { moment } of moments) for (const piece of viewOf(moment).pieces) sources.set(piece.source, piece.name) + const first = viewOf(moments[0]!.moment) return { + partsShown: first.pieces.map((piece) => ({ path: piece.source, name: piece.name, material: piece.material })), width, height, anchor: { x: left, y: up }, diff --git a/packages/tools/test/bake.test.ts b/packages/tools/test/bake.test.ts index 97a746b9..0516f176 100644 --- a/packages/tools/test/bake.test.ts +++ b/packages/tools/test/bake.test.ts @@ -117,12 +117,42 @@ describe("reading a model", () => { const unknown = parseBakeSetup({ camera: { zoom: 2 } }) expect(unknown.ok === false && unknown.problems[0]!.message).toContain("is not a field") }) + + it("reads the look a part chooses for itself, and names what is wrong with it", () => { + expect(problems({ parts: [{ ...box, steps: { top: 2, left: 0, right: 1 }, line: true }] })).toEqual([]) + expect(problems({ parts: [{ ...box, line: "step" }, { ...box, line: "#123456" }, { ...box, line: false }] })).toEqual([]) + expect(problems({ parts: [{ ...box, steps: { front: 1 } }] }).join("\n")).toContain("$.parts[0].steps.front") + expect(problems({ parts: [{ ...box, steps: { top: -1 } }] }).join("\n")).toContain("must be a ramp's step") + expect(problems({ parts: [{ ...box, line: "thick" }] }).join("\n")).toContain('"step" (one step darker)') + expect(problems({ parts: [{ ...box, outline: true }] }).join("\n")).toContain("use `line`") + }) + + it("reads a face's own rows, which need no material, and checks their letters", () => { + const door = { shape: "face", corners: [[-2, 5, 0], [2, 5, 0], [2, 5, 6], [-2, 5, 6]] } + expect(problems({ materials: { D: "#5d275d", Y: "#ffcd75" }, parts: [box, { ...door, rows: ["DD", "DY", "D."] }] })).toEqual([]) + expect(problems({ parts: [box, { ...door, rows: ["QQ"] }] }).join("\n")).toContain('uses "Q"') + expect(problems({ materials: { D: "#5d275d" }, parts: [box, { ...door, rows: ["DD", "D"] }] }).join("\n")).toContain("as long as the first row") + expect(problems({ parts: [box, door] }).join("\n")).toContain("material") + }) + + it("takes the camera in a game's own terms, and never mixed with the other", () => { + const axes = parseBakeSetup({ camera: { across: [1, -1, 0], down: [0.6, 0.6, -0.88] } }) + expect(axes.ok && axes.value.camera.axes).toEqual({ across: [1, -1, 0], down: [0.6, 0.6, -0.88] }) + const mixed = parseBakeSetup({ camera: { across: [1, -1, 0], down: [0.6, 0.6, -0.88], yaw: 45 } }) + expect(mixed.ok === false && mixed.problems[0]!.message).toContain("use one or the other") + const half = parseBakeSetup({ camera: { across: [1, -1, 0] } }) + expect(half.ok === false && half.problems[0]!.path).toBe("bake.camera.down") + const flat = parseBakeSetup({ camera: { across: [1, 0, 0], down: [2, 0, 0] } }) + expect(flat.ok === false && flat.problems[0]!.message).toContain("along one line") + const upside = parseBakeSetup({ camera: { across: [1, -1, 0], down: [0.5, 0.5, 1] } }) + expect(upside.ok === false && upside.problems[0]!.path).toBe("bake.camera.down") + }) }) describe("one camera", () => { it("puts a point where the project's numbers say", () => { // Landkeep's view: a point lands at x = u - v, y = (u + v) * 0.6 - z * 0.88. - const using = setup({ camera: { yaw: 45, elevation: Math.asin(0.6) * (180 / Math.PI), scale: Math.SQRT2, heightScale: 0.88 / (Math.SQRT2 * 0.8) } }) + const using = setup({ camera: { yaw: 45, elevation: Math.asin(0.6) * (180 / Math.PI), scale: Math.SQRT2, heightScale: 0.88 / (Math.SQRT2 * 0.8), axes: undefined } }) const result = bake(model({ outline: false, parts: [{ shape: "box", material: "#ffffff", from: [0, 0, 10], to: [10, 10, 20] }] }, using), using) const art = still(result) const xs: Array = [] @@ -152,6 +182,18 @@ describe("one camera", () => { expect(Number.isInteger(result.anchor.x)).toBe(true) }) + it("draws a game's own axes as the turned camera that matches them", () => { + const s = 2 + const e = Math.PI / 6 + const turned = setup() + const axes = setup({ camera: { ...defaultBakeSetup.camera, axes: { across: [s * Math.SQRT1_2, -s * Math.SQRT1_2, 0], down: [s * Math.SQRT1_2 * Math.sin(e), s * Math.SQRT1_2 * Math.sin(e), -s * Math.cos(e)] } } }) + const fields = { headings: 2, parts: [box, { shape: "cylinder", material: "ramp", from: [-3, -3, 10], to: [3, 3, 16], taper: 0.1 }] } + const a = bake(model(fields, turned), turned) + const b = bake(model(fields, axes), axes) + expect([b.width, b.height, b.anchor]).toEqual([a.width, a.height, a.anchor]) + a.clips.forEach((clip, i) => expect(Array.from(b.clips[i]!.frames[0]!.art.indices)).toEqual(Array.from(clip.frames[0]!.art.indices))) + }) + it("draws the same pixels every time", () => { const fields = { headings: 4, parts: [box, { shape: "cylinder", material: "ramp", from: [-3, -3, 10], to: [3, 3, 16], taper: 0.1 }] } const once = bake(model(fields), setup()) @@ -212,6 +254,103 @@ describe("one light", () => { }) }) +describe("the look a part chooses", () => { + const without = { outline: false } + + it("takes a step for each way its surfaces face, whatever the light", () => { + const chosen = { ...box, steps: { top: 2, left: 0, right: 1 } } + const right = setup({ light: { ...defaultBakeSetup.light, sun: [0.7, 0.05, 0.7] } }) + const left = setup({ light: { ...defaultBakeSetup.light, sun: [-0.7, 0.05, 0.7] } }) + const a = bake(model({ ...without, parts: [chosen] }, right), right) + const b = bake(model({ ...without, parts: [chosen] }, left), left) + expect(Array.from(still(a).indices)).toEqual(Array.from(still(b).indices)) + const { x, y } = a.anchor + expect(colorAt(still(a), x, y - Math.round(10 * Math.cos(Math.PI / 6) * 2))).toBe(LIGHT) + expect(colorAt(still(a), x + 5, y + 2)).toBe(MID) + expect(colorAt(still(a), x - 5, y + 2)).toBe(DARK) + // One facing chosen, the others from the light. + const lit = bake(model({ ...without, parts: [{ ...box, steps: { left: 2 } }] }, right), right) + expect(colorAt(still(lit), lit.anchor.x - 5, lit.anchor.y + 2)).toBe(LIGHT) + expect(colorAt(still(lit), lit.anchor.x + 5, lit.anchor.y + 2)).toBe(MID) + }) + + it("draws a line where a part meets what is behind it, on what is behind", () => { + const base = { shape: "box", material: "#00aa00", from: [-6, -6, 0], to: [6, 6, 6] } + const lid = { shape: "box", material: "#aa0000", from: [-7, -7, 6], to: [7, 7, 9] } + const plain = still(bake(model({ parts: [base, lid] }), setup())) + const lined = still(bake(model({ parts: [base, { ...lid, line: true }] }), setup())) + // The outline's color, on the base just under the lid; the lid keeps every pixel. + const drawn = count(lined, "#000000") - count(plain, "#000000") + expect(drawn).toBeGreaterThan(0) + expect(count(lined, "#aa0000")).toBe(count(plain, "#aa0000")) + expect(count(plain, "#00aa00") - count(lined, "#00aa00")).toBe(drawn) + const colored = still(bake(model({ parts: [base, { ...lid, line: "#0000aa" }] }), setup())) + expect(count(colored, "#0000aa")).toBe(drawn) + // "step": the base goes a step darker under the lid instead. + const ramped = still(bake(model({ parts: [{ ...base, material: "ramp", steps: { left: 2, right: 2 } }, { ...lid, line: "step" }] }), setup())) + expect(count(ramped, MID)).toBe(drawn) + // With no outline, a line takes the darkest color there is. + const bare = still(bake(model({ outline: false, materials: { deep: "#010101" }, parts: [base, { ...lid, line: true }, { shape: "box", material: "deep", from: [20, 20, 0], to: [21, 21, 1], detail: true }] }), setup())) + expect(count(bare, "#010101")).toBeGreaterThan(drawn) + }) + + it("lays a face's rows over it from its top left, so a door turns with its wall", () => { + const door = { shape: "face", corners: [[-4, 5, 0], [4, 5, 0], [4, 5, 8], [-4, 5, 8]], rows: ["AB", "CC"] } + const fields = { ...without, headings: 4, materials: { A: "#aa0000", B: "#00aa00", C: "#0000aa" }, parts: [box, door] } + const result = bake(model(fields), setup()) + const art = result.clips[0]!.frames[0]!.art + const where = (color: string) => { + const points: Array<[number, number]> = [] + for (let y = 0; y < art.height; y++) for (let x = 0; x < art.width; x++) if (colorAt(art, x, y) === color) points.push([x, y]) + return { x: points.reduce((sum, p) => sum + p[0], 0) / points.length, y: points.reduce((sum, p) => sum + p[1], 0) / points.length, n: points.length } + } + const a = where("#aa0000") + const b = where("#00aa00") + const c = where("#0000aa") + expect(a.n).toBeGreaterThan(0) + expect(a.x).toBeLessThan(b.x) + expect(c.y).toBeGreaterThan(a.y) + expect(colors(art).has(MID) || colors(art).has(DARK)).toBe(true) + // Turned half way, the wall faces away and its door with it. + expect(colors(result.clips[2]!.frames[0]!.art).has("#aa0000")).toBe(false) + }) + + it("shows what is behind a face where its rows say .", () => { + const window = { shape: "face", corners: [[-4, 5, 2], [4, 5, 2], [4, 5, 8], [-4, 5, 8]], rows: ["A.A"] } + const art = still(bake(model({ ...without, materials: { A: "#aa0000" }, parts: [{ ...box, steps: { left: 0, right: 0, top: 0 } }, window] }), setup())) + const middle = still(bake(model({ ...without, materials: { A: "#aa0000" }, parts: [{ ...box, steps: { left: 0, right: 0, top: 0 } }, { ...window, rows: ["AAA"] }] }), setup())) + expect(count(art, "#aa0000")).toBeLessThan(count(middle, "#aa0000")) + expect(count(art, DARK)).toBeGreaterThan(count(middle, DARK)) + }) + + it("maps what every pixel shows: its part, its face, which way it faces, and where on it", () => { + const door = { name: "door", shape: "face", material: "#aa0000", corners: [[-4, 5, 0], [4, 5, 0], [4, 5, 8], [-4, 5, 8]] } + const result = bake(model({ parts: [{ ...box, name: "walls" }, door] }), setup()) + const frame = result.clips[0]!.frames[0]! + const { map, art } = frame + expect(result.partsShown.map((part) => part.name)).toEqual(["walls", "door"]) + expect(result.partsShown[0]!.material).toBe("ramp") + const facings = new Set() + for (let i = 0; i < art.width * art.height; i++) { + if (map.part[i]! < 0) continue + // Mapped pixels are drawn, and what the outline adds is not mapped. + expect(art.indices[i]).not.toBe(TRANSPARENT) + expect(colorAt(art, i % art.width, Math.floor(i / art.width))).not.toBe("#000000") + facings.add(map.facing[i]!) + expect(map.u[i]!).toBeGreaterThanOrEqual(0) + expect(map.u[i]!).toBeLessThanOrEqual(1) + if (map.part[i] === 1) { + expect(colorAt(art, i % art.width, Math.floor(i / art.width))).toBe("#aa0000") + expect(map.facing[i]).toBe(2) + } + } + expect(facings).toEqual(new Set([1, 2, 3])) + // Nearer is more: the box's near corner is in front of its far one. + const { x, y } = result.anchor + expect(map.depth[(y + 2) * art.width + x]!).toBeGreaterThan(map.depth[(y - 20) * art.width + x]!) + }) +}) + describe("what a model can say", () => { const without = { outline: false } @@ -327,6 +466,28 @@ describe("hand-placed pixels", () => { }) }) +describe("one outline", () => { + it("never breaks at a sharp tip, and leaves no lone pixel there", () => { + const using = setup({ camera: { ...defaultBakeSetup.camera, axes: { across: [1, -1, 0], down: [0.6, 0.6, -0.88] } } }) + const house = { + parts: [ + { shape: "box", material: "ramp", from: [-7, -5, 0], to: [7, 5, 10] }, + { shape: "box", material: "ramp", from: [-8.5, -6.5, 10], to: [8.5, 6.5, 18], top: [1, 0] } + ] + } + const art = still(bake(model(house, using), using)) + expect(strayPixels(art.indices, art.width, art.height)).toEqual([]) + // Every pixel of the shape has the outline, never the empty, beside it. + for (let y = 0; y < art.height; y++) { + for (let x = 0; x < art.width; x++) { + const color = colorAt(art, x, y) + if (color === undefined || color === "#000000") continue + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) expect(colorAt(art, x + dx, y + dy)).toBeDefined() + } + } + }) +}) + describe("clean pixels", () => { const busy = { headings: 8, @@ -487,6 +648,29 @@ describe("the command", { timeout: 60_000 }, () => { expect(sprite.ok && sprite.art.palette.map(toHex).sort()).toEqual(["#020202", "#ffffff"]) }) + it("writes the map of a still or a sheet over its PNG, and removes it when no longer asked", () => { + const dir = folder() + write(dir, "cart.bake.json", { map: true, headings: 2, parts: [{ ...box, name: "body" }] }) + const result = run(dir, "bake", "cart.bake.json") + expect(result.status).toBe(0) + const report = JSON.parse(result.stdout) as { files: Record } + expect(report.files.map).toBeDefined() + const png = decodePng(new Uint8Array(readFileSync(join(dir, "cart.png")))) + const map = JSON.parse(readFileSync(join(dir, "cart.map.json"), "utf8")) as { image: string; width: number; height: number; parts: ReadonlyArray<{ name: string }>; facing: ReadonlyArray; part: ReadonlyArray } + expect([map.image, map.width, map.height]).toEqual(["cart.png", png.width, png.height]) + expect(map.parts.map((part) => part.name)).toEqual(["body"]) + expect(map.facing).toHaveLength(png.height) + expect(map.part).toHaveLength(png.width * png.height) + // Both headings are mapped, each where it sits in the sheet. + const half = png.width / 2 + expect(map.facing.some((row) => /[TLR]/.test(row.slice(0, half)))).toBe(true) + expect(map.facing.some((row) => /[TLR]/.test(row.slice(half)))).toBe(true) + for (let i = 0; i < map.part.length; i++) if (map.part[i]! >= 0) expect(png.data[i * 4 + 3]).toBe(255) + write(dir, "cart.bake.json", { parts: [box] }) + const again = JSON.parse(run(dir, "bake", "cart.bake.json").stdout) as { files: { removed: ReadonlyArray } } + expect(again.files.removed.map((file) => file.split(/[\\/]/).pop())).toContain("cart.map.json") + }) + it("writes where --out says", () => { const dir = folder() write(dir, "a.bake.json", { parts: [box] })