Files
Sanctification/card-harness/scripts/generate-reference-assets.mjs
2026-09-08 13:39:03 -07:00

171 lines
5.7 KiB
JavaScript

import { createHash } from 'node:crypto'
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PNG } from 'pngjs'
const here = dirname(fileURLToPath(import.meta.url))
const harnessRoot = resolve(here, '..')
const projectRoot = resolve(harnessRoot, '..')
const outputRoot = resolve(harnessRoot, 'public', 'reference')
const renderRoot = resolve(projectRoot, 'blender_prototype', 'renders')
await mkdir(resolve(outputRoot, 'renders'), { recursive: true })
const sources = {
david: resolve(projectRoot, 'legendary.png'),
timothy: resolve(projectRoot, 'common.png'),
back: resolve(projectRoot, 'card-back.png'),
}
await Promise.all([
copyFile(sources.david, resolve(outputRoot, 'david-front.png')),
copyFile(sources.timothy, resolve(outputRoot, 'timothy-front.png')),
copyFile(sources.back, resolve(outputRoot, 'card-back.png')),
])
const referenceRenders = [
'david_foil_closeup.png',
'david_foil_reflection.png',
'david_holographic_head_on.png',
'david_holographic_spectrum_angle.png',
'david_holographic_opposite_angle.png',
'card_three_quarter_thickness.png',
]
await Promise.all(referenceRenders.map((name) =>
copyFile(resolve(renderRoot, name), resolve(outputRoot, 'renders', name)),
))
const smoothstep = (edge0, edge1, value) => {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
return t * t * (3 - 2 * t)
}
const imageHsv = (r, g, b) => {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
return {
saturation: max === 0 ? 0 : (max - min) / max,
value: max,
}
}
async function generateMask(sourcePath, outputPath, kind) {
const source = PNG.sync.read(await readFile(sourcePath))
const mask = new PNG({ width: source.width, height: source.height })
for (let y = 0; y < source.height; y += 1) {
for (let x = 0; x < source.width; x += 1) {
const index = (y * source.width + x) * 4
const { saturation, value } = imageHsv(
source.data[index] / 255,
source.data[index + 1] / 255,
source.data[index + 2] / 255,
)
let coverage
if (kind === 'david-no-cutouts') {
const u = x / (source.width - 1)
const v = 1 - y / (source.height - 1)
coverage = smoothstep(0.18, 0.62, saturation) * smoothstep(0.015, 0.16, value)
const side = smoothstep(0.34, 0.41, Math.abs(u - 0.5))
const above = smoothstep(0.245, 0.29, v)
const below = 1 - smoothstep(0.07, 0.105, v)
coverage *= Math.max(side, above, below)
} else {
coverage = smoothstep(0.08, 0.58, saturation) * smoothstep(0.015, 0.16, value)
}
const byte = Math.round(Math.max(0, Math.min(1, coverage)) * 255)
mask.data[index] = byte
mask.data[index + 1] = byte
mask.data[index + 2] = byte
mask.data[index + 3] = 255
}
}
await writeFile(outputPath, PNG.sync.write(mask))
}
async function generateLinenNormal(outputPath) {
const size = 512
const png = new PNG({ width: size, height: size })
const heightAt = (x, y) => {
const weave = Math.sin(x * 0.27) * 0.55 + Math.sin(y * 0.31) * 0.45
const cross = Math.sin((x + y) * 0.075) * 0.16
return weave + cross
}
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const dx = heightAt(x + 1, y) - heightAt(x - 1, y)
const dy = heightAt(x, y + 1) - heightAt(x, y - 1)
const nx = -dx * 0.22
const ny = -dy * 0.22
const length = Math.hypot(nx, ny, 1)
const index = (y * size + x) * 4
png.data[index] = Math.round((nx / length * 0.5 + 0.5) * 255)
png.data[index + 1] = Math.round((ny / length * 0.5 + 0.5) * 255)
png.data[index + 2] = Math.round((1 / length * 0.5 + 0.5) * 255)
png.data[index + 3] = 255
}
}
await writeFile(outputPath, PNG.sync.write(png))
}
await Promise.all([
generateMask(sources.david, resolve(outputRoot, 'david-finish-mask.png'), 'david-no-cutouts'),
generateMask(sources.timothy, resolve(outputRoot, 'timothy-finish-mask.png'), 'automatic'),
generateLinenNormal(resolve(outputRoot, 'linen-normal.png')),
])
const generatedFiles = [
'david-front.png',
'david-finish-mask.png',
'timothy-front.png',
'timothy-finish-mask.png',
'card-back.png',
'linen-normal.png',
...referenceRenders.map((name) => `renders/${name}`),
]
const hashes = {}
for (const name of generatedFiles) {
hashes[name] = createHash('sha256')
.update(await readFile(resolve(outputRoot, name)))
.digest('hex')
}
await writeFile(resolve(outputRoot, 'manifest.json'), JSON.stringify({
schemaVersion: 2,
referenceRevision: 'runtime-look-v4-2026-09-07',
sourceMaterialReference: 'blender_prototype/premium_finishes.py',
genericMaterialReference: 'blender_prototype/card_finish_material.py',
geometry: {
source: 'runtime-procedural',
widthMeters: 0.063,
heightMeters: 0.0882,
thicknessMeters: 0.0004,
cornerRadiusMeters: 0.0028,
},
fixtures: {
david: {
artwork: 'david-front.png',
finishMask: 'david-finish-mask.png',
maskRule: 'Premium v3 saturation/value and title-panel protection; face, hand, and lamb ellipse cutouts removed',
},
timothy: {
artwork: 'timothy-front.png',
finishMask: 'timothy-finish-mask.png',
maskRule: 'Artwork-independent saturation/value coverage',
},
},
normalMap: 'linen-normal.png',
referenceRenders: referenceRenders.map((name) => `renders/${name}`),
hashes,
}, null, 2))
console.log(`Reference assets ready in ${outputRoot}`)