// depth.js — top-down depth-map exporter for reikhelm map envelopes. // // A *renderer* concern, exactly like render.js: it reads the same JSON envelope // (integer tile codes, regions, themes) and derives an 8-bit grayscale depth map // for the diffusion control pipeline (tools/comfy-spike → ComfyUI Z-Image). It // holds NO generation logic and the core stores none of this — height, like // color, lives renderer-side and is derived from tiles + regions + theme. // // Convention (matches tools/comfy-spike/comfy.py): a top-down depth map where // NEAR the camera = white(255), FAR = black(0). Looking straight down, the tops // of walls are closest to the camera (white), the floor sits mid-gray, and the // bottoms of recessed pools are farthest (black). So depth here is a *height // field*: brightness ∝ height. // // Note on the BFS field: render.js's `distanceToWall` is an openness/cavity map, // not a depth map — feeding it raw would dome the floors toward the camera (room // centres bulging), which is wrong. We build a height model instead, and reuse // the distance field only for a subtle ambient-occlusion darkening in the // crevices where stone meets floor. import { getStage, distanceToWall } from './render.js'; const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5; // Per-tile base height in [0,1] (1 = tallest = nearest the top-down camera = // whitest). The wall plateau is the high reference; pools recess below the // floor; a door notches just under floor level so passages read as openings; // a pillar stands as a tall column on the floor. Renderer-owned, like PAL. const HEIGHT = { [WALL]: 1.00, // flat wall-top plateau (uniform: from directly above, all wall tops sit level) [FLOOR]: 0.50, // mid reference plane [DOOR]: 0.45, // threshold, a hair below floor → reads as a gap in the wall line [PILLAR]: 0.84, // free-standing column rising off the floor [WATER]: 0.15, // recessed pool [LAVA]: 0.10, // recessed molten channel (lowest) }; // Per-theme relief (Stage-B layer, kept subtle so it modulates the room without // swamping the wall↔pool contrast that carries the structure). Floor bias raises // or sinks a themed room's floor; wall bias makes its walls stand a touch taller. // Keyed by the wire theme id (render.js THEMES). Absent themes → flat (0,0). const THEME_RELIEF = { throne: { floor: 0.07, wall: 0.06 }, // a raised dais under taller walls vault: { floor: 0.04, wall: 0.03 }, // built-up stone room hall: { floor: 0.025, wall: 0.02 }, library: { floor: 0.02, wall: 0.01 }, forge: { floor: 0.0, wall: 0.01 }, // lava already carries the relief threshold: { floor: 0.0, wall: 0.0 }, stone: { floor: 0.0, wall: 0.0 }, den: { floor: -0.02, wall: 0.0 }, cistern: { floor: -0.06, wall: 0.0 }, // sunken water room crypt: { floor: -0.05, wall: 0.0 }, // sunken vault }; const AO = { depth: 2.4, strength: 0.07 }; // crevice darkening near walls (reuses distanceToWall) const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v; // Per-cell theme relief: for every cell of a themed Room, its {floor,wall} bias; // {0,0} elsewhere. Rooms don't overlap, so the assignment is unambiguous. function reliefField(regions, w, h) { const fl = new Float32Array(w * h); const wl = new Float32Array(w * h); for (const r of regions) { if (r.kind !== 'Room' || !r.theme || !r.cells) continue; const rel = THEME_RELIEF[r.theme]; if (!rel) continue; for (const [x, y] of r.cells) { if (x < 0 || y < 0 || x >= w || y >= h) continue; fl[y * w + x] = rel.floor; wl[y * w + x] = rel.wall; } } return { fl, wl }; } // In-place separable box blur over a Float32 field (two passes ≈ Gaussian). // Used as a small bevel so wall edges read as sloped faces rather than 1px // cliffs, without melting the flat plateaus. radius in pixels; clamps at edges. function boxBlur(src, w, h, radius, passes = 2) { if (radius < 1) return src; let buf = src; const tmp = new Float32Array(w * h); const norm = 1 / (radius * 2 + 1); for (let p = 0; p < passes; p++) { // horizontal for (let y = 0; y < h; y++) { const row = y * w; let acc = 0; for (let k = -radius; k <= radius; k++) acc += buf[row + clamp(k, 0, w - 1)]; for (let x = 0; x < w; x++) { tmp[row + x] = acc * norm; const out = row + clamp(x - radius, 0, w - 1); const inc = row + clamp(x + radius + 1, 0, w - 1); acc += buf[inc] - buf[out]; } } // vertical for (let x = 0; x < w; x++) { let acc = 0; for (let k = -radius; k <= radius; k++) acc += tmp[clamp(k, 0, h - 1) * w + x]; for (let y = 0; y < h; y++) { buf[y * w + x] = acc * norm; const out = clamp(y - radius, 0, h - 1) * w + x; const inc = clamp(y + radius + 1, 0, h - 1) * w + x; acc += tmp[inc] - tmp[out]; } } } return buf; } // --- main entry ---------------------------------------------------------- // Derive a depth height field from an envelope. Returns { width, height, gray } // where gray is a Uint8ClampedArray (row-major, one 8-bit value per pixel), // full-range normalized with near(tall)=255. // // opts: { stage?, scale?, bevel?, ao? } // stage — snapshot to read (default final) // scale — pixels per cell (default ≈ 1024 / longest map edge, clamped 6..48) // bevel — edge-softening as a fraction of a cell (default 0.16; 0 = hard steps) // ao — crevice AO strength (default AO.strength; 0 = off) export function computeDepthField(env, opts = {}) { const { tiles, regions } = getStage(env, opts.stage); const w = env.width, h = env.height; const scale = opts.scale ?? clamp(Math.round(1024 / Math.max(w, h)), 6, 48); const aoStrength = opts.ao ?? AO.strength; // 1) Per-cell height = tile base + theme relief − crevice AO. const dist = distanceToWall(tiles, w, h); const { fl, wl } = reliefField(regions, w, h); const cell = new Float32Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; const t = tiles[y][x]; let v = HEIGHT[t] ?? HEIGHT[FLOOR]; if (t === WALL) { v += wl[i]; // taller themed walls } else { v += fl[i]; // raised/sunken themed floors // Crevice AO: open cells right against stone sit slightly lower. `dist` // is 0 in walls and grows outward; openness = min(dist/aoDepth, 1). const openness = Math.min(1, dist[i] / AO.depth); v -= (1 - openness) * aoStrength; } cell[i] = v; } } // 2) Upscale to pixels as flat blocks (nearest), preserving plateaus/pools — // hard depth steps at wall tops are physically correct top-down. const W = w * scale, H = h * scale; const px = new Float32Array(W * H); for (let y = 0; y < H; y++) { const cy = (y / scale) | 0; for (let x = 0; x < W; x++) { px[y * W + x] = cell[cy * w + ((x / scale) | 0)]; } } // 3) Light bevel: a small blur turns the 1px block edges into sloped wall // faces the depth ControlNet can read, without rounding off the flat tops. const bevel = opts.bevel ?? 0.16; const radius = Math.max(0, Math.round(scale * bevel)); boxBlur(px, W, H, radius); // 4) Height → 8-bit gray, near(tall)=white. Default ('levels') maps heights // onto the *proven* room_depth levels (floor≈120, wall≈235, recessed pools // ≈30–45 — the input distribution the Z-Image depth patch was validated // against): gray = h·230 + 5, so floor(0.5)→120 and wall(1.0)→235. This // keeps the floor a stable mid-gray across seeds instead of letting one tall // themed room crush every other floor toward black (what literal full-range // did). `normalize:'range'` opts back into data-driven full-range. const gray = new Uint8ClampedArray(W * H); if (opts.normalize === 'range') { let lo = Infinity, hi = -Infinity; for (let i = 0; i < px.length; i++) { const v = px[i]; if (v < lo) lo = v; if (v > hi) hi = v; } const span = hi - lo || 1; for (let i = 0; i < px.length; i++) gray[i] = ((px[i] - lo) / span) * 255 + 0.5; } else { for (let i = 0; i < px.length; i++) gray[i] = px[i] * 230 + 5 + 0.5; } return { width: W, height: H, gray }; } // --- canvas / export glue ------------------------------------------------ // Paint a depth field onto a fresh offscreen canvas (R=G=B=gray). export function depthToCanvas(field) { const { width, height, gray } = field; const cv = document.createElement('canvas'); cv.width = width; cv.height = height; const c = cv.getContext('2d'); const img = c.createImageData(width, height); const d = img.data; for (let i = 0; i < gray.length; i++) { const v = gray[i], j = i * 4; d[j] = v; d[j + 1] = v; d[j + 2] = v; d[j + 3] = 255; } c.putImageData(img, 0, 0); return cv; } // Build the depth PNG and return its data URL plus dimensions. export function exportDepthDataURL(env, opts = {}) { const field = computeDepthField(env, opts); return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height }; } // Trigger a browser download of the depth PNG (the human / button path). export function downloadDepth(env, opts = {}) { const { url, width, height } = exportDepthDataURL(env, opts); const a = document.createElement('a'); a.href = url; a.download = `reikhelm-depth-seed${env.seed}-${width}x${height}.png`; document.body.appendChild(a); a.click(); a.remove(); return { width, height }; } // POST the depth PNG to the dev save-server (tools/serve.py) under `name` — the // automated-pipeline path (Playwright / scripted export). Returns the server's // JSON ({ok, path, bytes}) merged with the dimensions; throws if no server is // listening (use downloadDepth() for the browser-download path instead). export async function exportDepthToServer(env, name, opts = {}) { const field = computeDepthField(env, opts); const blob = await new Promise((res) => depthToCanvas(field).toBlob(res, 'image/png')); const resp = await fetch('/save/' + encodeURIComponent(name), { method: 'POST', body: blob }); if (!resp.ok) throw new Error('save failed: ' + resp.status); const info = await resp.json(); return { ...info, width: field.width, height: field.height }; }