MORE RE-ORGANIZING
This commit is contained in:
73
spikes/card-harness/scripts/export-card-mesh.mjs
Normal file
73
spikes/card-harness/scripts/export-card-mesh.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import { access, mkdir } from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const harnessRoot = resolve(here, '..')
|
||||
const projectRoot = resolve(harnessRoot, '..')
|
||||
const blendFile = resolve(projectRoot, 'blender_prototype', 'sanctification_card_material_prototype.blend')
|
||||
const pythonScript = resolve(here, 'export_card_mesh.py')
|
||||
const outputFile = resolve(harnessRoot, 'public', 'reference', 'card-mesh.glb')
|
||||
|
||||
const explicit = process.env.BLENDER_EXECUTABLE
|
||||
const platformCandidates = process.platform === 'win32'
|
||||
? [
|
||||
'C:\\Program Files\\Blender Foundation\\Blender 5.2\\blender.exe',
|
||||
'C:\\Program Files\\Blender Foundation\\Blender 5.1\\blender.exe',
|
||||
'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe',
|
||||
]
|
||||
: [
|
||||
'/usr/bin/blender',
|
||||
'/usr/local/bin/blender',
|
||||
'/snap/bin/blender',
|
||||
]
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let blender = explicit
|
||||
if (blender && !(await exists(blender))) {
|
||||
throw new Error(`BLENDER_EXECUTABLE does not exist or is not executable: ${blender}`)
|
||||
}
|
||||
if (!blender) {
|
||||
for (const candidate of platformCandidates) {
|
||||
if (await exists(candidate)) {
|
||||
blender = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!blender) blender = 'blender'
|
||||
|
||||
await mkdir(dirname(outputFile), { recursive: true })
|
||||
|
||||
const args = [
|
||||
'--background',
|
||||
blendFile,
|
||||
'--python',
|
||||
pythonScript,
|
||||
'--',
|
||||
'--output',
|
||||
outputFile,
|
||||
]
|
||||
|
||||
console.log(`Exporting card mesh with ${blender}`)
|
||||
const child = spawn(blender, args, { stdio: 'inherit', shell: false })
|
||||
const exitCode = await new Promise((resolveExit, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code) => resolveExit(code ?? 1))
|
||||
})
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Blender export failed with exit code ${exitCode}`)
|
||||
}
|
||||
|
||||
console.log(`Card mesh exported to ${outputFile}`)
|
||||
67
spikes/card-harness/scripts/export_card_mesh.py
Normal file
67
spikes/card-harness/scripts/export_card_mesh.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def script_args():
|
||||
separator = sys.argv.index("--") if "--" in sys.argv else len(sys.argv)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", required=True)
|
||||
return parser.parse_args(sys.argv[separator + 1:])
|
||||
|
||||
|
||||
def slot_material(name, color):
|
||||
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
||||
material.diffuse_color = (*color, 1.0)
|
||||
return material
|
||||
|
||||
|
||||
args = script_args()
|
||||
output = Path(args.output).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source = bpy.data.objects.get("CARD_David_Foil")
|
||||
if source is None:
|
||||
raise RuntimeError("Expected CARD_David_Foil in the Blender prototype")
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
export_object = source.copy()
|
||||
export_object.data = source.data.copy()
|
||||
export_object.name = "SanctificationCardMesh"
|
||||
export_object.data.name = "SanctificationCardMesh"
|
||||
export_object.animation_data_clear()
|
||||
export_object.location = (0, 0, 0)
|
||||
export_object.rotation_euler = (0, 0, 0)
|
||||
export_object.scale = (1, 1, 1)
|
||||
bpy.context.scene.collection.objects.link(export_object)
|
||||
|
||||
material_indices = [polygon.material_index for polygon in export_object.data.polygons]
|
||||
export_object.data.materials.clear()
|
||||
export_object.data.materials.append(slot_material("SLOT_FRONT", (0.8, 0.8, 0.8)))
|
||||
export_object.data.materials.append(slot_material("SLOT_BACK", (0.25, 0.25, 0.25)))
|
||||
export_object.data.materials.append(slot_material("SLOT_EDGE", (0.5, 0.42, 0.28)))
|
||||
for polygon, material_index in zip(export_object.data.polygons, material_indices):
|
||||
polygon.material_index = material_index
|
||||
|
||||
export_object.select_set(True)
|
||||
bpy.context.view_layer.objects.active = export_object
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(output),
|
||||
export_format="GLB",
|
||||
use_selection=True,
|
||||
export_apply=True,
|
||||
export_materials="EXPORT",
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_yup=True,
|
||||
)
|
||||
|
||||
print({
|
||||
"output": str(output),
|
||||
"object": export_object.name,
|
||||
"mesh": export_object.data.name,
|
||||
"materials": [material.name for material in export_object.data.materials],
|
||||
})
|
||||
170
spikes/card-harness/scripts/generate-reference-assets.mjs
Normal file
170
spikes/card-harness/scripts/generate-reference-assets.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
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}`)
|
||||
Reference in New Issue
Block a user