card harness dynamic changes

This commit is contained in:
2026-09-11 21:19:08 -07:00
parent 8fcda5f2b7
commit 56f010a71e
25 changed files with 1055 additions and 126 deletions

View File

@@ -0,0 +1,111 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, 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))
export const harnessRoot = resolve(here, '..')
export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art')
export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json')
function parsePng(name, bytes) {
try {
return PNG.sync.read(bytes)
} catch (error) {
throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`)
}
}
function validatePair(name, artworkBytes, maskBytes) {
const artwork = parsePng(`${name}.png`, artworkBytes)
const mask = parsePng(`${name}-mask.png`, maskBytes)
if (artwork.width * 7 !== artwork.height * 5) {
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`)
}
if (mask.width !== artwork.width || mask.height !== artwork.height) {
throw new Error(
`${name}-mask.png must match ${name}.png dimensions; found ` +
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`,
)
}
for (let offset = 0; offset < artwork.data.length; offset += 4) {
if (artwork.data[offset + 3] !== 255) {
throw new Error(`${name}.png must be fully opaque for the runtime card surface`)
}
if (
mask.data[offset] !== mask.data[offset + 1] ||
mask.data[offset] !== mask.data[offset + 2] ||
mask.data[offset + 3] !== 255
) {
throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`)
}
}
return { width: artwork.width, height: artwork.height }
}
export async function scanCardCatalog(root = cardArtRoot) {
await mkdir(root, { recursive: true })
const files = (await readdir(root, { withFileTypes: true }))
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
.map(entry => entry.name)
const fileSet = new Set(files)
const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png'))
const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png'))
const cards = []
const errors = []
for (const artworkFile of artworkFiles) {
const name = artworkFile.slice(0, -4)
const maskFile = `${name}-mask.png`
if (!fileSet.has(maskFile)) {
errors.push(`Missing ${maskFile} for ${artworkFile}`)
continue
}
try {
const [artworkBytes, maskBytes] = await Promise.all([
readFile(resolve(root, artworkFile)),
readFile(resolve(root, maskFile)),
])
const dimensions = validatePair(name, artworkBytes, maskBytes)
const revision = createHash('sha256')
.update(artworkBytes)
.update(maskBytes)
.digest('hex')
.slice(0, 16)
cards.push({
name,
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`,
mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`,
revision,
...dimensions,
})
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error))
}
}
for (const maskFile of maskFiles) {
const artworkFile = `${maskFile.slice(0, -9)}.png`
if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`)
}
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
errors.sort()
return { schemaVersion: 1, cards, errors }
}
export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) {
const catalog = await scanCardCatalog(root)
await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`)
return catalog
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const catalog = await writeCardCatalog()
console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
if (catalog.errors.length) {
for (const error of catalog.errors) console.error(`- ${error}`)
process.exitCode = 1
}
}

View File

@@ -0,0 +1,47 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { PNG } from 'pngjs'
import { scanCardCatalog } from './card-catalog.mjs'
function png(width, height, pixel) {
const image = new PNG({ width, height })
for (let offset = 0; offset < image.data.length; offset += 4) {
image.data.set(pixel, offset)
}
return PNG.sync.write(image)
}
test('catalog pairs filename-based card art with validated masks', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-'))
t.after(() => rm(root, { recursive: true }))
await Promise.all([
writeFile(resolve(root, 'david.png'), png(10, 14, [20, 40, 60, 255])),
writeFile(resolve(root, 'david-mask.png'), png(10, 14, [128, 128, 128, 255])),
])
const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.errors, [])
assert.equal(catalog.cards.length, 1)
assert.equal(catalog.cards[0].name, 'david')
assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/)
assert.equal(catalog.cards[0].width, 10)
assert.equal(catalog.cards[0].height, 14)
})
test('catalog reports missing pairs and invalid runtime images explicitly', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-'))
t.after(() => rm(root, { recursive: true }))
await Promise.all([
writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])),
writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])),
writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])),
writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])),
])
const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.cards, [])
assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png')))
assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png')))
assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque')))
})

View File

