Improved harness - with card selection

This commit is contained in:
2026-09-15 06:08:43 -07:00
parent 97ed7f05c8
commit 786f63a042
29 changed files with 1398 additions and 494 deletions

View File

@@ -1,5 +1,9 @@
export const resolutionNames = ['low', 'med', 'high'] as const
export const printingNames = ['normal', 'borderless', 'textless', 'boundless'] as const
export type CardResolution = typeof resolutionNames[number]
export type CardPrinting = typeof printingNames[number]
export interface CardAsset {
name: string
artwork: string
mask: string
textMask?: string
@@ -7,56 +11,57 @@ export interface CardAsset {
width: number
height: number
}
export interface CardCatalog {
schemaVersion: 1
cards: CardAsset[]
errors: string[]
export interface CatalogCard {
cardId: string
title: string
rarity: string
folderName: string
acceptedRevision: string
variants: Record<CardResolution, Record<CardPrinting, CardAsset>>
}
export interface CardCatalog { schemaVersion: 2; cards: CatalogCard[]; errors: string[] }
function isCardAsset(value: unknown): value is CardAsset {
function isAsset(value: unknown): value is CardAsset {
if (typeof value !== 'object' || value === null) return false
const card = value as Record<string, unknown>
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/') &&
(card.textMask === undefined || (typeof card.textMask === 'string' && card.textMask.startsWith('/card-art/'))) &&
typeof card.revision === 'string' &&
Number.isInteger(card.width) && Number.isInteger(card.height)
const asset = value as Record<string, unknown>
return typeof asset.artwork === 'string' && asset.artwork.startsWith('/__card_asset/') &&
typeof asset.mask === 'string' && asset.mask.startsWith('/__card_asset/') &&
(asset.textMask === undefined || (typeof asset.textMask === 'string' && asset.textMask.startsWith('/__card_asset/'))) &&
typeof asset.revision === 'string' && Number.isInteger(asset.width) && Number.isInteger(asset.height)
}
export async function fetchCardCatalog(): Promise<CardCatalog> {
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 isCard(value: unknown): value is CatalogCard {
if (typeof value !== 'object' || value === null) return false
const card = value as Record<string, unknown>, variants = card.variants as Record<string, unknown> | undefined
return typeof card.cardId === 'string' && typeof card.title === 'string' && typeof card.rarity === 'string' &&
typeof card.folderName === 'string' && typeof card.acceptedRevision === 'string' && !!variants &&
resolutionNames.every(resolution => {
const group = variants[resolution] as Record<string, unknown> | undefined
return !!group && printingNames.every(printing => isAsset(group[printing]))
})
}
export 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<string, unknown>
if (
catalog.schemaVersion !== 1 ||
!Array.isArray(catalog.cards) ||
!catalog.cards.every(isCardAsset) ||
!Array.isArray(catalog.errors) ||
!catalog.errors.every(error => typeof error === 'string')
) {
if (catalog.schemaVersion !== 2 || !Array.isArray(catalog.cards) || !catalog.cards.every(isCard) ||
!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
}
export async function fetchCardCatalog(): Promise<CardCatalog> {
const response = await fetch('/__card_catalog', { cache: 'no-store' })
if (!response.ok) throw new Error(`Card catalog request failed (${response.status})`)
const type = response.headers.get('content-type') ?? ''
if (!type.includes('application/json')) throw new Error(`Card catalog returned ${type || 'unknown content'}`)
return validateCardCatalog(await response.json())
}
export function resolveCardAsset(card: CatalogCard, resolution: CardResolution, printing: CardPrinting) {
return card.variants[resolution][printing]
}
export function cardLabel(card: CatalogCard) { return `${card.title} · ${card.cardId}` }
export function findLegacyCard(cards: readonly CatalogCard[], value: string) {
const normalized = value.trim().toLocaleLowerCase()
return cards.find(card => card.cardId.toLocaleLowerCase() === normalized ||
card.title.toLocaleLowerCase() === normalized || card.folderName.toLocaleLowerCase() === normalized ||
card.folderName.slice(card.cardId.length + 1).toLocaleLowerCase() === normalized)
}

View File

@@ -0,0 +1,123 @@
import { cardLabel, type CatalogCard } from './cardCatalog'
type SelectCard = (card: CatalogCard) => void
export class CardPicker {
private cards: readonly CatalogCard[] = []
private selectedCardId = ''
private visibleCount = 60
private readonly dialog = document.createElement('dialog')
private readonly search = document.createElement('input')
private readonly rarity = document.createElement('select')
private readonly count = document.createElement('span')
private readonly results = document.createElement('div')
private readonly onSelect: SelectCard
constructor(cards: readonly CatalogCard[], onSelect: SelectCard) {
this.onSelect = onSelect
this.dialog.className = 'card-picker'
this.dialog.setAttribute('aria-labelledby', 'card-picker-title')
this.search.type = 'search'
this.search.placeholder = 'Search title or card ID'
this.search.autocomplete = 'off'
this.search.spellcheck = false
this.search.setAttribute('aria-label', 'Search accepted cards')
this.rarity.setAttribute('aria-label', 'Filter by rarity')
this.results.className = 'card-picker-results'
this.results.addEventListener('scroll', () => {
if (this.results.scrollTop + this.results.clientHeight >= this.results.scrollHeight - 320) {
this.visibleCount += 60
this.render()
}
})
this.dialog.innerHTML = `
<div class="card-picker-header">
<div><p class="eyebrow">Accepted card library</p><h2 id="card-picker-title">Choose a card</h2></div>
<button class="card-picker-close" type="button" aria-label="Close card library">×</button>
</div>
<div class="card-picker-filters"></div>
`
this.dialog.querySelector('.card-picker-filters')!.append(this.search, this.rarity, this.count)
this.dialog.append(this.results)
document.body.append(this.dialog)
this.dialog.querySelector<HTMLButtonElement>('.card-picker-close')!.addEventListener('click', () => this.close())
this.dialog.addEventListener('click', event => { if (event.target === this.dialog) this.close() })
this.dialog.addEventListener('cancel', event => { event.preventDefault(); this.close() })
this.search.addEventListener('input', () => { this.visibleCount = 60; this.render() })
this.rarity.addEventListener('change', () => { this.visibleCount = 60; this.render() })
this.update(cards)
}
update(cards: readonly CatalogCard[]) {
this.cards = cards
const selectedRarity = this.rarity.value
const rarities = [...new Set(cards.map(card => card.rarity))].sort()
this.rarity.replaceChildren(
new Option('All rarities', ''),
...rarities.map(value => new Option(value, value)),
)
if (rarities.includes(selectedRarity)) this.rarity.value = selectedRarity
this.render()
}
open(selectedCardId: string) {
this.selectedCardId = selectedCardId
this.visibleCount = 60
this.render()
this.dialog.showModal()
requestAnimationFrame(() => this.search.focus())
}
close() { if (this.dialog.open) this.dialog.close() }
adjacent(selectedCardId: string, delta: -1 | 1) {
const index = this.cards.findIndex(card => card.cardId === selectedCardId)
if (index < 0 || !this.cards.length) return undefined
return this.cards[(index + delta + this.cards.length) % this.cards.length]
}
private filteredCards() {
const words = this.search.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
return this.cards.filter(card => {
if (this.rarity.value && card.rarity !== this.rarity.value) return false
const haystack = `${card.title} ${card.cardId} ${card.rarity}`.toLocaleLowerCase()
return words.every(word => haystack.includes(word))
})
}
private render() {
const filtered = this.filteredCards()
this.count.textContent = `${filtered.length} card${filtered.length === 1 ? '' : 's'}`
const fragment = document.createDocumentFragment()
for (const card of filtered.slice(0, this.visibleCount)) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'card-picker-result'
button.classList.toggle('selected', card.cardId === this.selectedCardId)
button.setAttribute('aria-label', `Choose ${cardLabel(card)}, ${card.rarity}`)
const image = document.createElement('img')
image.src = card.variants.low.normal.artwork
image.alt = ''
image.loading = 'lazy'
image.decoding = 'async'
const copy = document.createElement('span')
copy.innerHTML = `<strong></strong><small></small>`
copy.querySelector('strong')!.textContent = card.title
copy.querySelector('small')!.textContent = `${card.cardId} · ${card.rarity}`
button.append(image, copy)
button.addEventListener('click', () => {
this.selectedCardId = card.cardId
this.close()
this.onSelect(card)
})
fragment.append(button)
}
this.results.replaceChildren(fragment)
if (!filtered.length) {
const empty = document.createElement('p')
empty.className = 'card-picker-empty'
empty.textContent = 'No accepted cards match this search.'
this.results.append(empty)
}
}
}

View File

@@ -3,6 +3,7 @@ import {
finishNames, substrateNames, surfaceDetailMax,
type FinishName, type SubstrateName, type MaterialControls,
} from './cardMaterial'
import { printingNames, resolutionNames, type CardPrinting, type CardResolution } from './cardCatalog'
type FixtureName = string
type LightingPreset = 'Studio' | 'Warm gallery' | 'Cool window' | 'Dramatic spot' | 'Flat review' | 'Custom'
@@ -10,11 +11,15 @@ type EnvironmentName = 'Studio' | 'Warm' | 'Cool' | 'Dark' | 'Neutral'
type LightType = 'Point' | 'Directional' | 'Spot'
export interface ExperimentSnapshot {
schemaVersion: 1 | 2
schemaVersion: 1 | 2 | 3
runtimeRevision: 'runtime-look-v4-2026-09-07'
name: string
savedAt: string
fixture: FixtureName
fixture?: FixtureName
cardId?: string
acceptedRevision?: string
resolution?: CardResolution
printing?: CardPrinting
customArtworkName?: string
customMaskName?: string
finish: FinishName
@@ -52,7 +57,11 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
export function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
if (typeof value !== 'object' || value === null) return false
const snapshot = value as Record<string, unknown>
const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
const legacyFixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
const cardSelectionValid = typeof snapshot.cardId === 'string' && snapshot.cardId.length > 0 &&
typeof snapshot.acceptedRevision === 'string' &&
resolutionNames.some(name => name === snapshot.resolution) &&
printingNames.some(name => name === snapshot.printing)
const finishValid = finishNames.some(name => name === snapshot.finish)
const substrateValid = snapshot.substrate === 'Plastic' ||
substrateNames.some(name => name === snapshot.substrate)
@@ -77,7 +86,8 @@ export function isExperimentSnapshot(value: unknown): value is ExperimentSnapsho
(snapshot.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') &&
(snapshot.customMaskName === undefined || typeof snapshot.customMaskName === 'string')
const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2
const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2 || snapshot.schemaVersion === 3
const selectionValid = snapshot.schemaVersion === 3 ? cardSelectionValid : legacyFixtureValid
const wearValid = snapshot.schemaVersion === 1 ||
(isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed))
@@ -85,7 +95,7 @@ export function isExperimentSnapshot(value: unknown): value is ExperimentSnapsho
snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' &&
typeof snapshot.name === 'string' &&
typeof snapshot.savedAt === 'string' &&
fixtureValid &&
selectionValid &&
optionalNamesValid &&
finishValid &&
substrateValid &&

View File

@@ -8,7 +8,6 @@ import {
applyMaterialControls,
createCardMaterial,
createStudioCubeTexture,
defaultSurfaceDetail,
surfaceDetailMax,
finishNames,
substrateNames,
@@ -21,10 +20,15 @@ import {
isExperimentSnapshot, type ExperimentSnapshot,
} from './experimentSnapshot'
import { createCardGeometry } from './cardGeometry'
import { PackOpening, packSize, rollPackContents } from './packOpening'
import { PackOpening, packSize, rollPackContents, type CardTextureLease, type PackCardSpec } from './packOpening'
import { defaultPackStyle, packStyles } from './packDesigns'
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog'
import { loadCardTextures, disposeCardTextures } from './cardTextures'
import {
cardLabel, fetchCardCatalog, findLegacyCard, printingNames, resolutionNames, resolveCardAsset,
type CardCatalog, type CardPrinting, type CardResolution,
} from './cardCatalog'
import { CardPicker } from './cardPicker'
import { SurfaceDefaultsStore } from './surfaceDefaults'
import { loadCardTextures, disposeCardTextures, type CardTextures } from './cardTextures'
import { RenderCadence } from './renderCadence'
import { PerformanceRecording } from './performanceRecording'
import { drawingPixelRatio, FramePacing, GpuTimer, median, rendererIdentity } from './renderDiagnostics'
@@ -48,9 +52,13 @@ let displayedIdle = false
let observedPixelRatio = window.devicePixelRatio
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
if (!cardCatalog.cards.length) throw new Error('The accepted artifacts catalog contains no valid cards')
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.cardId, card]))
let defaultFixture = fixtureAssets.has('BP-002') ? 'BP-002' : cardCatalog.cards[0].cardId
let selectedResolution: CardResolution = 'high'
let selectedPrinting: CardPrinting = 'normal'
const defaultsStore = new SurfaceDefaultsStore()
await defaultsStore.refresh()
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
texture.colorSpace = colorSpace
@@ -89,10 +97,23 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
</select>
</label>
<label>Card
<label class="card-control">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>
<span class="card-select-row">
<button id="card-previous" type="button" aria-label="Previous card">‹</button>
<button id="card-picker-open" type="button"></button>
<button id="card-next" type="button" aria-label="Next card">›</button>
</span>
</label>
<label class="variant-control">Resolution
<select id="resolution">
${resolutionNames.map(name => `<option${name === 'high' ? ' selected' : ''}>${name}</option>`).join('')}
</select>
</label>
<label class="variant-control">Printing
<select id="printing">
${printingNames.map(name => `<option>${name}</option>`).join('')}
</select>
</label>
<label>Finish
<select id="finish">
@@ -133,9 +154,10 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<button id="flip" type="button">Flip</button>
<button id="reset" type="button">Reset</button>
<button id="sweep" type="button">Play sweep</button>
<button id="record-timings" type="button" title="Record 30 seconds: stay idle, then rotate/sweep or open a pack">Record timings</button>
<button id="export-timings" type="button" disabled>Export timings</button>
<span id="timing-record-status" role="status"></span>
<button class="diagnostics-toggle" id="diagnostics-toggle" type="button" aria-pressed="false">Show diagnostics</button>
<button class="diagnostic-control" id="record-timings" type="button" title="Record 30 seconds: stay idle, then rotate/sweep or open a pack">Record timings</button>
<button class="diagnostic-control" id="export-timings" type="button" disabled>Export timings</button>
<span class="diagnostic-control" id="timing-record-status" role="status"></span>
</div>
</header>
@@ -144,8 +166,8 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="loading" id="loading" hidden>Loading card…</div>
<div class="status-panel">
<span id="status">Preparing reference assets…</span>
<span id="performance">Frame time —</span>
<span id="render-diagnostics" style="font-size: 10px; color: #89909b"></span>
<span class="diagnostic-readout" id="performance">Frame time —</span>
<span class="diagnostic-readout" id="render-diagnostics"></span>
</div>
<div class="hint" id="hint">Drag the card · pinch or wheel to zoom · flip to inspect the back</div>
<div class="pack-controls" id="pack-controls" aria-label="Pack opening controls" hidden>
@@ -173,6 +195,7 @@ const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
const statusElement = document.querySelector<HTMLSpanElement>('#status')!
const performanceElement = document.querySelector<HTMLSpanElement>('#performance')!
const renderDiagnosticsElement = document.querySelector<HTMLSpanElement>('#render-diagnostics')!
const diagnosticsToggle = document.querySelector<HTMLButtonElement>('#diagnostics-toggle')!
const recordTimingsButton = document.querySelector<HTMLButtonElement>('#record-timings')!
const exportTimingsButton = document.querySelector<HTMLButtonElement>('#export-timings')!
const timingRecordStatus = document.querySelector<HTMLSpanElement>('#timing-record-status')!
@@ -181,8 +204,11 @@ 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<HTMLInputElement>('#fixture')!
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')!
const cardPickerButton = document.querySelector<HTMLButtonElement>('#card-picker-open')!
const previousCardButton = document.querySelector<HTMLButtonElement>('#card-previous')!
const nextCardButton = document.querySelector<HTMLButtonElement>('#card-next')!
const resolutionSelect = document.querySelector<HTMLSelectElement>('#resolution')!
const printingSelect = document.querySelector<HTMLSelectElement>('#printing')!
const catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
@@ -203,12 +229,16 @@ const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-resta
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
function setDiagnosticsEnabled(enabled: boolean) {
document.documentElement.classList.toggle('diagnostics-enabled', enabled)
diagnosticsToggle.setAttribute('aria-pressed', String(enabled))
diagnosticsToggle.textContent = enabled ? 'Hide diagnostics' : 'Show diagnostics'
renderCadence.invalidate()
}
setDiagnosticsEnabled(false)
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`
@@ -216,8 +246,13 @@ function renderCardCatalog(catalog: CardCatalog) {
catalogStateElement.title = catalog.errors.join('\n')
}
function updateCardButton(cardId = activeFixture) {
const card = fixtureAssets.get(cardId)
cardPickerButton.textContent = card ? cardLabel(card) : cardId
cardPickerButton.title = card ? `${card.rarity} · accepted ${card.acceptedRevision.slice(0, 10)}` : ''
}
renderCardCatalog(cardCatalog)
fixtureSelect.value = defaultFixture
function setControlsCollapsed(collapsed: boolean) {
topbar.classList.toggle('controls-collapsed', collapsed)
@@ -292,11 +327,56 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
backTexture.colorSpace = THREE.SRGBColorSpace
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
const initialAsset = fixtureAssets.get(defaultFixture)!
const initialAsset = resolveCardAsset(fixtureAssets.get(defaultFixture)!, selectedResolution, selectedPrinting)
const initialTextures = await loadCardTextures(loader, initialAsset, renderer.capabilities.getMaxAnisotropy())
const initialArtwork = initialTextures.artwork
const initialMask = initialTextures.mask
function solidTexture(r: number, g: number, b: number, colorSpace: THREE.ColorSpace) {
const texture = new THREE.DataTexture(new Uint8Array([r, g, b, 255]), 1, 1, THREE.RGBAFormat)
texture.colorSpace = colorSpace
texture.needsUpdate = true
return texture
}
const packPlaceholderTextures: CardTextures = {
artwork: solidTexture(26, 29, 35, THREE.SRGBColorSpace),
mask: solidTexture(0, 0, 0, THREE.NoColorSpace),
}
class PackTextureCache {
private readonly entries = new Map<string, { promise: Promise<CardTextures>; references: number }>()
async acquire(spec: PackCardSpec): Promise<CardTextureLease> {
const card = fixtureAssets.get(spec.fixture)
if (!card) throw new Error(`Card "${spec.fixture}" disappeared from the catalog`)
const asset = resolveCardAsset(card, selectedResolution, selectedPrinting)
const key = asset.revision
let entry = this.entries.get(key)
if (!entry) {
entry = { promise: loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy()), references: 0 }
this.entries.set(key, entry)
}
entry.references++
try {
const textures = await entry.promise
let released = false
return { textures, release: () => {
if (released) return
released = true
entry!.references--
if (entry!.references === 0) {
this.entries.delete(key)
disposeCardTextures(textures)
}
} }
} catch (error) {
entry.references--
if (entry.references === 0) this.entries.delete(key)
throw error
}
}
}
const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const frontMaterial = createCardMaterial(
initialArtwork,
@@ -341,6 +421,8 @@ floor.position.y = -2.7
floor.receiveShadow = true
scene.add(floor)
const initialSurfaceDefaults = defaultsStore.resolved('Holographic', 'Paper')
const controls: MaterialControls & {
lightX: number
lightY: number
@@ -354,10 +436,7 @@ const controls: MaterialControls & {
} = {
finish: 'Holographic',
substrate: 'Paper',
finishStrength: 0.6,
roughness: 0.23,
normalStrength: defaultSurfaceDetail('Paper'),
metalBrushHorizontal: false,
...initialSurfaceDefaults,
environmentIntensity: 0.7,
condition: 1,
imperfectionSeed: 81251,
@@ -435,13 +514,15 @@ const surfaceFolder = gui.addFolder('Surface')
surfaceFolder.add(controls, 'finish', [...finishNames]).name('Finish').onChange((value: FinishName) => {
pauseScriptedMotion()
finishSelect.value = value
defaultsStore.applyFinish(controls, value)
gui.controllersRecursive().forEach(controller => controller.updateDisplay())
updateMaterial()
updateStatus()
})
surfaceFolder.add(controls, 'substrate', [...substrateNames]).name('Material').onChange((value: SubstrateName) => {
pauseScriptedMotion()
substrateSelect.value = value
controls.normalStrength = defaultSurfaceDetail(value)
defaultsStore.applyMaterial(controls, value)
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateMaterial()
updateEdgeMaterial()
@@ -469,6 +550,21 @@ surfaceFolder.add(controls, 'environmentIntensity', 0, 1.5, 0.01).name('Environm
updateMaterial()
})
const surfaceDefaultActions = {
reapplyFinish: () => { defaultsStore.applyFinish(controls, controls.finish); syncSurfaceControls() },
reapplyMaterial: () => { defaultsStore.applyMaterial(controls, controls.substrate); syncSurfaceControls() },
saveFinish: () => { void saveCurrentFinishDefault() },
saveMaterial: () => { void saveCurrentMaterialDefault() },
}
surfaceFolder.add(surfaceDefaultActions, 'reapplyFinish').name('Reapply finish default')
surfaceFolder.add(surfaceDefaultActions, 'reapplyMaterial').name('Reapply material default')
const saveFinishDefaultController = surfaceFolder.add(surfaceDefaultActions, 'saveFinish').name('Save finish as default')
const saveMaterialDefaultController = surfaceFolder.add(surfaceDefaultActions, 'saveMaterial').name('Save material as default')
if (!defaultsStore.writable) {
saveFinishDefaultController.disable()
saveMaterialDefaultController.disable()
}
const wearFolder = gui.addFolder('Wear')
wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => {
pauseScriptedMotion()
@@ -546,7 +642,7 @@ const assetActions = {
loadMask: () => maskFileInput.click(),
restoreFixture: () => {
pauseScriptedMotion()
void loadFixture(fixtureSelect.value)
void loadFixture(activeFixture)
},
}
const assetFolder = gui.addFolder('Assets')
@@ -578,6 +674,11 @@ gui.hide()
let mode: AppMode = 'Inspect'
let textureRequest = 0
let activeFixture: FixtureName = defaultFixture
const cardPicker = new CardPicker(cardCatalog.cards, card => {
pauseScriptedMotion()
void loadFixture(card.cardId)
})
updateCardButton()
let customArtworkName: string | undefined
let customMaskName: string | undefined
let dragging = false
@@ -600,9 +701,43 @@ let pack: PackOpening | undefined
let packLoading = false
let packUIDirty = false
let packError: string | undefined
let packTextures: THREE.Texture[] = []
let selectedPackStyle = defaultPackStyle
function syncSurfaceControls() {
finishSelect.value = controls.finish
substrateSelect.value = controls.substrate
gui.controllersRecursive().forEach(controller => controller.updateDisplay())
updateMaterial()
updateEdgeMaterial()
updateStatus()
}
async function saveCurrentFinishDefault() {
try {
await defaultsStore.saveFinish(controls.finish, { finishStrength: controls.finishStrength })
statusElement.textContent = `Saved ${controls.finish} finish default at ${controls.finishStrength.toFixed(2)}.`
} catch (error) {
statusElement.textContent = `Could not save finish default: ${error instanceof Error ? error.message : String(error)}`
}
}
async function saveCurrentMaterialDefault() {
if (controls.substrate === 'Plastic') {
statusElement.textContent = 'Plastic is a legacy experiment material and has no repository default.'
return
}
try {
await defaultsStore.saveMaterial(controls.substrate, {
surfaceDetail: controls.normalStrength,
roughness: controls.roughness,
metalBrushHorizontal: controls.metalBrushHorizontal,
})
statusElement.textContent = `Saved ${controls.substrate} material defaults.`
} catch (error) {
statusElement.textContent = `Could not save material default: ${error instanceof Error ? error.message : String(error)}`
}
}
function updateMaterial() {
renderCadence.invalidate()
applyMaterialControls(frontMaterial, controls)
@@ -725,7 +860,7 @@ function updateStatus() {
updatePackUI()
return
}
const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : activeFixture
const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : cardLabel(fixtureAssets.get(activeFixture)!)
const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : ''
statusElement.textContent = `${artworkLabel} · ${controls.substrate} · ${controls.finish}${maskLabel}`
}
@@ -759,7 +894,7 @@ function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
if (target[key] !== value) target[key] = value
}
async function preparePack(newRoll = false) {
async function preparePack(newRoll = false, retainedContents?: readonly PackCardSpec[]) {
if (pack && !newRoll) {
pack.syncLighting(frontMaterial)
pack.setActive(true)
@@ -769,46 +904,27 @@ async function preparePack(newRoll = false) {
if (packLoading) return
packLoading = true
packError = undefined
if (newRoll && pack) {
if (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 {
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 results = await Promise.allSettled(selectedAssets.map(asset =>
loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy())))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') {
for (const result of results) {
if (result.status === 'fulfilled') disposeCardTextures(result.value)
}
throw failure.reason
}
const selectedTextures = new Map(selectedAssets.map((asset, index) => {
const result = results[index]
if (result.status !== 'fulfilled') throw new Error(`Missing pack card: ${asset.name}`)
const loaded = result.value
textures.push(loaded.artwork, loaded.mask)
if (loaded.textMask) textures.push(loaded.textMask)
return [asset.name, loaded] as const
}))
const contents = retainedContents?.map(spec => ({ ...spec })) ?? rollPackContents([...fixtureAssets.keys()])
const textureCache = new PackTextureCache()
preparedPack = new PackOpening({
canvas,
contents,
textures: selectedTextures,
placeholderTextures: packPlaceholderTextures,
acquireTextures: spec => textureCache.acquire(spec),
prepareTextures: textures => {
renderer.initTexture(textures.artwork)
renderer.initTexture(textures.mask)
if (textures.textMask) renderer.initTexture(textures.textMask)
},
resolveSurfaceDefaults: (finish, substrate) => defaultsStore.resolved(finish, substrate),
backMaterial,
normalMap,
environment: activeEnvironment,
@@ -830,10 +946,8 @@ async function preparePack(newRoll = false) {
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
packTextures = textures
} catch (error) {
preparedPack?.dispose()
for (const texture of textures) texture.dispose()
packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}`
console.error('Could not prepare pack prototype', error)
} finally {
@@ -855,11 +969,14 @@ function normalizedExperimentName() {
function createExperimentSnapshot(name: string): ExperimentSnapshot {
pauseScriptedMotion()
return {
schemaVersion: 2,
schemaVersion: 3,
runtimeRevision: 'runtime-look-v4-2026-09-07',
name,
savedAt: new Date().toISOString(),
fixture: activeFixture,
cardId: activeFixture,
acceptedRevision: fixtureAssets.get(activeFixture)!.acceptedRevision,
resolution: selectedResolution,
printing: selectedPrinting,
customArtworkName,
customMaskName,
...captureExperimentMaterialControls(controls),
@@ -971,11 +1088,19 @@ 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`)
const experimentCard = snapshot.schemaVersion === 3
? fixtureAssets.get(snapshot.cardId!)
: findLegacyCard(cardCatalog.cards, snapshot.fixture!)
if (!experimentCard) {
throw new Error(`Experiment card "${snapshot.cardId ?? snapshot.fixture}" is not in the accepted artifact catalog`)
}
fixtureSelect.value = snapshot.fixture
await loadFixture(snapshot.fixture)
if (snapshot.schemaVersion === 3) {
selectedResolution = snapshot.resolution!
selectedPrinting = snapshot.printing!
resolutionSelect.value = selectedResolution
printingSelect.value = selectedPrinting
}
await loadFixture(experimentCard.cardId)
Object.assign(controls, restoreExperimentMaterialControls(snapshot))
controls.lightingPreset = snapshot.lightingPreset
controls.lightType = snapshot.lightType
@@ -1019,7 +1144,10 @@ async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName
? ' Local artwork or masks must be loaded again.'
: ''
if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${customAssetNote}`
const revisionNote = snapshot.schemaVersion === 3 && snapshot.acceptedRevision !== experimentCard.acceptedRevision
? ` Card acceptance changed from ${snapshot.acceptedRevision?.slice(0, 10)} to ${experimentCard.acceptedRevision.slice(0, 10)}.`
: ''
if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${revisionNote}${customAssetNote}`
}
function syncLightControls() {
@@ -1060,16 +1188,16 @@ function resetView() {
orbit.update()
modeSelect.value = 'Inspect'
controls.target = 'Card'
fixtureSelect.value = defaultFixture
selectedResolution = 'high'
selectedPrinting = 'normal'
resolutionSelect.value = selectedResolution
printingSelect.value = selectedPrinting
finishSelect.value = 'Holographic'
substrateSelect.value = 'Paper'
poseSelect.value = 'Front'
controls.finish = 'Holographic'
controls.substrate = 'Paper'
controls.finishStrength = 0.6
controls.roughness = 0.23
controls.normalStrength = defaultSurfaceDetail(controls.substrate)
controls.metalBrushHorizontal = false
Object.assign(controls, defaultsStore.resolved(controls.finish, controls.substrate))
controls.condition = 1
controls.imperfectionSeed = 81251
applyLightingPreset('Studio')
@@ -1087,14 +1215,14 @@ function resetView() {
async function loadFixture(nextFixture: FixtureName) {
const request = ++textureRequest
loadingElement.hidden = false
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
const asset = fixtureAssets.get(nextFixture)
if (!asset) {
const card = fixtureAssets.get(nextFixture)
if (!card) {
loadingElement.hidden = true
fixtureSelect.value = activeFixture
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
statusElement.textContent = `Card "${nextFixture}" is not in the accepted artifact catalog.`
return
}
statusElement.textContent = `Loading ${cardLabel(card)} · ${selectedResolution} · ${selectedPrinting}…`
const asset = resolveCardAsset(card, selectedResolution, selectedPrinting)
try {
const loaded = await loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy())
@@ -1112,52 +1240,44 @@ async function loadFixture(nextFixture: FixtureName) {
activeFixture = nextFixture
customArtworkName = undefined
customMaskName = undefined
updateCardButton()
previousArtwork.dispose()
previousMask.dispose()
previousTextMask?.dispose()
updateStatus()
} catch (error) {
if (request === textureRequest) {
fixtureSelect.value = activeFixture
if (mode !== 'Pack') {
statusElement.textContent = `Could not load ${nextFixture}: ${error instanceof Error ? error.message : String(error)}`
}
if (request === textureRequest && mode !== 'Pack') {
statusElement.textContent = `Could not load ${cardLabel(card)}: ${error instanceof Error ? error.message : String(error)}`
}
} finally {
if (request === textureRequest) loadingElement.hidden = true
}
}
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)
if (!nextCatalog.cards.length) throw new Error(nextCatalog.errors.join('; ') || 'No accepted cards found')
const previousCard = fixtureAssets.get(activeFixture)
const previousAsset = previousCard && resolveCardAsset(previousCard, selectedResolution, selectedPrinting)
const nextAssets = new Map(nextCatalog.cards.map(card => [card.cardId, card]))
const nextCard = nextAssets.get(activeFixture)
cardCatalog = nextCatalog
fixtureAssets = nextAssets
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
defaultFixture = fixtureAssets.has('BP-002') ? 'BP-002' : nextCatalog.cards[0].cardId
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)
cardPicker.update(nextCatalog.cards)
if (!nextCard) {
activeFixture = defaultFixture
await loadFixture(defaultFixture)
} else {
updateCardButton()
const nextAsset = resolveCardAsset(nextCard, selectedResolution, selectedPrinting)
if (previousAsset?.revision !== nextAsset.revision && !customArtworkName && !customMaskName) {
await loadFixture(activeFixture)
}
}
if (nextCatalog.errors.length) statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
catalogStateElement.textContent = 'Catalog error'
@@ -1366,30 +1486,35 @@ modeSelect.addEventListener('change', () => {
pauseScriptedMotion()
updateMode()
})
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(fixture)
}
fixtureSelect.addEventListener('change', selectFixtureFromPicker)
fixtureSelect.addEventListener('focus', () => { void refreshCardCatalog() })
fixtureSelect.addEventListener('keydown', event => {
if (event.key === 'Enter') selectFixtureFromPicker()
cardPickerButton.addEventListener('click', () => {
void refreshCardCatalog()
cardPicker.open(activeFixture)
})
previousCardButton.addEventListener('click', () => {
const card = cardPicker.adjacent(activeFixture, -1)
if (card) void loadFixture(card.cardId)
})
nextCardButton.addEventListener('click', () => {
const card = cardPicker.adjacent(activeFixture, 1)
if (card) void loadFixture(card.cardId)
})
async function changeVariant() {
selectedResolution = resolutionSelect.value as CardResolution
selectedPrinting = printingSelect.value as CardPrinting
if (mode === 'Pack') await preparePack(true, pack?.contents)
else await loadFixture(activeFixture)
}
resolutionSelect.addEventListener('change', () => { void changeVariant() })
printingSelect.addEventListener('change', () => { void changeVariant() })
window.addEventListener('focus', () => { void refreshCardCatalog() })
if (import.meta.hot) {
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
import.meta.hot.on('surface-defaults:update', () => { void defaultsStore.refresh() })
}
substrateSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.substrate = substrateSelect.value as SubstrateName
controls.normalStrength = defaultSurfaceDetail(controls.substrate)
defaultsStore.applyMaterial(controls, controls.substrate)
updateMaterial()
updateEdgeMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
@@ -1398,6 +1523,7 @@ substrateSelect.addEventListener('change', () => {
finishSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.finish = finishSelect.value as FinishName
defaultsStore.applyFinish(controls, controls.finish)
updateMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateStatus()
@@ -1465,6 +1591,9 @@ flipButton.addEventListener('click', () => {
(normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI)
})
resetButton.addEventListener('click', resetView)
diagnosticsToggle.addEventListener('click', () => {
setDiagnosticsEnabled(diagnosticsToggle.getAttribute('aria-pressed') !== 'true')
})
sweepButton.addEventListener('click', startSweep)
controlsToggle.addEventListener('click', () => {
setControlsCollapsed(!topbar.classList.contains('controls-collapsed'))

View File

@@ -4,6 +4,7 @@ import { createFoilWrapper } from './foilWrapper'
import type { PackStyle } from './packDesigns'
import type { CardTextures } from './cardTextures'
import {
applyCardTextMask,
applyEdgeMaterialControls,
applyMaterialControls,
createCardMaterial,
@@ -56,10 +57,24 @@ const cardSweepRadius = Math.hypot(
const inspectionDepth = Math.max(3, cardSweepRadius + 0.5)
const inspectionCameraRetreat = inspectionDepth - 0.85
export interface CardTextureLease {
textures: CardTextures
release: () => void
}
interface PackOptions {
canvas: HTMLCanvasElement
contents: readonly PackCardSpec[]
textures: ReadonlyMap<string, CardTextures>
textures?: ReadonlyMap<string, CardTextures>
placeholderTextures?: CardTextures
acquireTextures?: (spec: PackCardSpec) => Promise<CardTextureLease>
prepareTextures?: (textures: CardTextures) => Promise<void> | void
resolveSurfaceDefaults?: (finish: FinishName, substrate: SubstrateName) => {
finishStrength: number
roughness: number
normalStrength: number
metalBrushHorizontal: boolean
}
backMaterial: THREE.Material
normalMap: THREE.Texture
environment: THREE.CubeTexture
@@ -143,6 +158,7 @@ export class PackOpening {
private readonly pointers = new Map<number, THREE.Vector2>()
private tap: { id: number; x: number; y: number; at: number } | undefined
private pinchDistance: number | undefined
private disposed = false
constructor(options: PackOptions) {
if (!options.contents.length) throw new Error('Pack must contain at least one card')
@@ -154,22 +170,26 @@ export class PackOpening {
this.root.add(this.wrapper.root)
const buffers = createCardBuffers()
this.cards = this.contents.map((spec) => {
const textures = options.textures.get(spec.fixture)
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`)
const staticTextures = options.textures?.get(spec.fixture)
const textures = staticTextures ?? options.placeholderTextures
if (!textures) throw new Error(`Missing textures or placeholder for pack card "${spec.fixture}"`)
const material = createCardMaterial(
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
textures.textMask ?? null,
)
applyMaterialControls(material, {
...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: defaultSurfaceDetail(spec.substrate),
const defaults = options.resolveSurfaceDefaults?.(spec.finish, spec.substrate) ?? {
finishStrength: 0.6, roughness: 0.23, normalStrength: defaultSurfaceDetail(spec.substrate),
metalBrushHorizontal: false,
environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
}
applyMaterialControls(material, {
...spec, ...defaults, environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
})
const edge = new THREE.MeshPhysicalMaterial()
const object = createCardGeometry(material, options.backMaterial, edge, buffers)
const front = object.getObjectByName('CARD_FRONT')!
this.root.add(object)
return { object, material, edge, front }
return { object, material, edge, front, ready: !!staticTextures, lease: undefined as CardTextureLease | undefined,
loading: undefined as Promise<void> | undefined, error: undefined as string | undefined }
})
this.restart()
}
@@ -192,7 +212,11 @@ export class PackOpening {
},
stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' },
lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
lifted: this.cards[this.index].loading
? { action: 'Loading…', status: `Card ${number} · loading ${spec.fixture}`, hint: 'Preparing only this card front before the reveal' }
: this.cards[this.index].error
? { action: 'Retry card', status: `Card ${number} · ${this.cards[this.index].error}`, hint: 'Retry loads this card front again' }
: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
inspecting: {
action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card',
@@ -257,6 +281,8 @@ export class PackOpening {
}
dispose() {
this.disposed = true
this.cards.forEach((_, index) => this.releaseTextures(index))
this.clearPointers()
this.active = false
this.root.visible = false
@@ -307,6 +333,7 @@ export class PackOpening {
}
restart() {
if (this.options.acquireTextures) this.cards.forEach((_, index) => this.releaseTextures(index))
this.clearPointers()
this.motion = undefined
this.paused = false
@@ -378,6 +405,8 @@ export class PackOpening {
card.object.visible = state !== 'complete' && index >= this.index
card.front.visible = revealPhase && index === this.index
})
if (state === 'stackReady') void this.ensureTextures(this.index).catch(() => {})
if (state === 'inspecting' && this.index + 1 < this.cards.length) void this.ensureTextures(this.index + 1).catch(() => {})
if (state === 'inspecting' && !this.revealed.has(this.index)) {
this.revealed.add(this.index)
applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 })
@@ -392,6 +421,56 @@ export class PackOpening {
this.options.onChange()
}
private async ensureTextures(index: number) {
const card = this.cards[index]
if (card.ready || !this.options.acquireTextures) return
if (card.loading) return card.loading
card.error = undefined
const loading = (async () => {
const lease = await this.options.acquireTextures!(this.contents[index])
if (this.disposed) {
lease.release()
return
}
await this.options.prepareTextures?.(lease.textures)
if (this.disposed) {
lease.release()
return
}
card.lease = lease
card.material.uniforms.artwork.value = lease.textures.artwork
card.material.uniforms.finishMask.value = lease.textures.mask
applyCardTextMask(card.material, lease.textures.textMask ?? null)
card.ready = true
})()
card.loading = loading
this.options.onChange()
try {
await loading
} catch (error) {
card.error = error instanceof Error ? error.message : String(error)
throw error
} finally {
card.loading = undefined
this.options.onChange()
}
}
private releaseTextures(index: number) {
const card = this.cards[index]
const lease = card.lease
if (!lease) return
const placeholder = this.options.placeholderTextures
if (placeholder) {
card.material.uniforms.artwork.value = placeholder.artwork
card.material.uniforms.finishMask.value = placeholder.mask
applyCardTextMask(card.material, placeholder.textMask ?? null)
}
card.lease = undefined
card.ready = false
lease.release()
}
private begin(
state: PackState, tracks: Track[], duration: number, finish: () => void,
progress?: (value: number) => void,
@@ -436,6 +515,12 @@ export class PackOpening {
break
}
case 'lifted':
if (!card.ready) {
void this.ensureTextures(this.index).then(() => {
if (this.active && this.state === 'lifted' && !this.motion) this.primary()
}).catch(() => {})
break
}
this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820,
() => this.setState('inspecting'))
break
@@ -450,12 +535,14 @@ export class PackOpening {
}
const startRetreat = this.cameraRetreat
this.begin('advancing', tracks, 650, () => {
const previousIndex = this.index
if (this.index === this.cards.length - 1) {
this.setState('complete')
} else {
this.index++
this.setState('stackReady')
}
this.releaseTextures(previousIndex)
}, (value) => {
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value)
this.updateCamera()

View File

@@ -100,8 +100,8 @@ h1 {
.toolbar label {
display: grid;
flex: 1 1 110px;
min-width: 100px;
flex: 0 1 124px;
min-width: 104px;
gap: 4px;
color: #9da3ad;
font-size: 11px;
@@ -329,8 +329,56 @@ button:disabled {
cursor: default;
}
.card-control { flex: 1 1 300px !important; min-width: min(300px, 100%); max-width: 440px; }
.card-select-row { display: grid; grid-template-columns: 32px minmax(150px, 1fr) 32px; gap: 5px; }
.card-select-row button { min-width: 0; padding-inline: 7px; }
#card-picker-open { overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.card-picker {
position: fixed; inset: 0 0 0 auto; width: min(520px, 94vw); max-width: none;
height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0;
border-left: 1px solid #363d49; background: #11151d; color: #edf0f5;
box-shadow: -16px 0 50px #0009;
}
.card-picker::backdrop { background: #05070bbb; }
.card-picker[open] { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.card-picker-header {
display: flex; align-items: center; gap: 10px; padding: 16px; border-bottom: 1px solid #2b313c;
}
.card-picker-filters {
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
gap: 10px; padding: 16px; border-bottom: 1px solid #2b313c;
}
.card-picker-header { justify-content: space-between; }
.card-picker-header h2 { margin: 2px 0 0; font-size: 20px; }
.card-picker-header .eyebrow { margin: 0; }
.card-picker-close { width: 40px; min-width: 40px; font-size: 24px; }
.card-picker-filters input { grid-column: 1 / -1; min-width: 0; }
.card-picker-filters select { width: min(240px, 65vw); }
.card-picker-filters span { justify-self: end; color: #9fa8b8; font-size: 12px; white-space: nowrap; }
.card-picker-results {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start;
gap: 10px; overflow: auto; padding: 14px;
}
.card-picker-result {
display: grid; grid-template-columns: 62px minmax(0, 1fr); align-items: center;
gap: 10px; min-width: 0; height: auto; padding: 7px; text-align: left;
}
.card-picker-result.selected { border-color: #b69a51; background: #332d20; }
.card-picker-result img { width: 62px; aspect-ratio: 5 / 7; border-radius: 3px; object-fit: cover; background: #080a0e; }
.card-picker-result span { display: grid; gap: 4px; min-width: 0; }
.card-picker-result strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-picker-result small { color: #a3abba; line-height: 1.35; }
.card-picker-empty { grid-column: 1 / -1; color: #a3abba; text-align: center; }
.diagnostic-readout,
.diagnostic-control { display: none; }
.diagnostics-enabled .diagnostic-readout { display: block; }
.diagnostics-enabled .toolbar .diagnostic-control { display: inline-block; }
#render-diagnostics { color: #89909b; font-size: 10px; }
.toolbar .pack-style,
.pack-mode .toolbar > :not(:first-child):not(.pack-style) {
.pack-mode .toolbar > :not(:first-child):not(.pack-style):not(.variant-control):not(.diagnostics-toggle) {
display: none;
}
@@ -339,6 +387,8 @@ button:disabled {
flex-basis: 200px;
}
.pack-mode .toolbar .variant-control { display: grid; }
.pack-mode .toolbar {
flex: 0 1 400px;
}
@@ -354,7 +404,7 @@ button:disabled {
max-width: min(440px, calc(100% - 32px));
}
@media (max-width: 1120px) {
@media (max-width: 1500px) {
.topbar {
align-items: flex-start;
}
@@ -393,7 +443,13 @@ button:disabled {
}
.toolbar label {
flex-basis: calc(33.333% - 6px);
flex: 1 1 calc(33.333% - 6px);
}
.toolbar .card-control {
flex: 2 1 calc(66.666% - 6px) !important;
max-width: none;
min-width: 0;
}
.toolbar button {
@@ -479,6 +535,10 @@ button:disabled {
padding-inline: 10px;
font-size: 12px;
}
.card-control { grid-column: 1 / -1; }
.card-picker { width: 100vw; }
.card-picker-results { grid-template-columns: 1fr; }
}
@media (max-height: 540px) and (min-aspect-ratio: 4/3) {

View File

@@ -0,0 +1,132 @@
import bundledDefaults from '../config/surface-defaults.json'
import {
finishNames, substrateNames, surfaceDetailMax,
type FinishName, type MaterialControls, type SubstrateName,
} from './cardMaterial'
export interface FinishDefaults { finishStrength: number }
export interface SubstrateDefaults {
surfaceDetail: number
roughness: number
metalBrushHorizontal?: boolean
}
export interface SurfaceDefaults {
schemaVersion: 1
finishes: Record<FinishName, FinishDefaults>
materials: Record<Exclude<SubstrateName, 'Plastic'>, SubstrateDefaults>
}
interface SurfaceDefaultsResponse {
defaults: SurfaceDefaults
revision: string
writable: boolean
}
function finiteRange(value: unknown, min: number, max: number, label: string) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
throw new Error(`${label} must be between ${min} and ${max}`)
}
return value
}
export function validateSurfaceDefaults(value: unknown): SurfaceDefaults {
if (typeof value !== 'object' || value === null) throw new Error('Surface defaults are not an object')
const root = value as Record<string, unknown>
if (root.schemaVersion !== 1 || typeof root.finishes !== 'object' || root.finishes === null ||
typeof root.materials !== 'object' || root.materials === null) {
throw new Error('Surface defaults use an unsupported format')
}
const finishes = root.finishes as Record<string, unknown>
const materials = root.materials as Record<string, unknown>
for (const name of finishNames) {
const item = finishes[name]
if (typeof item !== 'object' || item === null) throw new Error(`Missing defaults for finish ${name}`)
finiteRange((item as Record<string, unknown>).finishStrength, 0, 1, `${name} finish strength`)
}
for (const name of substrateNames) {
const item = materials[name]
if (typeof item !== 'object' || item === null) throw new Error(`Missing defaults for material ${name}`)
const record = item as Record<string, unknown>
finiteRange(record.surfaceDetail, 0, surfaceDetailMax, `${name} surface detail`)
finiteRange(record.roughness, 0.05, 0.8, `${name} roughness`)
if (record.metalBrushHorizontal !== undefined && typeof record.metalBrushHorizontal !== 'boolean') {
throw new Error(`${name} metal grain orientation must be a boolean`)
}
}
return value as SurfaceDefaults
}
const fallbackDefaults = validateSurfaceDefaults(bundledDefaults)
export class SurfaceDefaultsStore {
defaults: SurfaceDefaults = structuredClone(fallbackDefaults)
revision = 'bundled'
writable = false
async refresh() {
try {
const response = await fetch('/__surface_defaults', { cache: 'no-store' })
if (!response.ok) throw new Error(`request failed (${response.status})`)
const body = await response.json() as Partial<SurfaceDefaultsResponse>
this.defaults = validateSurfaceDefaults(body.defaults)
this.revision = typeof body.revision === 'string' ? body.revision : 'unknown'
this.writable = body.writable === true
} catch {
this.defaults = structuredClone(fallbackDefaults)
this.revision = 'bundled'
this.writable = false
}
}
finish(name: FinishName) { return this.defaults.finishes[name] }
material(name: SubstrateName) {
if (name === 'Plastic') return { surfaceDetail: 0.14, roughness: 0.23, metalBrushHorizontal: true }
return this.defaults.materials[name]
}
applyFinish(controls: MaterialControls, name: FinishName) {
controls.finish = name
controls.finishStrength = this.finish(name).finishStrength
}
applyMaterial(controls: MaterialControls, name: SubstrateName) {
const preset = this.material(name)
controls.substrate = name
controls.normalStrength = preset.surfaceDetail
controls.roughness = preset.roughness
controls.metalBrushHorizontal = preset.metalBrushHorizontal ?? false
}
resolved(finish: FinishName, substrate: SubstrateName) {
const finishPreset = this.finish(finish)
const materialPreset = this.material(substrate)
return {
finishStrength: finishPreset.finishStrength,
roughness: materialPreset.roughness,
normalStrength: materialPreset.surfaceDetail,
metalBrushHorizontal: materialPreset.metalBrushHorizontal ?? false,
}
}
async saveFinish(name: FinishName, values: FinishDefaults) {
return this.save('finish', name, values)
}
async saveMaterial(name: Exclude<SubstrateName, 'Plastic'>, values: SubstrateDefaults) {
return this.save('material', name, values)
}
private async save(group: 'finish' | 'material', name: string, values: FinishDefaults | SubstrateDefaults) {
if (!this.writable) throw new Error('Repository defaults can only be saved from the development server')
const response = await fetch('/__surface_defaults', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: this.revision, group, name, values }),
})
const body = await response.json() as Partial<SurfaceDefaultsResponse> & { error?: string }
if (!response.ok) throw new Error(body.error ?? `save failed (${response.status})`)
this.defaults = validateSurfaceDefaults(body.defaults)
this.revision = typeof body.revision === 'string' ? body.revision : this.revision
this.writable = body.writable === true
}
}