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

@@ -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<FixtureName, { artwork: string; mask: string }> = {
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<HTMLDivElement>('#app')!.innerHTML = `
<option>Pack</option>
</select>
</label>
<label>Card
<select id="fixture">
<option>David</option>
<option>Timothy</option>
<label class="pack-style">Pack style
<select id="pack-style" title="Change the wrapper only; use Restart to compare sealed packs" disabled>
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
</select>
</label>
<label>Card
<span class="catalog-state" id="catalog-state" aria-live="polite"></span>
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false">
<datalist id="fixture-options"></datalist>
</label>
<label>Finish
<select id="finish">
<option>Holographic</option>
@@ -110,7 +110,6 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<select id="substrate">
<option>Paper</option>
<option>Linen</option>
<option>Plastic</option>
<option>Metal</option>
<option>Wood</option>
</select>
@@ -159,7 +158,7 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<span id="pack-status" role="status" aria-live="polite"></span>
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
<div class="pack-actions">
<button id="pack-restart" type="button">Restart</button>
<button id="pack-restart" type="button">New pack</button>
<button id="pack-primary" type="button">Open pack</button>
<button id="pack-skip" type="button">Skip</button>
</div>
@@ -184,7 +183,9 @@ const hintElement = document.querySelector<HTMLDivElement>('#hint')!
const topbar = document.querySelector<HTMLElement>('.topbar')!
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
const fixtureSelect = document.querySelector<HTMLSelectElement>('#fixture')!
const fixtureSelect = document.querySelector<HTMLInputElement>('#fixture')!
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')!
const catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
@@ -202,6 +203,23 @@ const packTearProgress = document.querySelector<HTMLProgressElement>('#pack-tear
const packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
function renderCardCatalog(catalog: CardCatalog) {
fixtureOptions.replaceChildren(...catalog.cards.map(card => {
const option = document.createElement('option')
option.value = card.name
return option
}))
catalogStateElement.textContent = catalog.errors.length
? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
: `${catalog.cards.length} cards`
catalogStateElement.classList.toggle('has-errors', catalog.errors.length > 0)
catalogStateElement.title = catalog.errors.join('\n')
}
renderCardCatalog(cardCatalog)
fixtureSelect.value = defaultFixture
function setControlsCollapsed(collapsed: boolean) {
topbar.classList.toggle('controls-collapsed', collapsed)
@@ -270,8 +288,9 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
backTexture.colorSpace = THREE.SRGBColorSpace
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork)
const initialMask = await loader.loadAsync(fixtureAssets.David.mask)
const initialAsset = fixtureAssets.get(defaultFixture)!
const initialArtwork = await loader.loadAsync(initialAsset.artwork)
const initialMask = await loader.loadAsync(initialAsset.mask)
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
configureFrontTexture(initialMask, THREE.NoColorSpace)
@@ -413,7 +432,7 @@ surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).na
updateMaterial()
updateStatus()
})
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
pauseScriptedMotion()
substrateSelect.value = value
updateMaterial()
@@ -515,7 +534,7 @@ const assetActions = {
loadMask: () => maskFileInput.click(),
restoreFixture: () => {
pauseScriptedMotion()
void loadFixture(fixtureSelect.value as FixtureName)
void loadFixture(fixtureSelect.value)
},
}
const assetFolder = gui.addFolder('Assets')
@@ -546,7 +565,7 @@ gui.hide()
let mode: AppMode = 'Inspect'
let textureRequest = 0
let activeFixture: FixtureName = 'David'
let activeFixture: FixtureName = defaultFixture
let customArtworkName: string | undefined
let customMaskName: string | undefined
let dragging = false
@@ -567,6 +586,8 @@ let pack: PackOpening | undefined
let packLoading = false
let packUIDirty = false
let packError: string | undefined
let packTextures: THREE.Texture[] = []
let selectedPackStyle = defaultPackStyle
function updateMaterial() {
applyMaterialControls(frontMaterial, controls)
@@ -696,12 +717,16 @@ function updatePackUI() {
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
setIfChanged(packRestartButton, 'disabled', !pack)
setIfChanged(packRestartButton, 'disabled', packLoading || fixtureAssets.size === 0)
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
setIfChanged(packRestartButton, 'textContent', 'New pack')
setIfChanged(packStyleSelect, 'disabled', !pack || packLoading)
setIfChanged(packStyleSelect, 'value', pack?.style ?? defaultPackStyle)
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
setIfChanged(packStatusElement, 'textContent', status)
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
setIfChanged(statusElement, 'textContent',
`Random foil pack · ${packSize} cards · equal card / material / finish odds`)
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
}
@@ -709,8 +734,8 @@ function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
if (target[key] !== value) target[key] = value
}
async function preparePack() {
if (pack) {
async function preparePack(newRoll = false) {
if (pack && !newRoll) {
pack.syncLighting(frontMaterial)
pack.setActive(true)
updatePackUI()
@@ -719,13 +744,26 @@ async function preparePack() {
if (packLoading) return
packLoading = true
packError = undefined
if (newRoll && pack) {
selectedPackStyle = pack.style
pack.dispose()
pack = undefined
for (const texture of packTextures) texture.dispose()
packTextures = []
}
updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try {
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask]
const contents = rollPackContents([...fixtureAssets.keys()])
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
const selectedAssets = selectedNames.map(name => {
const asset = fixtureAssets.get(name)
if (!asset) throw new Error(`Card "${name}" disappeared from the catalog while rolling`)
return asset
})
// Only cards selected for this pack are loaded; a large catalog does not consume GPU memory.
const paths = selectedAssets.flatMap(asset => [asset.artwork, asset.mask])
const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
@@ -739,18 +777,21 @@ async function preparePack() {
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
return result.value
})
const selectedTextures = new Map(selectedAssets.map((asset, index) => [
asset.name,
{ artwork: textures[index * 2], mask: textures[index * 2 + 1] },
]))
preparedPack = new PackOpening({
canvas,
textures: {
David: { artwork: textures[0], mask: textures[1] },
Timothy: { artwork: textures[2], mask: textures[3] },
},
contents,
textures: selectedTextures,
backMaterial,
normalMap,
environment: activeEnvironment,
lightPosition,
onChange: () => { packUIDirty = true },
})
preparedPack.setStyle(selectedPackStyle)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
let preparedEnvironment: THREE.Texture | null
let preparedLightType: LightType
@@ -765,6 +806,7 @@ async function preparePack() {
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
packTextures = textures
} catch (error) {
preparedPack?.dispose()
for (const texture of textures) texture.dispose()
@@ -789,7 +831,7 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
if (typeof value !== 'object' || value === null) return false
const snapshot = value as Record<string, unknown>
const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy'
const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
const finishValid =
snapshot.finish === 'Printed ink' ||
snapshot.finish === 'Foil' ||
@@ -986,6 +1028,9 @@ function captureExperimentPng() {
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
pauseScriptedMotion()
if (!fixtureAssets.has(snapshot.fixture)) {
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
}
fixtureSelect.value = snapshot.fixture
await loadFixture(snapshot.fixture)
controls.finish = snapshot.finish
@@ -1072,7 +1117,8 @@ function applyInspectionPose(pose: InspectionPose) {
function resetView() {
pauseScriptedMotion()
const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined
const resetFixture = activeFixture !== defaultFixture ||
customArtworkName !== undefined || customMaskName !== undefined
rotationTarget.set(-0.06, 0.12, 0)
cardRoot.rotation.copy(rotationTarget)
camera.position.set(0, 0, 8.2)
@@ -1080,7 +1126,7 @@ function resetView() {
orbit.update()
modeSelect.value = 'Inspect'
controls.target = 'Card'
fixtureSelect.value = 'David'
fixtureSelect.value = defaultFixture
finishSelect.value = 'Holographic'
substrateSelect.value = 'Paper'
poseSelect.value = 'Front'
@@ -1096,7 +1142,7 @@ function resetView() {
updateEdgeMaterial()
updateMode()
if (resetFixture) {
void loadFixture('David')
void loadFixture(defaultFixture)
} else {
updateStatus()
}
@@ -1107,7 +1153,13 @@ async function loadFixture(nextFixture: FixtureName) {
const request = ++textureRequest
loadingElement.hidden = false
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
const asset = fixtureAssets[nextFixture]
const asset = fixtureAssets.get(nextFixture)
if (!asset) {
loadingElement.hidden = true
fixtureSelect.value = activeFixture
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
return
}
try {
const results = await Promise.allSettled([
@@ -1119,6 +1171,7 @@ async function loadFixture(nextFixture: FixtureName) {
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
throw artworkResult.reason
}
if (maskResult.status === 'rejected') {
artworkResult.value.dispose()
throw maskResult.reason
@@ -1157,6 +1210,45 @@ async function loadFixture(nextFixture: FixtureName) {
}
}
function resolveFixtureName(value: string) {
if (fixtureAssets.has(value)) return value
const normalized = value.toLocaleLowerCase()
return [...fixtureAssets.keys()].find(name => name.toLocaleLowerCase() === normalized)
}
async function refreshCardCatalog() {
try {
const nextCatalog = await fetchCardCatalog()
if (!nextCatalog.cards.length) {
throw new Error(nextCatalog.errors.join('; ') || 'No valid artwork/mask pairs found')
}
const previousAsset: CardAsset | undefined = fixtureAssets.get(activeFixture)
const nextAssets = new Map(nextCatalog.cards.map(card => [card.name, card]))
const nextAsset = nextAssets.get(activeFixture)
cardCatalog = nextCatalog
fixtureAssets = nextAssets
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
renderCardCatalog(nextCatalog)
if (nextCatalog.errors.length) {
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
}
if (
nextAsset &&
previousAsset?.revision !== nextAsset.revision &&
customArtworkName === undefined &&
customMaskName === undefined
) {
await loadFixture(activeFixture)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
catalogStateElement.textContent = 'Catalog error'
catalogStateElement.classList.add('has-errors')
catalogStateElement.title = message
statusElement.textContent = `Could not refresh card catalog: ${message}`
}
}
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
const request = ++textureRequest
const objectUrl = URL.createObjectURL(file)
@@ -1331,17 +1423,46 @@ packPrimaryButton.addEventListener('click', () => {
if (pack) pack.primary()
else void preparePack()
})
packRestartButton.addEventListener('click', () => pack?.restart())
packRestartButton.addEventListener('click', () => { void preparePack(true) })
packSkipButton.addEventListener('click', () => pack?.skip())
packStyleSelect.addEventListener('change', () => {
try {
const style = packStyles.find(style => style === packStyleSelect.value)
if (!style || !pack) throw new Error('The selected pack style is unavailable')
selectedPackStyle = style
pack.setStyle(style)
updatePackUI()
} catch (error) {
console.error('Could not change pack style', error)
packStyleSelect.value = pack?.style ?? defaultPackStyle
packStatusElement.textContent = `Pack style unavailable: ${error instanceof Error ? error.message : String(error)}`
}
})
modeSelect.addEventListener('change', () => {
pauseScriptedMotion()
updateMode()
})
fixtureSelect.addEventListener('change', () => {
function selectFixtureFromPicker() {
const fixture = resolveFixtureName(fixtureSelect.value.trim())
if (!fixture) {
statusElement.textContent = `No card named "${fixtureSelect.value.trim()}" exists in the card-art folder.`
fixtureSelect.value = activeFixture
return
}
fixtureSelect.value = fixture
pauseScriptedMotion()
void loadFixture(fixtureSelect.value as FixtureName)
void loadFixture(fixture)
}
fixtureSelect.addEventListener('change', selectFixtureFromPicker)
fixtureSelect.addEventListener('focus', () => { void refreshCardCatalog() })
fixtureSelect.addEventListener('keydown', event => {
if (event.key === 'Enter') selectFixtureFromPicker()
})
window.addEventListener('focus', () => { void refreshCardCatalog() })
if (import.meta.hot) {
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
}
substrateSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.substrate = substrateSelect.value as SubstrateName