@@ -5,6 +5,7 @@ import { installDOM, TestCanvas, geometryHash, wrapperCases } from './test-suppo
const { createFoilWrapper } = await import('../src/foilWrapper.ts')
const { PackOpening } = await import('../src/packOpening.ts')
const { packStyles, defaultPackStyle, createPackTexture } = await import('../src/packDesigns.ts')
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
const originalHashes = [
@@ -56,6 +57,49 @@ test('only changed surfaces recompute normals, bounds and upload buffers', (t) =
assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
})
test('three cached pack designs change all printed surfaces without changing geometry or materials', () => {
installDOM()
const wrapper = createFoilWrapper()
assert.equal(packStyles.length, 3)
assert.equal(wrapper.style, defaultPackStyle)
assert.equal(wrapper.textures.length, 6)
const materials = wrapper.root.children.map(mesh => mesh.material)
const maps = new Set()
for (const style of packStyles) {
wrapper.setStyle(style)
assert.equal(wrapper.style, style)
const [front, back, strip] = wrapper.root.children.map(mesh => mesh.material.map)
assert.equal(front, strip, 'ribbon must use the same printed sheet as the front')
assert.notEqual(front, back)
maps.add(front)
maps.add(back)
for (const texture of [front, back]) {
assert.equal(texture.image.width, 1024)
assert.equal(texture.image.height, 1400)
assert.equal(texture.colorSpace, THREE.SRGBColorSpace)
}
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material), materials)
wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => {
wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit)
assert.equal(geometryHash(wrapper), originalHashes[index], `${style}, pose ${index}`)
})
wrapper.setStyle(style)
assert.equal(wrapper.root.children[0].material.map, front, 'selecting the same style reuses its texture')
}
assert.equal(maps.size, 6)
const before = wrapper.root.children.map(mesh => mesh.material.map)
assert.throws(() => wrapper.setStyle('unknown'), /Unknown pack style/)
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material.map), before)
wrapper.dispose()
})
test('pack textures reject invalid styles and unavailable drawing contexts', (t) => {
installDOM()
assert.throws(() => createPackTexture('unknown'))
t.mock.method(TestCanvas.prototype, 'getContext', () => null)
assert.throws(() => createPackTexture(defaultPackStyle))
})
function setupPack(t, reducedMotion = false) {
installDOM(reducedMotion)
let now = 1000
@@ -158,6 +202,39 @@ test('interruption and mode changes preserve progress without deforming in point
assert.equal(pack.state, 'stackReady')
})
test('style switching preserves partial tears, card order, restart selection and retained mode state', (t) => {
const { pack, canvas, step } = setupPack(t)
let reveals = 0
canvas.addEventListener('packreveal', () => { reveals++ })
pack.primary()
step(600)
pack.pointerDown(pointer(1, 0, 0))
pack.pointerUp(pointer(1, 0, 0))
const progress = pack.tearProgress
const geometry = geometryHash(pack.wrapper)
const cardSpecs = pack.cards.map(card => card.material.uuid)
for (const style of packStyles) {
pack.setStyle(style)
assert.equal(pack.style, style)
assert.equal(pack.tearProgress, progress)
assert.equal(pack.paused, true)
assert.equal(pack.state, 'opening')
assert.equal(geometryHash(pack.wrapper), geometry)
assert.deepEqual(pack.cards.map(card => card.material.uuid), cardSpecs)
assert.equal(reveals, 0)
}
pack.setStyle(packStyles[0])
pack.setActive(false)
pack.setActive(true)
assert.equal(pack.style, packStyles[0])
assert.equal(pack.tearProgress, progress)
pack.restart()
assert.equal(pack.style, packStyles[0])
assert.equal(pack.state, 'sealed')
assert.equal(pack.tearProgress, 0)
pack.dispose()
})
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
const { pack, canvas, step } = setupPack(t)
const seam = pack.seamScreenBounds()
@@ -267,7 +344,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
finishCompile()
await preparation
assert.equal(uploaded.length, new Set(uploaded).size)
assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps')
assert.equal(uploaded.length, 13, '4 artwork/mask textures, normal, environment, back and 6 wrapper maps')
for (const { artwork, mask } of Object.values(pack.options.textures)) {
assert.ok(uploaded.includes(artwork))
assert.ok(uploaded.includes(mask))
@@ -275,6 +352,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
assert.ok(uploaded.includes(backTexture))
assert.ok(uploaded.includes(pack.options.normalMap))
assert.ok(uploaded.includes(scene.environment))
for (const texture of pack.wrapper.textures) assert.ok(uploaded.includes(texture), 'inactive pack styles must also be GPU-ready')
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(geometryHash(pack.wrapper), before)
@@ -333,8 +411,7 @@ test('pack cleanup disposes owned resources without disposing shared or caller-o
materials.add(object.material)
})
materials.delete(pack.options.backMaterial)
const resources = [...geometries, ...materials,
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
const resources = [...geometries, ...materials, ...pack.wrapper.textures]
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
pack.dispose()
assert.equal(pack.root.parent, null)

View File

@@ -27,6 +27,10 @@ export class TestCanvas extends EventTarget {
return {
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
beginPath() {}, closePath() {}, moveTo() {}, lineTo() {}, bezierCurveTo() {},
quadraticCurveTo() {}, arc() {}, ellipse() {}, rect() {}, roundRect() {},
fill() {}, stroke() {}, clip() {}, scale() {}, setLineDash() {},
measureText(text) { return { width: text.length * 22 } },
}
}
getBoundingClientRect() { return { left: 0, top: 0, width: this.width, height: this.height } }