card harness dynamic changes
This commit is contained in:
111
spikes/card-harness/scripts/card-catalog.mjs
Normal file
111
spikes/card-harness/scripts/card-catalog.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
47
spikes/card-harness/scripts/card-catalog.test.mjs
Normal file
47
spikes/card-harness/scripts/card-catalog.test.mjs
Normal 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')))
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
Reference in New Issue
Block a user