diff --git a/scripts/brand-assets/textures.html b/scripts/brand-assets/textures.html new file mode 100644 index 0000000..ad95061 --- /dev/null +++ b/scripts/brand-assets/textures.html @@ -0,0 +1,121 @@ + + + + + + tera — surface library + + + + + + diff --git a/scripts/brand-assets/textures.mjs b/scripts/brand-assets/textures.mjs new file mode 100644 index 0000000..472dbed --- /dev/null +++ b/scripts/brand-assets/textures.mjs @@ -0,0 +1,269 @@ +/** + * Photograph the surface library. + * + * node scripts/brand-assets/textures.mjs + * + * `/simulate/assets` on lumbridgecorp.com is a page whose whole argument is that + * the art in this repository is code — eight texture kinds drawn on a 2D canvas + * from seeded value noise, thirty-five surface roles that pair one of those with + * a colour and a roughness, and no binary art anywhere, enforced by a CI job. + * It made that argument in about four thousand pixels of prose with nothing on + * it to look at, which is a strange way to talk about the appearance of things. + * + * So this writes the library out: one tile per texture kind, drawn by + * `TextureBin`, multiplied by the colour of the first role that carries it, and + * one neutral tile beside it so a reader can see for themselves that the map has + * no hue of its own. The role table goes with them as data, so the site can set + * it in its own type rather than baking labels into an image. + * + * ### Why this one runs against Vite dev and the others run against `dist/` + * + * `shots.mjs` and `films.mjs` photograph the *application*, and the application + * a visitor gets is the built bundle, so that is what they point a camera at. + * This photographs a *module*. `TextureBin` is not reachable from the built + * bundle — it exposes no names — and making it reachable would mean a third Vite + * entry, which would ship a texture-sheet page to tera.lumbridgecorp.com so that + * a script could take a screenshot of it. Vite dev serves and transforms the + * source on request, so the page imports the same files a reader opens on the + * repository. That is a shorter chain of custody than the bundle, not a longer + * one, and the manifest records the commit either way. + * + * ### It needs no GPU + * + * Nothing here draws with WebGL. The textures are Canvas2D and the encoder is + * the browser's own WebP, so this runs in a couple of seconds on the software + * path and there is nothing to check a renderer string for. Chrome is still the + * right tool: `toDataURL("image/webp")` is the encoder, exactly as in + * `shots.mjs`, because there is no `sharp` on this box. + * + * ### Flags + * + * --site where lumbridge-v4 is (default ../../lumbridge-v4) + * --manifest-only rewrite the generated TS without drawing anything + */ + +import { chromium } from "playwright"; +import { createServer } from "vite"; +import { mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { execFileSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); + +const args = process.argv.slice(2); +function flag(name, fallback = null) { + const i = args.indexOf(name); + return i === -1 ? fallback : args[i + 1]; +} +const SITE = flag("--site", join(ROOT, "..", "lumbridge-v4")); +const MANIFEST_ONLY = args.includes("--manifest-only"); + +const PUBLIC_DIR = join(SITE, "apps", "web", "public", "textures"); +const DATA_DIR = join(SITE, "apps", "web", "src", "data"); + +/** + * A sentence per texture kind, written here rather than on the website. + * + * Same rule the shot captions follow: the description lives next to the thing + * that produces it, so a texture that is rewritten cannot leave a paragraph + * behind on a marketing page describing the grain it used to have. The website + * renders these; it does not author them. + * + * Every claim below is checkable against `DRAW` in `src/assets/textures.ts`. + */ +const NOTES = { + carpetLoop: + "Two octaves of value noise for the pile, then a loop pattern punched over it on a half-offset grid — the same trick a real loop carpet uses to hide its seams.", + woodPlank: + "Planks of a random width, each with its own grain running the length of it, and a hairline gap where two meet.", + polishedConcrete: + "Broad, slow noise for the pour, a fine speckle for the aggregate, and a few pale trowel sweeps that stop it reading as a gradient.", + ceilingTile: + "A mineral-fibre face: dense fine noise, pinholes punched through it, and a chamfer drawn around the edge of the tile.", + plasterPaint: + "Almost nothing, on purpose. Roller texture at the threshold of visibility is what keeps a painted wall from reading as a flat fill under raking light.", + fabricWeave: + "A warp and a weft drawn as alternating lines, which is enough structure to catch a highlight without becoming a pattern anybody notices.", + tileGrid: + "A hard grid of grout lines over lightly varied tiles, so no two squares in a floor are quite the same value.", + whiteboard: + "A near-white gloss with a faint vertical wipe and a few ghosted horizontal strokes — a board that has been used and cleaned, not a board out of a box.", +}; + +function sha() { + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: ROOT }) + .toString() + .trim(); + } catch { + return "unknown"; + } +} + +function dirty() { + try { + return ( + execFileSync("git", ["status", "--porcelain"], { cwd: ROOT }).toString().trim() !== "" + ); + } catch { + return true; + } +} + +async function draw() { + // `server.middlewareMode` is off: the page is fetched over HTTP like any + // other, so Vite's HTML transform runs and the inline module script is + // rewritten with resolved import URLs. + const server = await createServer({ + root: ROOT, + logLevel: "warn", + server: { port: 5313, strictPort: true }, + }); + await server.listen(); + + const browser = await chromium.launch({ channel: "chrome" }); + try { + const page = await browser.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.goto("http://localhost:5313/scripts/brand-assets/textures.html", { + waitUntil: "networkidle", + }); + // The tiles are drawn during module evaluation, so this is the whole wait. + // `waitForFunction`, not `waitForSelector`: the page paints nothing, so its + // body has no height, and Playwright's visibility rule counts a zero-height + // element as hidden however present it is. + await page.waitForFunction(() => document.body.dataset.ready === "1", null, { + timeout: 30000, + }); + if (errors.length) throw new Error(`page errors: ${errors.join("; ")}`); + + const library = await page.evaluate(() => window.__library()); + if (!library.textures.length) throw new Error("no textures came back"); + return library; + } finally { + await browser.close(); + await server.close(); + } +} + +function manifest(library) { + const rows = library.textures + .map( + (t) => ` { + kind: ${JSON.stringify(t.kind)}, + src: ${JSON.stringify(`/textures/${t.kind}.webp`)}, + neutralSrc: ${JSON.stringify(`/textures/${t.kind}-neutral.webp`)}, + size: ${t.size}, + leadRole: ${JSON.stringify(t.leadRole)}, + leadColor: ${JSON.stringify(t.leadColor)}, + carriedBy: ${JSON.stringify(t.carriedBy)}, + note: ${JSON.stringify(NOTES[t.kind] ?? "")}, + },`, + ) + .join("\n"); + + const surfaces = library.surfaces + .map( + (s) => + ` { role: ${JSON.stringify(s.role)}, color: ${JSON.stringify(s.color)}, texture: ${JSON.stringify(s.texture)}, roughness: ${s.roughness}, metalness: ${s.metalness} },`, + ) + .join("\n"); + + return `/** + * Generated. Do not edit — \`scripts/brand-assets/textures.mjs\` in the tera repo + * rewrites this file wholesale, and the descriptions below live next to the code + * that draws each texture so the two cannot drift apart. + * + * Every tile named here was drawn by tera's own \`TextureBin\` at the commit + * recorded below, and every row in \`SURFACES\` is what \`MaterialRegistry\` hands + * the office for that role. Nothing is an illustration and nothing is retouched. + * + * To change a description: edit \`NOTES\` in that script and run it with + * \`--manifest-only\`, which redraws nothing. + */ + +/** A union rather than \`string\`, so a page naming a texture that is gone fails typecheck. */ +export type TextureKind = +${library.textures.map((t) => ` | ${JSON.stringify(t.kind)}`).join("\n")}; + +export interface TextureTile { + kind: TextureKind; + /** The map multiplied by \`leadColor\` — what the office actually renders. */ + src: string; + /** The map alone. Near-white by construction: the hue arrives from the palette. */ + neutralSrc: string; + /** Pixels per side, which is also metres-per-repeat divided into resolution. */ + size: number; + /** The first role in palette order that carries this texture. */ + leadRole: string; + leadColor: string; + /** Every role that carries it. */ + carriedBy: string[]; + note: string; +} + +/** One row per surface role, as \`MaterialRegistry\` builds it at \`high\` quality. */ +export interface Surface { + role: string; + color: string; + texture: string | null; + roughness: number | null; + metalness: number | null; +} + +/** The tera commit these were drawn from. */ +export const TEXTURES_COMMIT = ${JSON.stringify(sha())}; +/** True if that commit is not the whole story — the tree had uncommitted work. */ +export const TEXTURES_DIRTY = ${dirty()}; + +export const TEXTURES: TextureTile[] = [ +${rows} +]; + +export const SURFACES: Surface[] = [ +${surfaces} +]; + +/** How many metres one repeat covers. From \`TEXTURE_TILE_METRES\` in tera. */ +export const TEXTURE_TILE_METRES = 2; +`; +} + +const dataUrl = (value) => Buffer.from(value.split(",")[1], "base64"); + +async function main() { + if (MANIFEST_ONLY) { + // Redrawing is the only way to know the role table, so a manifest-only run + // still needs the page — it just does not write any images. + const library = await draw(); + await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library)); + console.log(`manifest only → ${join(DATA_DIR, "textures.generated.ts")}`); + return; + } + + const library = await draw(); + await mkdir(PUBLIC_DIR, { recursive: true }); + + let bytes = 0; + for (const tile of library.textures) { + const tinted = dataUrl(tile.tinted); + const neutral = dataUrl(tile.neutral); + await writeFile(join(PUBLIC_DIR, `${tile.kind}.webp`), tinted); + await writeFile(join(PUBLIC_DIR, `${tile.kind}-neutral.webp`), neutral); + bytes += tinted.length + neutral.length; + console.log( + `${tile.kind.padEnd(18)} ${tile.size}px ${tile.leadColor} ${tile.carriedBy.length} role(s) ${Math.round((tinted.length + neutral.length) / 1024)} kB`, + ); + } + + await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library)); + console.log( + `\n${library.textures.length} textures, ${library.surfaces.length} surface roles, ${Math.round(bytes / 1024)} kB total → ${SITE}`, + ); + if (dirty()) console.log("warning: tera's tree is dirty; the manifest names a dirty sha"); +} + +await main();