diff --git a/spikes/card-harness/CURRENT-DEFAULTS.md b/spikes/card-harness/CURRENT-DEFAULTS.md index 8c68d35..0cd3af2 100644 --- a/spikes/card-harness/CURRENT-DEFAULTS.md +++ b/spikes/card-harness/CURRENT-DEFAULTS.md @@ -113,7 +113,7 @@ Changing a light type, color, position, intensity, environment contribution, or | Control | Default | Available range | |---|---:|---:| | Finish | Holographic | Printed ink / Foil / Holographic | -| Material | Paper | Paper / Linen / Plastic / Metal / Wood | +| Material | Paper | Paper / Linen / Metal / Wood | | Finish strength | 0.60 | 0-1 | | Roughness | 0.23 | 0.05-0.8 | | Surface detail | 0.14 | 0-0.45 | diff --git a/spikes/card-harness/README.md b/spikes/card-harness/README.md index e46e564..5dd3ff2 100644 --- a/spikes/card-harness/README.md +++ b/spikes/card-harness/README.md @@ -7,7 +7,7 @@ The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFA ## Included proof scope - David printed-ink, foil, and holographic presets -- Independently selectable paper, linen, plastic, metal, and wood substrates +- Independently selectable paper, linen, metal, and wood substrates - Timothy as the second-artwork fixture - David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy - Procedural rounded-card geometry generated from shared physical dimensions @@ -28,6 +28,21 @@ Transform gizmos and a general timeline are intentionally deferred. The separate ## Tear-open foil pack prototype Choose **Pack** in the Mode selector (expand **Controls** first on mobile). +Use **Pack style** in the same toolbar to compare three wrapper-only design studies: + +- **Cathedral glass:** deep blue, geometric stained-glass window artwork, and restrained gold. +- **Illuminated manuscript:** warm ivory, burgundy, and botanical manuscript linework. +- **Quiet modern** (initial selection): dark green, generous space, and a small sacred emblem. + +All three use the same wording, foil material, pouch geometry, lighting, card contents, +and opening animation. These are alternative visual studies, not different pack tiers +or reward odds. Switching styles changes the front, back, and matching tear strip in +place, preserving the current tear/inspection state. Use **Restart** to compare sealed +packs; the chosen style is retained across Restart and mode switches, but not a page +reload. On mobile, reopen **Controls** after entering Pack to access the selector. +The six front/back textures are generated once and uploaded during pack preparation, +then reused for instant switching. No illustration service or external asset is needed. + Drag the gold top seam **to the right**, or use **Tear open** for the same authored opening without dragging: **Sealed → progressive tear → strip curls away → mouth spreads → staggered card extraction → face-down stack → lifting → lifted → revealing → inspecting → advancing → next stack / complete.** @@ -105,8 +120,8 @@ silhouette, back, or thickness. This linen look was visually approved September 2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration is required: selecting Linen applies it in Inspect, Lab, and the authored pack. -Plastic has a smooth clear-coat -highlight even in Printed ink mode. Wood uses raised longitudinal grain with +The legacy Plastic shader recipe remains readable by older saved experiments but is +not offered in the UI or randomized packs. Wood uses raised longitudinal grain with bounded procedural parallax, stronger relief, and a restrained warm tint. The artwork stays anchored while grain shading and tint shift together. Metal retains its reflective underprint and uses luminance-driven recesses: diff --git a/spikes/card-harness/package.json b/spikes/card-harness/package.json index 95dc4a7..855ce67 100644 --- a/spikes/card-harness/package.json +++ b/spikes/card-harness/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "assets": "node scripts/generate-reference-assets.mjs", + "catalog": "node scripts/card-catalog.mjs", "export:blender": "node scripts/export-card-mesh.mjs", "test": "node --test scripts/*.test.mjs", "predev": "npm run assets", diff --git a/spikes/card-harness/public/card-art/David-mask.png b/spikes/card-harness/public/card-art/David-mask.png new file mode 100644 index 0000000..0e92846 Binary files /dev/null and b/spikes/card-harness/public/card-art/David-mask.png differ diff --git a/spikes/card-harness/public/card-art/David.png b/spikes/card-harness/public/card-art/David.png new file mode 100644 index 0000000..9eb7cd0 Binary files /dev/null and b/spikes/card-harness/public/card-art/David.png differ diff --git a/spikes/card-harness/public/card-art/README.md b/spikes/card-harness/public/card-art/README.md new file mode 100644 index 0000000..4696aa5 --- /dev/null +++ b/spikes/card-harness/public/card-art/README.md @@ -0,0 +1,21 @@ +# Card Art Drop Folder + +Add each card as one PNG artwork and one matching PNG finish mask: + +```text +david.png +david-mask.png +``` + +The filename before `.png` is the card name shown in the harness. Names are +case-sensitive. Artwork and mask must: + +- have identical dimensions; +- use a 5:7 aspect ratio; +- be fully opaque; +- use an opaque grayscale mask. + +During `npm run dev`, adding or replacing either PNG refreshes the searchable +card catalog automatically. Reopening or focusing the Card field also forces a +rescan. Production builds generate `manifest.json` from this folder and fail if +any pair is missing or invalid. diff --git a/spikes/card-harness/public/card-art/Timothy-mask.png b/spikes/card-harness/public/card-art/Timothy-mask.png new file mode 100644 index 0000000..f4be62f Binary files /dev/null and b/spikes/card-harness/public/card-art/Timothy-mask.png differ diff --git a/spikes/card-harness/public/card-art/Timothy.png b/spikes/card-harness/public/card-art/Timothy.png new file mode 100644 index 0000000..a6e3b95 Binary files /dev/null and b/spikes/card-harness/public/card-art/Timothy.png differ diff --git a/spikes/card-harness/public/card-art/calling-disciples-SOL-v2-normal-1000-mask.png b/spikes/card-harness/public/card-art/calling-disciples-SOL-v2-normal-1000-mask.png new file mode 100644 index 0000000..097a5a6 Binary files /dev/null and b/spikes/card-harness/public/card-art/calling-disciples-SOL-v2-normal-1000-mask.png differ diff --git a/spikes/card-harness/public/card-art/calling-disciples-SOL-v2-normal-1000.png b/spikes/card-harness/public/card-art/calling-disciples-SOL-v2-normal-1000.png new file mode 100644 index 0000000..d64f770 Binary files /dev/null and b/spikes/card-harness/public/card-art/calling-disciples-SOL-v2-normal-1000.png differ diff --git a/spikes/card-harness/public/card-art/manifest.json b/spikes/card-harness/public/card-art/manifest.json new file mode 100644 index 0000000..31c4100 --- /dev/null +++ b/spikes/card-harness/public/card-art/manifest.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "cards": [ + { + "name": "David", + "artwork": "/card-art/David.png?v=a5541f0b4b68e13e", + "mask": "/card-art/David-mask.png?v=a5541f0b4b68e13e", + "revision": "a5541f0b4b68e13e", + "width": 1060, + "height": 1484 + }, + { + "name": "Timothy", + "artwork": "/card-art/Timothy.png?v=1118b083057106d4", + "mask": "/card-art/Timothy-mask.png?v=1118b083057106d4", + "revision": "1118b083057106d4", + "width": 1060, + "height": 1484 + } + ], + "errors": [] +} diff --git a/spikes/card-harness/public/reference/card-back-old.png b/spikes/card-harness/public/reference/card-back-old.png new file mode 100644 index 0000000..caa4df8 Binary files /dev/null and b/spikes/card-harness/public/reference/card-back-old.png differ diff --git a/spikes/card-harness/public/reference/card-back-white.png b/spikes/card-harness/public/reference/card-back-white.png new file mode 100644 index 0000000..3b3c9c4 Binary files /dev/null and b/spikes/card-harness/public/reference/card-back-white.png differ diff --git a/spikes/card-harness/public/reference/card-back.png b/spikes/card-harness/public/reference/card-back.png index caa4df8..713322b 100644 Binary files a/spikes/card-harness/public/reference/card-back.png and b/spikes/card-harness/public/reference/card-back.png differ diff --git a/spikes/card-harness/scripts/card-catalog.mjs b/spikes/card-harness/scripts/card-catalog.mjs new file mode 100644 index 0000000..b0d5f15 --- /dev/null +++ b/spikes/card-harness/scripts/card-catalog.mjs @@ -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 + } +} diff --git a/spikes/card-harness/scripts/card-catalog.test.mjs b/spikes/card-harness/scripts/card-catalog.test.mjs new file mode 100644 index 0000000..bcbcc78 --- /dev/null +++ b/spikes/card-harness/scripts/card-catalog.test.mjs @@ -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'))) +}) diff --git a/spikes/card-harness/scripts/pack-performance.test.mjs b/spikes/card-harness/scripts/pack-performance.test.mjs index 71547cb..b80eb50 100644 --- a/spikes/card-harness/scripts/pack-performance.test.mjs +++ b/spikes/card-harness/scripts/pack-performance.test.mjs @@ -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) diff --git a/spikes/card-harness/scripts/test-support.mjs b/spikes/card-harness/scripts/test-support.mjs index 0b959f4..63c67ec 100644 --- a/spikes/card-harness/scripts/test-support.mjs +++ b/spikes/card-harness/scripts/test-support.mjs @@ -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 } } diff --git a/spikes/card-harness/src/cardCatalog.ts b/spikes/card-harness/src/cardCatalog.ts new file mode 100644 index 0000000..96c39a9 --- /dev/null +++ b/spikes/card-harness/src/cardCatalog.ts @@ -0,0 +1,60 @@ +export interface CardAsset { + name: string + artwork: string + mask: string + revision: string + width: number + height: number +} + +export interface CardCatalog { + schemaVersion: 1 + cards: CardAsset[] + errors: string[] +} + +function isCardAsset(value: unknown): value is CardAsset { + if (typeof value !== 'object' || value === null) return false + const card = value as Record + return typeof card.name === 'string' && card.name.length > 0 && + typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') && + typeof card.mask === 'string' && card.mask.startsWith('/card-art/') && + typeof card.revision === 'string' && + Number.isInteger(card.width) && Number.isInteger(card.height) +} + +export async function fetchCardCatalog(): Promise { + const urls = import.meta.env.DEV + ? ['/__card_catalog', '/card-art/manifest.json'] + : ['/card-art/manifest.json'] + const failures: string[] = [] + for (const url of urls) { + try { + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`request failed (${response.status})`) + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + throw new Error(`expected JSON but received ${contentType || 'an unknown content type'}`) + } + return validateCardCatalog(await response.json()) + } catch (error) { + failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`) + } + } + throw new Error(`Card catalog unavailable: ${failures.join('; ')}`) +} + +function validateCardCatalog(value: unknown): CardCatalog { + if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object') + const catalog = value as Record + if ( + catalog.schemaVersion !== 1 || + !Array.isArray(catalog.cards) || + !catalog.cards.every(isCardAsset) || + !Array.isArray(catalog.errors) || + !catalog.errors.every(error => typeof error === 'string') + ) { + throw new Error('Card catalog has an unsupported or invalid format') + } + return catalog as unknown as CardCatalog +} diff --git a/spikes/card-harness/src/foilWrapper.ts b/spikes/card-harness/src/foilWrapper.ts index 4f5afa4..f82535d 100644 --- a/spikes/card-harness/src/foilWrapper.ts +++ b/spikes/card-harness/src/foilWrapper.ts @@ -1,4 +1,5 @@ import * as THREE from 'three' +import { createPackTexture, defaultPackStyle, packStyles, type PackStyle } from './packDesigns' const halfWidth = 1.68 const bottom = -2.24 @@ -7,50 +8,6 @@ const top = 2.28 const midDepth = -0.09 const smooth = THREE.MathUtils.smoothstep -function wrapperTexture(back = false) { - const canvas = document.createElement('canvas') - canvas.width = 1024 - canvas.height = 1400 - const context = canvas.getContext('2d')! - context.fillStyle = '#263a40' - context.fillRect(0, 0, 1024, 1400) - const gradient = context.createLinearGradient(0, 0, 1024, 1400) - gradient.addColorStop(0, '#ffffff0c') - gradient.addColorStop(0.45, '#ffffff00') - gradient.addColorStop(1, '#00000022') - context.fillStyle = gradient - context.fillRect(0, 0, 1024, 1400) - context.strokeStyle = '#a59569' - context.lineWidth = 2 - context.strokeRect(66, 155, 892, 1100) - context.strokeRect(80, 169, 864, 1072) - context.fillStyle = '#baa775' - context.fillRect(0, 0, 1024, 100) - context.fillRect(0, 1315, 1024, 85) - context.textAlign = 'center' - context.fillStyle = '#283339' - context.font = 'bold 19px sans-serif' - context.fillText('P U L L T O O P E N →', 512, 58) - context.fillStyle = '#d8c994' - context.save() - context.translate(512, 480) - context.rotate(Math.PI / 4) - context.strokeRect(-100, -100, 200, 200) - context.strokeRect(-80, -80, 160, 160) - context.restore() - context.font = '56px Georgia' - context.fillText('S', 512, 500) - context.font = '44px Georgia' - context.fillText('SANCTIFICATION', 512, 740) - context.font = '21px sans-serif' - context.fillText('C O L L E C T O R S E R I E S', 512, 795) - context.font = '19px sans-serif' - context.fillText(back ? 'AUTHORED EDITION / 001' : 'THREE CARDS / ONE COLLECTION', 512, 1175) - const texture = new THREE.CanvasTexture(canvas) - texture.colorSpace = THREE.SRGBColorSpace - return texture -} - /** * Two joined pouch surfaces and one sealed ribbon, never a label over a shell. * All deformation is evaluated from immutable UVs, not accumulated frame deltas. @@ -58,13 +15,37 @@ function wrapperTexture(back = false) { export function createFoilWrapper() { const root = new THREE.Group() root.name = 'PACK_FOIL_WRAPPER' - const frontTexture = wrapperTexture() + const designs = new Map() + const textures: THREE.Texture[] = [] + try { + for (const style of packStyles) { + const front = createPackTexture(style) + textures.push(front) + const back = createPackTexture(style, true) + textures.push(back) + designs.set(style, { front, back }) + } + } catch (error) { + for (const texture of textures) texture.dispose() + throw error + } + let activeStyle = defaultPackStyle + const initialDesign = designs.get(activeStyle)! const frontMaterial = new THREE.MeshStandardMaterial({ - map: frontTexture, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide, + map: initialDesign.front, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide, }) const backMaterial = frontMaterial.clone() - backMaterial.map = wrapperTexture(true) + backMaterial.map = initialDesign.back const stripMaterial = frontMaterial.clone() + function setStyle(style: PackStyle) { + const design = designs.get(style) + if (!design) throw new Error(`Unknown pack style: ${style}`) + if (style === activeStyle) return + frontMaterial.map = design.front + backMaterial.map = design.back + stripMaterial.map = design.front + activeStyle = style + } const sheets = [1, -1].map((side) => { const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80) const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial) @@ -176,9 +157,8 @@ export function createFoilWrapper() { function dispose() { for (const { mesh } of sheets) mesh.geometry.dispose() stripGeometry.dispose() - frontTexture.dispose() - backMaterial.map!.dispose() + for (const texture of textures) texture.dispose() for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose() } - return { root, deform, dispose } + return { root, deform, dispose, setStyle, textures: Object.freeze(textures), get style() { return activeStyle } } } diff --git a/spikes/card-harness/src/main.ts b/spikes/card-harness/src/main.ts index 374eab4..63a2743 100644 --- a/spikes/card-harness/src/main.ts +++ b/spikes/card-harness/src/main.ts @@ -12,9 +12,11 @@ import { type SubstrateName, } from './cardMaterial' import { createCardGeometry } from './cardGeometry' -import { PackOpening, type PackFixture } from './packOpening' +import { PackOpening, packSize, rollPackContents } from './packOpening' +import { defaultPackStyle, packStyles } from './packDesigns' +import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog' -type FixtureName = PackFixture +type FixtureName = string type AppMode = 'Inspect' | 'Lab' | 'Pack' type ManipulationTarget = 'Card' | 'Camera' type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back' @@ -50,16 +52,10 @@ interface ExperimentSnapshot { orbitTarget: [number, number, number] } -const fixtureAssets: Record = { - David: { - artwork: '/reference/david-front.png', - mask: '/reference/david-finish-mask.png', - }, - Timothy: { - artwork: '/reference/timothy-front.png', - mask: '/reference/timothy-finish-mask.png', - }, -} +let cardCatalog = await fetchCardCatalog() +if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs') +let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card])) +let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) { texture.colorSpace = colorSpace @@ -93,12 +89,16 @@ document.querySelector('#app')!.innerHTML = ` - +