171 lines
5.7 KiB
JavaScript
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}`)
|