Improved harness - with card selection
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
123
card-harness/src/cardPicker.ts
Normal file
123
card-harness/src/cardPicker.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
132
card-harness/src/surfaceDefaults.ts
Normal file
132
card-harness/src/surfaceDefaults.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user