Files
Sanctification/spikes/card-harness/src/main.ts
2026-09-12 08:33:44 -07:00

1611 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './style.css'
import * as THREE from 'three'
import GUI from 'lil-gui'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import {
applyEdgeMaterialControls,
applyMaterialControls,
createCardMaterial,
createStudioCubeTexture,
finishNames,
substrateNames,
type FinishName,
type MaterialControls,
type SubstrateName,
} from './cardMaterial'
import { createCardGeometry } from './cardGeometry'
import { PackOpening, packSize, rollPackContents } from './packOpening'
import { defaultPackStyle, packStyles } from './packDesigns'
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog'
type FixtureName = string
type AppMode = 'Inspect' | 'Lab' | 'Pack'
type ManipulationTarget = 'Card' | 'Camera'
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
type LightType = 'Point' | 'Directional' | 'Spot'
type LightingPreset = 'Studio' | 'Warm gallery' | 'Cool window' | 'Dramatic spot' | 'Flat review' | 'Custom'
type EnvironmentName = 'Studio' | 'Warm' | 'Cool' | 'Dark' | 'Neutral'
interface ExperimentSnapshot {
schemaVersion: 1 | 2
runtimeRevision: 'runtime-look-v4-2026-09-07'
name: string
savedAt: string
fixture: FixtureName
customArtworkName?: string
customMaskName?: string
finish: FinishName
substrate: SubstrateName
finishStrength: number
roughness: number
normalStrength: number
environmentIntensity: number
condition?: number
imperfectionSeed?: number
lightingPreset: LightingPreset
environment: EnvironmentName
lightType: LightType
lightColor: string
lightPosition: [number, number, number]
lightIntensity: number
exposure: number
cardRotation: [number, number, number]
cameraPosition: [number, number, number]
orbitTarget: [number, number, number]
}
let cardCatalog = await fetchCardCatalog()
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs')
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card]))
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
texture.colorSpace = colorSpace
texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
}
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<main class="app-shell">
<header class="topbar">
<div class="title-row">
<div>
<p class="eyebrow">Renderer proof · runtime look v4</p>
<h1>Sanctification Card Lab</h1>
</div>
<button
class="controls-toggle"
id="controls-toggle"
type="button"
aria-expanded="true"
aria-controls="card-controls"
>
<span>Controls</span>
<span class="controls-toggle-icon" aria-hidden="true">^</span>
</button>
</div>
<div class="toolbar" id="card-controls" aria-label="Card controls">
<label>Mode
<select id="mode">
<option>Inspect</option>
<option>Lab</option>
<option>Pack</option>
</select>
</label>
<label class="pack-style">Pack style
<select id="pack-style" title="Change the wrapper only; use Restart to compare sealed packs" disabled>
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
</select>
</label>
<label>Card
<span class="catalog-state" id="catalog-state" aria-live="polite"></span>
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false">
<datalist id="fixture-options"></datalist>
</label>
<label>Finish
<select id="finish">
${finishNames.map(name => `<option${name === 'Holographic' ? ' selected' : ''}>${name}</option>`).join('')}
</select>
</label>
<label>Material
<select id="substrate">
${substrateNames.map(name => `<option>${name}</option>`).join('')}
</select>
</label>
<label>Pose
<select id="pose">
<option>Front</option>
<option>Grazing</option>
<option>Edge</option>
<option>Back</option>
<option>Free</option>
</select>
</label>
<label>Lighting
<select id="lighting-preset">
<option>Studio</option>
<option>Warm gallery</option>
<option>Cool window</option>
<option>Dramatic spot</option>
<option>Flat review</option>
<option>Custom</option>
</select>
</label>
<label>Light
<select id="light-type">
<option>Point</option>
<option>Directional</option>
<option>Spot</option>
</select>
</label>
<button id="flip" type="button">Flip</button>
<button id="reset" type="button">Reset</button>
<button id="sweep" type="button">Play sweep</button>
</div>
</header>
<section class="viewport-shell">
<canvas id="scene" aria-label="Interactive three-dimensional card"></canvas>
<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>
</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>
<span id="pack-status" role="status" aria-live="polite"></span>
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
<div class="pack-actions">
<button id="pack-restart" type="button">New pack</button>
<button id="pack-primary" type="button">Open pack</button>
<button id="pack-skip" type="button">Skip</button>
</div>
</div>
</section>
<footer>
<span>Runtime materials · procedural card geometry</span>
<a href="/reference/manifest.json" target="_blank">Asset manifest</a>
</footer>
<input id="artwork-file" type="file" accept="image/*" hidden>
<input id="mask-file" type="file" accept="image/*" hidden>
<input id="experiment-file" type="file" accept="application/json,.json" hidden>
</main>
`
const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
const statusElement = document.querySelector<HTMLSpanElement>('#status')!
const performanceElement = document.querySelector<HTMLSpanElement>('#performance')!
const loadingElement = document.querySelector<HTMLDivElement>('#loading')!
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 catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
const lightingPresetSelect = document.querySelector<HTMLSelectElement>('#lighting-preset')!
const lightTypeSelect = document.querySelector<HTMLSelectElement>('#light-type')!
const flipButton = document.querySelector<HTMLButtonElement>('#flip')!
const resetButton = document.querySelector<HTMLButtonElement>('#reset')!
const sweepButton = document.querySelector<HTMLButtonElement>('#sweep')!
const artworkFileInput = document.querySelector<HTMLInputElement>('#artwork-file')!
const maskFileInput = document.querySelector<HTMLInputElement>('#mask-file')!
const experimentFileInput = document.querySelector<HTMLInputElement>('#experiment-file')!
const packControlsElement = document.querySelector<HTMLDivElement>('#pack-controls')!
const packStatusElement = document.querySelector<HTMLSpanElement>('#pack-status')!
const packTearProgress = document.querySelector<HTMLProgressElement>('#pack-tear')!
const packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
function renderCardCatalog(catalog: CardCatalog) {
fixtureOptions.replaceChildren(...catalog.cards.map(card => {
const option = document.createElement('option')
option.value = card.name
return option
}))
catalogStateElement.textContent = catalog.errors.length
? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
: `${catalog.cards.length} cards`
catalogStateElement.classList.toggle('has-errors', catalog.errors.length > 0)
catalogStateElement.title = catalog.errors.join('\n')
}
renderCardCatalog(cardCatalog)
fixtureSelect.value = defaultFixture
function setControlsCollapsed(collapsed: boolean) {
topbar.classList.toggle('controls-collapsed', collapsed)
document.documentElement.classList.toggle('compact-controls', collapsed)
controlsToggle.setAttribute('aria-expanded', String(!collapsed))
controlsToggle.querySelector<HTMLSpanElement>('.controls-toggle-icon')!.textContent =
collapsed ? 'v' : '^'
}
setControlsCollapsed(window.matchMedia('(max-width: 880px)').matches)
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: false,
powerPreference: 'high-performance',
})
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.0
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b0e14')
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
camera.position.set(0, 0, 8.2)
const orbit = new OrbitControls(camera, canvas)
orbit.enableDamping = true
orbit.dampingFactor = 0.08
orbit.enablePan = true
orbit.minDistance = 5.0
orbit.maxDistance = 12
orbit.target.set(0, 0, 0)
orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE
orbit.mouseButtons.RIGHT = THREE.MOUSE.PAN
orbit.enabled = false
const environments: Record<EnvironmentName, THREE.CubeTexture> = {
Studio: createStudioCubeTexture(),
Warm: createStudioCubeTexture(['#e1aa62', '#402419', '#fff0c8', '#20120c', '#9c5d35', '#28130d']),
Cool: createStudioCubeTexture(['#94bfe8', '#14283d', '#e8f4ff', '#0b1522', '#527da8', '#111c2d']),
Dark: createStudioCubeTexture(['#544836', '#090d16', '#8b7655', '#05070c', '#27344c', '#120d0b']),
Neutral: createStudioCubeTexture(['#d9dde2', '#30343a', '#ffffff', '#1a1d22', '#9ca3ac', '#25282e']),
}
let activeEnvironmentName: EnvironmentName = 'Studio'
let activeEnvironment = environments[activeEnvironmentName]
scene.environment = activeEnvironment
const cardRoot = new THREE.Group()
scene.add(cardRoot)
const edgeMaterial = new THREE.MeshPhysicalMaterial({
color: '#9c8c69',
roughness: 0.48,
metalness: 0.05,
clearcoat: 0.18,
})
const loader = new THREE.TextureLoader()
const normalMap = await loader.loadAsync('/reference/linen-normal.png')
normalMap.colorSpace = THREE.NoColorSpace
normalMap.wrapS = normalMap.wrapT = THREE.RepeatWrapping
const backTexture = await loader.loadAsync('/reference/card-back.png')
backTexture.colorSpace = THREE.SRGBColorSpace
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
const initialAsset = fixtureAssets.get(defaultFixture)!
const initialArtwork = await loader.loadAsync(initialAsset.artwork)
const initialMask = await loader.loadAsync(initialAsset.mask)
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
configureFrontTexture(initialMask, THREE.NoColorSpace)
const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const frontMaterial = createCardMaterial(
initialArtwork,
initialMask,
normalMap,
activeEnvironment,
lightPosition,
)
const backMaterial = new THREE.MeshPhysicalMaterial({
map: backTexture,
roughness: 0.42,
metalness: 0,
clearcoat: 0.2,
clearcoatRoughness: 0.24,
normalMap: null,
side: THREE.DoubleSide,
})
cardRoot.add(createCardGeometry(frontMaterial, backMaterial, edgeMaterial))
const lightTarget = new THREE.Object3D()
scene.add(lightTarget)
const pointLight = new THREE.PointLight('#fff0d0', 34, 20, 1.7)
const directionalLight = new THREE.DirectionalLight('#fff0d0', 5.4)
const spotLight = new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7)
directionalLight.target = lightTarget
spotLight.target = lightTarget
for (const light of [pointLight, directionalLight, spotLight]) {
light.position.copy(lightPosition)
light.castShadow = false
scene.add(light)
}
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: '#090b10', roughness: 0.82, metalness: 0.05 }),
)
floor.rotation.x = -Math.PI / 2
floor.position.y = -2.7
floor.receiveShadow = true
scene.add(floor)
const controls: MaterialControls & {
lightX: number
lightY: number
lightZ: number
lightIntensity: number
lightColor: string
lightType: LightType
lightingPreset: LightingPreset
exposure: number
target: ManipulationTarget
} = {
finish: 'Holographic',
substrate: 'Paper',
finishStrength: 0.6,
roughness: 0.23,
normalStrength: 0.14,
environmentIntensity: 0.7,
condition: 1,
imperfectionSeed: 81251,
lightX: lightPosition.x,
lightY: lightPosition.y,
lightZ: lightPosition.z,
lightIntensity: 3,
lightColor: '#fff0d0',
lightType: 'Point',
lightingPreset: 'Studio',
exposure: 1,
target: 'Card',
}
const lightingPresets: Record<Exclude<LightingPreset, 'Custom'>, {
environment: EnvironmentName
lightType: LightType
lightColor: string
lightPosition: [number, number, number]
lightIntensity: number
environmentIntensity: number
exposure: number
}> = {
Studio: {
environment: 'Studio',
lightType: 'Point',
lightColor: '#fff0d0',
lightPosition: [2.2, 2.5, 4.4],
lightIntensity: 3,
environmentIntensity: 0.7,
exposure: 1,
},
'Warm gallery': {
environment: 'Warm',
lightType: 'Spot',
lightColor: '#ffd29a',
lightPosition: [-2.8, 3.1, 4.8],
lightIntensity: 4,
environmentIntensity: 0.52,
exposure: 1.04,
},
'Cool window': {
environment: 'Cool',
lightType: 'Directional',
lightColor: '#bddcff',
lightPosition: [-3.4, 4.2, 5.2],
lightIntensity: 2.7,
environmentIntensity: 0.78,
exposure: 1,
},
'Dramatic spot': {
environment: 'Dark',
lightType: 'Spot',
lightColor: '#ffe1ad',
lightPosition: [3.6, 1.9, 3.6],
lightIntensity: 4.8,
environmentIntensity: 0.28,
exposure: 0.92,
},
'Flat review': {
environment: 'Neutral',
lightType: 'Directional',
lightColor: '#ffffff',
lightPosition: [0.6, 0.8, 5.5],
lightIntensity: 2.25,
environmentIntensity: 1,
exposure: 1,
},
}
let applyingLightingPreset = false
const gui = new GUI({ title: 'Material proof' })
const surfaceFolder = gui.addFolder('Surface')
surfaceFolder.add(controls, 'finish', [...finishNames]).name('Finish').onChange((value: FinishName) => {
pauseScriptedMotion()
finishSelect.value = value
updateMaterial()
updateStatus()
})
surfaceFolder.add(controls, 'substrate', [...substrateNames]).name('Material').onChange((value: SubstrateName) => {
pauseScriptedMotion()
substrateSelect.value = value
updateMaterial()
updateEdgeMaterial()
updateStatus()
})
surfaceFolder.add(controls, 'finishStrength', 0, 1, 0.01).name('Finish strength').onChange(() => {
pauseScriptedMotion()
updateMaterial()
})
surfaceFolder.add(controls, 'roughness', 0.05, 0.8, 0.01).name('Roughness').onChange(() => {
pauseScriptedMotion()
updateMaterial()
})
surfaceFolder.add(controls, 'normalStrength', 0, 0.45, 0.01).name('Surface detail').onChange(() => {
pauseScriptedMotion()
updateMaterial()
})
surfaceFolder.add(controls, 'environmentIntensity', 0, 1.5, 0.01).name('Environment').onChange(() => {
pauseScriptedMotion()
markLightingCustom()
updateMaterial()
})
const wearFolder = gui.addFolder('Wear')
wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => {
pauseScriptedMotion()
updateMaterial()
updateEdgeMaterial()
})
wearFolder.add(controls, 'imperfectionSeed', 1, 99999, 1).name('Seed').onChange(() => {
pauseScriptedMotion()
updateMaterial()
})
wearFolder.add({
randomize: () => {
pauseScriptedMotion()
const randomValue = crypto.getRandomValues(new Uint32Array(1))[0]
controls.imperfectionSeed = 1 + randomValue % 99999
updateMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
},
}, 'randomize').name('Randomize seed')
const lightFolder = gui.addFolder('Light')
lightFolder.add(controls, 'lightingPreset', ['Studio', 'Warm gallery', 'Cool window', 'Dramatic spot', 'Flat review', 'Custom']).name('Preset').onChange((value: LightingPreset) => {
if (value !== 'Custom') applyLightingPreset(value)
})
lightFolder.add(controls, 'lightType', ['Point', 'Directional', 'Spot']).name('Type').onChange((value: LightType) => {
pauseScriptedMotion()
controls.lightType = value
lightTypeSelect.value = value
markLightingCustom()
updateLight()
})
lightFolder.addColor(controls, 'lightColor').name('Color').onChange(() => {
pauseScriptedMotion()
markLightingCustom()
updateLight()
})
lightFolder.add(controls, 'lightX', -6, 6, 0.05).name('X').onChange(() => {
const value = controls.lightX
pauseScriptedMotion()
controls.lightX = value
markLightingCustom()
updateLight()
})
lightFolder.add(controls, 'lightY', -6, 6, 0.05).name('Y').onChange(() => {
const value = controls.lightY
pauseScriptedMotion()
controls.lightY = value
markLightingCustom()
updateLight()
})
lightFolder.add(controls, 'lightZ', 0.5, 9, 0.05).name('Z').onChange(() => {
const value = controls.lightZ
pauseScriptedMotion()
controls.lightZ = value
markLightingCustom()
updateLight()
})
lightFolder.add(controls, 'lightIntensity', 0, 8, 0.05).name('Intensity').onChange(() => {
pauseScriptedMotion()
markLightingCustom()
updateLight()
})
lightFolder.add(controls, 'exposure', 0.5, 1.8, 0.01).name('Exposure').onChange(() => {
pauseScriptedMotion()
markLightingCustom()
renderer.toneMappingExposure = controls.exposure
})
gui.add(controls, 'target', ['Card', 'Camera']).name('Drag target').onChange(() => {
pauseScriptedMotion()
updateMode()
})
const assetActions = {
loadArtwork: () => artworkFileInput.click(),
loadMask: () => maskFileInput.click(),
restoreFixture: () => {
pauseScriptedMotion()
void loadFixture(fixtureSelect.value)
},
}
const assetFolder = gui.addFolder('Assets')
assetFolder.add(assetActions, 'loadArtwork').name('Load artwork image')
assetFolder.add(assetActions, 'loadMask').name('Load finish mask')
assetFolder.add(assetActions, 'restoreFixture').name('Restore selected card')
const experimentControls = {
name: 'comparison-1',
}
const experimentActions = {
save: () => saveExperimentToBrowser(),
load: () => void loadExperimentFromBrowser(),
remove: () => removeExperimentFromBrowser(),
exportJson: () => exportExperimentJson(),
importJson: () => experimentFileInput.click(),
capturePng: () => captureExperimentPng(),
}
const experimentFolder = gui.addFolder('Experiments')
experimentFolder.add(experimentControls, 'name').name('Name')
experimentFolder.add(experimentActions, 'save').name('Save in browser')
experimentFolder.add(experimentActions, 'load').name('Load from browser')
experimentFolder.add(experimentActions, 'remove').name('Delete browser save')
experimentFolder.add(experimentActions, 'exportJson').name('Export JSON')
experimentFolder.add(experimentActions, 'importJson').name('Import JSON')
experimentFolder.add(experimentActions, 'capturePng').name('Capture PNG')
gui.hide()
let mode: AppMode = 'Inspect'
let textureRequest = 0
let activeFixture: FixtureName = defaultFixture
let customArtworkName: string | undefined
let customMaskName: string | undefined
let dragging = false
let lastPointer = new THREE.Vector2()
const activePointers = new Map<number, THREE.Vector2>()
let lastPinchDistance: number | undefined
const rotationTarget = new THREE.Euler()
let scriptedMotion = false
let scriptStartedAt = 0
let scriptStartRotation = new THREE.Euler()
let scriptStartLight = new THREE.Vector3()
let scriptTargetLightX = 0
const clock = new THREE.Clock()
const frameTimes: number[] = []
let lastPerformanceUpdate = performance.now()
let lastFrameTime: number | undefined
let pack: PackOpening | undefined
let packLoading = false
let packUIDirty = false
let packError: string | undefined
let packTextures: THREE.Texture[] = []
let selectedPackStyle = defaultPackStyle
function updateMaterial() {
applyMaterialControls(frontMaterial, controls)
pack?.syncLighting(frontMaterial)
}
function updateEdgeMaterial() {
applyEdgeMaterialControls(edgeMaterial, controls)
}
function syncRuntimeLightPositions() {
pointLight.position.copy(lightPosition)
directionalLight.position.copy(lightPosition)
spotLight.position.copy(lightPosition)
}
function updateLight() {
lightPosition.set(controls.lightX, controls.lightY, controls.lightZ)
syncRuntimeLightPositions()
pointLight.color.set(controls.lightColor)
directionalLight.color.set(controls.lightColor)
spotLight.color.set(controls.lightColor)
pointLight.intensity = controls.lightIntensity * 11.3
directionalLight.intensity = controls.lightIntensity * 1.8
spotLight.intensity = controls.lightIntensity * 18
pointLight.visible = controls.lightType === 'Point'
directionalLight.visible = controls.lightType === 'Directional'
spotLight.visible = controls.lightType === 'Spot'
const lightModes: Record<LightType, number> = {
Point: 0,
Directional: 1,
Spot: 2,
}
frontMaterial.uniforms.lightMode.value = lightModes[controls.lightType]
frontMaterial.uniforms.lightColor.value.set(controls.lightColor)
frontMaterial.uniforms.lightIntensity.value = controls.lightIntensity
pack?.syncLighting(frontMaterial)
}
function markLightingCustom() {
if (applyingLightingPreset || controls.lightingPreset === 'Custom') return
controls.lightingPreset = 'Custom'
lightingPresetSelect.value = 'Custom'
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
}
function applyLightingPreset(presetName: Exclude<LightingPreset, 'Custom'>) {
pauseScriptedMotion()
applyingLightingPreset = true
const preset = lightingPresets[presetName]
controls.lightingPreset = presetName
controls.lightType = preset.lightType
controls.lightColor = preset.lightColor
controls.lightX = preset.lightPosition[0]
controls.lightY = preset.lightPosition[1]
controls.lightZ = preset.lightPosition[2]
controls.lightIntensity = preset.lightIntensity
controls.environmentIntensity = preset.environmentIntensity
controls.exposure = preset.exposure
lightingPresetSelect.value = presetName
lightTypeSelect.value = preset.lightType
activeEnvironmentName = preset.environment
activeEnvironment = environments[activeEnvironmentName]
scene.environment = activeEnvironment
frontMaterial.uniforms.studioEnvironment.value = activeEnvironment
renderer.toneMappingExposure = preset.exposure
updateMaterial()
updateLight()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
applyingLightingPreset = false
}
function updateMode() {
const previousMode = mode
mode = modeSelect.value as AppMode
if (previousMode !== mode) {
dragging = false
lastPinchDistance = undefined
for (const id of activePointers.keys()) {
if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id)
}
activePointers.clear()
if (mode === 'Pack') {
// Freeze the normal pose, including a partially finished flip, while it is hidden.
rotationTarget.copy(cardRoot.rotation)
loadingElement.hidden = true
if (window.matchMedia('(max-width: 880px)').matches) setControlsCollapsed(true)
void preparePack()
} else {
pack?.setActive(false)
}
}
cardRoot.visible = mode !== 'Pack'
document.documentElement.classList.toggle('pack-mode', mode === 'Pack')
packControlsElement.hidden = mode !== 'Pack'
const cameraTarget = mode === 'Lab' && controls.target === 'Camera'
orbit.enabled = cameraTarget
canvas.classList.toggle('camera-target', cameraTarget)
gui.show(mode === 'Lab')
hintElement.textContent = mode === 'Inspect'
? 'Drag the card · pinch or wheel to zoom · flip to inspect the back'
: cameraTarget
? 'Camera target · drag to orbit · pinch or wheel to zoom'
: 'Card target · drag to rotate · pinch or wheel to zoom'
updateStatus()
}
function updateStatus() {
if (mode === 'Pack') {
updatePackUI()
return
}
const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : activeFixture
const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : ''
statusElement.textContent = `${artworkLabel} · ${controls.substrate} · ${controls.finish}${maskLabel}`
}
function updatePackUI() {
packUIDirty = false
if (mode !== 'Pack') return
const view = pack?.view
const state = pack?.state ?? (packError ? 'error' : 'loading')
const paused = String(pack?.paused ?? false)
if (packControlsElement.dataset.state !== state) packControlsElement.dataset.state = state
if (packControlsElement.dataset.paused !== paused) packControlsElement.dataset.paused = paused
setIfChanged(packTearProgress, 'hidden', !!pack && pack.state !== 'sealed' && pack.state !== 'opening')
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
setIfChanged(packRestartButton, 'disabled', packLoading || fixtureAssets.size === 0)
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
setIfChanged(packRestartButton, 'textContent', 'New pack')
setIfChanged(packStyleSelect, 'disabled', !pack || packLoading)
setIfChanged(packStyleSelect, 'value', pack?.style ?? defaultPackStyle)
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
setIfChanged(packStatusElement, 'textContent', status)
setIfChanged(statusElement, 'textContent',
`Random foil pack · ${packSize} cards · equal card / material / finish odds`)
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
}
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) {
if (pack && !newRoll) {
pack.syncLighting(frontMaterial)
pack.setActive(true)
updatePackUI()
return
}
if (packLoading) return
packLoading = true
packError = undefined
if (newRoll && pack) {
selectedPackStyle = pack.style
pack.dispose()
pack = undefined
for (const texture of packTextures) texture.dispose()
packTextures = []
}
updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try {
const contents = rollPackContents([...fixtureAssets.keys()])
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
const selectedAssets = selectedNames.map(name => {
const asset = fixtureAssets.get(name)
if (!asset) throw new Error(`Card "${name}" disappeared from the catalog while rolling`)
return asset
})
// Only cards selected for this pack are loaded; a large catalog does not consume GPU memory.
const paths = selectedAssets.flatMap(asset => [asset.artwork, asset.mask])
const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
for (const result of results) {
if (result.status === 'fulfilled') result.value.dispose()
}
throw failure.reason
}
textures = results.map((result, index) => {
if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`)
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
return result.value
})
const selectedTextures = new Map(selectedAssets.map((asset, index) => [
asset.name,
{ artwork: textures[index * 2], mask: textures[index * 2 + 1] },
]))
preparedPack = new PackOpening({
canvas,
contents,
textures: selectedTextures,
backMaterial,
normalMap,
environment: activeEnvironment,
lightPosition,
onChange: () => { packUIDirty = true },
})
preparedPack.setStyle(selectedPackStyle)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
let preparedEnvironment: THREE.Texture | null
let preparedLightType: LightType
do {
preparedEnvironment = scene.environment
preparedLightType = controls.lightType
preparedPack.syncLighting(frontMaterial)
await preparedPack.prepareGPU(renderer, scene)
} while (scene.environment !== preparedEnvironment || controls.lightType !== preparedLightType)
preparedPack.syncLighting(frontMaterial)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
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 {
packLoading = false
updatePackUI()
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isVectorTuple(value: unknown): value is [number, number, number] {
return Array.isArray(value) &&
value.length === 3 &&
value.every(isFiniteNumber)
}
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 finishValid = finishNames.some(name => name === snapshot.finish)
const substrateValid = snapshot.substrate === 'Plastic' ||
substrateNames.some(name => name === snapshot.substrate)
const lightingPresetValid =
snapshot.lightingPreset === 'Studio' ||
snapshot.lightingPreset === 'Warm gallery' ||
snapshot.lightingPreset === 'Cool window' ||
snapshot.lightingPreset === 'Dramatic spot' ||
snapshot.lightingPreset === 'Flat review' ||
snapshot.lightingPreset === 'Custom'
const environmentValid =
snapshot.environment === 'Studio' ||
snapshot.environment === 'Warm' ||
snapshot.environment === 'Cool' ||
snapshot.environment === 'Dark' ||
snapshot.environment === 'Neutral'
const lightTypeValid =
snapshot.lightType === 'Point' ||
snapshot.lightType === 'Directional' ||
snapshot.lightType === 'Spot'
const optionalNamesValid =
(snapshot.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') &&
(snapshot.customMaskName === undefined || typeof snapshot.customMaskName === 'string')
const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2
const wearValid = snapshot.schemaVersion === 1 ||
(isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed))
return schemaValid &&
snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' &&
typeof snapshot.name === 'string' &&
typeof snapshot.savedAt === 'string' &&
fixtureValid &&
optionalNamesValid &&
finishValid &&
substrateValid &&
isFiniteNumber(snapshot.finishStrength) &&
isFiniteNumber(snapshot.roughness) &&
isFiniteNumber(snapshot.normalStrength) &&
isFiniteNumber(snapshot.environmentIntensity) &&
wearValid &&
lightingPresetValid &&
environmentValid &&
lightTypeValid &&
typeof snapshot.lightColor === 'string' &&
/^#[0-9a-f]{6}$/i.test(snapshot.lightColor) &&
isVectorTuple(snapshot.lightPosition) &&
isFiniteNumber(snapshot.lightIntensity) &&
isFiniteNumber(snapshot.exposure) &&
isVectorTuple(snapshot.cardRotation) &&
isVectorTuple(snapshot.cameraPosition) &&
isVectorTuple(snapshot.orbitTarget)
}
function normalizedExperimentName() {
const name = experimentControls.name.trim()
if (!name) {
statusElement.textContent = 'Enter an experiment name first.'
return undefined
}
return name
}
function createExperimentSnapshot(name: string): ExperimentSnapshot {
pauseScriptedMotion()
return {
schemaVersion: 2,
runtimeRevision: 'runtime-look-v4-2026-09-07',
name,
savedAt: new Date().toISOString(),
fixture: activeFixture,
customArtworkName,
customMaskName,
finish: controls.finish,
substrate: controls.substrate,
finishStrength: controls.finishStrength,
roughness: controls.roughness,
normalStrength: controls.normalStrength,
environmentIntensity: controls.environmentIntensity,
condition: controls.condition,
imperfectionSeed: controls.imperfectionSeed,
lightingPreset: controls.lightingPreset,
environment: activeEnvironmentName,
lightType: controls.lightType,
lightColor: controls.lightColor,
lightPosition: [lightPosition.x, lightPosition.y, lightPosition.z],
lightIntensity: controls.lightIntensity,
exposure: controls.exposure,
cardRotation: [cardRoot.rotation.x, cardRoot.rotation.y, cardRoot.rotation.z],
cameraPosition: [camera.position.x, camera.position.y, camera.position.z],
orbitTarget: [orbit.target.x, orbit.target.y, orbit.target.z],
}
}
function safeFilename(name: string) {
const safe = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return safe || 'card-experiment'
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function experimentStorageKey(name: string) {
return `sanctification-card-lab:experiment:${name}`
}
function saveExperimentToBrowser() {
const name = normalizedExperimentName()
if (!name) return
try {
localStorage.setItem(
experimentStorageKey(name),
JSON.stringify(createExperimentSnapshot(name)),
)
statusElement.textContent = `Saved experiment "${name}" in this browser.`
} catch (error) {
statusElement.textContent =
`Could not save experiment: ${error instanceof Error ? error.message : String(error)}`
}
}
async function loadExperimentFromBrowser() {
const name = normalizedExperimentName()
if (!name) return
try {
const stored = localStorage.getItem(experimentStorageKey(name))
if (stored === null) {
statusElement.textContent = `No browser experiment named "${name}".`
return
}
const parsed: unknown = JSON.parse(stored)
if (!isExperimentSnapshot(parsed)) {
statusElement.textContent = `Experiment "${name}" has an unsupported or invalid format.`
return
}
await applyExperimentSnapshot(parsed)
} catch (error) {
statusElement.textContent =
`Could not load experiment: ${error instanceof Error ? error.message : String(error)}`
}
}
function removeExperimentFromBrowser() {
const name = normalizedExperimentName()
if (!name) return
const key = experimentStorageKey(name)
if (localStorage.getItem(key) === null) {
statusElement.textContent = `No browser experiment named "${name}".`
return
}
localStorage.removeItem(key)
statusElement.textContent = `Deleted browser experiment "${name}".`
}
function exportExperimentJson() {
const name = normalizedExperimentName()
if (!name) return
const snapshot = createExperimentSnapshot(name)
downloadBlob(
new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json' }),
`${safeFilename(name)}.card-experiment.json`,
)
statusElement.textContent = `Exported experiment "${name}".`
}
function captureExperimentPng() {
const name = normalizedExperimentName()
if (!name) return
pauseScriptedMotion()
resizeRenderer()
renderer.render(scene, camera)
canvas.toBlob((blob) => {
if (blob === null) {
statusElement.textContent = 'Could not capture the current render.'
return
}
downloadBlob(blob, `${safeFilename(name)}.png`)
statusElement.textContent = `Captured experiment "${name}".`
}, 'image/png')
}
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
pauseScriptedMotion()
if (!fixtureAssets.has(snapshot.fixture)) {
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
}
fixtureSelect.value = snapshot.fixture
await loadFixture(snapshot.fixture)
controls.finish = snapshot.finish
controls.substrate = snapshot.substrate
controls.finishStrength = THREE.MathUtils.clamp(snapshot.finishStrength, 0, 1)
controls.roughness = THREE.MathUtils.clamp(snapshot.roughness, 0.05, 0.8)
controls.normalStrength = THREE.MathUtils.clamp(snapshot.normalStrength, 0, 0.45)
controls.environmentIntensity = THREE.MathUtils.clamp(snapshot.environmentIntensity, 0, 1.5)
controls.condition = THREE.MathUtils.clamp(snapshot.condition ?? 1, 0, 1)
controls.imperfectionSeed = Math.round(
THREE.MathUtils.clamp(snapshot.imperfectionSeed ?? 81251, 1, 99999),
)
controls.lightingPreset = snapshot.lightingPreset
controls.lightType = snapshot.lightType
controls.lightColor = snapshot.lightColor
controls.lightX = THREE.MathUtils.clamp(snapshot.lightPosition[0], -6, 6)
controls.lightY = THREE.MathUtils.clamp(snapshot.lightPosition[1], -6, 6)
controls.lightZ = THREE.MathUtils.clamp(snapshot.lightPosition[2], 0.5, 9)
controls.lightIntensity = THREE.MathUtils.clamp(snapshot.lightIntensity, 0, 8)
controls.exposure = THREE.MathUtils.clamp(snapshot.exposure, 0.5, 1.8)
activeEnvironmentName = snapshot.environment
activeEnvironment = environments[activeEnvironmentName]
scene.environment = activeEnvironment
frontMaterial.uniforms.studioEnvironment.value = activeEnvironment
renderer.toneMappingExposure = controls.exposure
finishSelect.value = controls.finish
substrateSelect.value = controls.substrate
lightingPresetSelect.value = controls.lightingPreset
lightTypeSelect.value = controls.lightType
poseSelect.value = 'Free'
cardRoot.rotation.set(
THREE.MathUtils.clamp(snapshot.cardRotation[0], -1.25, 1.25),
snapshot.cardRotation[1],
THREE.MathUtils.clamp(snapshot.cardRotation[2], -Math.PI, Math.PI),
)
rotationTarget.copy(cardRoot.rotation)
camera.position.set(...snapshot.cameraPosition)
if (camera.position.lengthSq() < 0.0001) camera.position.set(0, 0, 8.2)
camera.position.setLength(THREE.MathUtils.clamp(camera.position.length(), 5, 12))
orbit.target.set(
THREE.MathUtils.clamp(snapshot.orbitTarget[0], -5, 5),
THREE.MathUtils.clamp(snapshot.orbitTarget[1], -5, 5),
THREE.MathUtils.clamp(snapshot.orbitTarget[2], -5, 5),
)
orbit.update()
updateMaterial()
updateEdgeMaterial()
updateLight()
experimentControls.name = snapshot.name
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName
? ' Local artwork or masks must be loaded again.'
: ''
if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${customAssetNote}`
}
function syncLightControls() {
controls.lightX = lightPosition.x
controls.lightY = lightPosition.y
controls.lightZ = lightPosition.z
}
function pauseScriptedMotion() {
if (!scriptedMotion) return
scriptedMotion = false
rotationTarget.copy(cardRoot.rotation)
syncLightControls()
sweepButton.textContent = 'Play sweep'
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
}
function applyInspectionPose(pose: InspectionPose) {
pauseScriptedMotion()
if (pose === 'Free') return
const poses: Record<Exclude<InspectionPose, 'Free'>, [number, number, number]> = {
Front: [-0.06, 0.12, 0],
Grazing: [-0.14, THREE.MathUtils.degToRad(52), 0],
Edge: [-0.05, Math.PI / 2, 0],
Back: [-0.06, Math.PI + 0.12, 0],
}
rotationTarget.set(...poses[pose])
}
function resetView() {
pauseScriptedMotion()
const resetFixture = activeFixture !== defaultFixture ||
customArtworkName !== undefined || customMaskName !== undefined
rotationTarget.set(-0.06, 0.12, 0)
cardRoot.rotation.copy(rotationTarget)
camera.position.set(0, 0, 8.2)
orbit.target.set(0, 0, 0)
orbit.update()
modeSelect.value = 'Inspect'
controls.target = 'Card'
fixtureSelect.value = defaultFixture
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 = 0.14
controls.condition = 1
controls.imperfectionSeed = 81251
applyLightingPreset('Studio')
updateMaterial()
updateEdgeMaterial()
updateMode()
if (resetFixture) {
void loadFixture(defaultFixture)
} else {
updateStatus()
}
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
}
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) {
loadingElement.hidden = true
fixtureSelect.value = activeFixture
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
return
}
try {
const results = await Promise.allSettled([
loader.loadAsync(asset.artwork),
loader.loadAsync(asset.mask),
])
const [artworkResult, maskResult] = results
if (artworkResult.status === 'rejected') {
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
throw artworkResult.reason
}
if (maskResult.status === 'rejected') {
artworkResult.value.dispose()
throw maskResult.reason
}
const artwork = artworkResult.value
const mask = maskResult.value
configureFrontTexture(artwork, THREE.SRGBColorSpace)
configureFrontTexture(mask, THREE.NoColorSpace)
if (request !== textureRequest) {
artwork.dispose()
mask.dispose()
return
}
const previousArtwork = frontMaterial.uniforms.artwork.value as THREE.Texture
const previousMask = frontMaterial.uniforms.finishMask.value as THREE.Texture
frontMaterial.uniforms.artwork.value = artwork
frontMaterial.uniforms.finishMask.value = mask
frontMaterial.uniformsNeedUpdate = true
activeFixture = nextFixture
customArtworkName = undefined
customMaskName = undefined
previousArtwork.dispose()
previousMask.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)}`
}
}
} 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)
cardCatalog = nextCatalog
fixtureAssets = nextAssets
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
renderCardCatalog(nextCatalog)
if (nextCatalog.errors.length) {
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
}
if (
nextAsset &&
previousAsset?.revision !== nextAsset.revision &&
customArtworkName === undefined &&
customMaskName === undefined
) {
await loadFixture(activeFixture)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
catalogStateElement.textContent = 'Catalog error'
catalogStateElement.classList.add('has-errors')
catalogStateElement.title = message
statusElement.textContent = `Could not refresh card catalog: ${message}`
}
}
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
const request = ++textureRequest
const objectUrl = URL.createObjectURL(file)
loadingElement.hidden = false
statusElement.textContent = `Loading ${target} from ${file.name}…`
try {
const texture = await loader.loadAsync(objectUrl)
configureFrontTexture(
texture,
target === 'artwork' ? THREE.SRGBColorSpace : THREE.NoColorSpace,
)
if (request !== textureRequest) {
texture.dispose()
return
}
const uniformName = target === 'artwork' ? 'artwork' : 'finishMask'
const previousTexture = frontMaterial.uniforms[uniformName].value as THREE.Texture
frontMaterial.uniforms[uniformName].value = texture
frontMaterial.uniformsNeedUpdate = true
previousTexture.dispose()
if (target === 'artwork') {
customArtworkName = file.name
} else {
customMaskName = file.name
}
updateStatus()
} catch (error) {
if (request === textureRequest && mode !== 'Pack') {
statusElement.textContent =
`Could not load ${file.name}: ${error instanceof Error ? error.message : String(error)}`
}
} finally {
URL.revokeObjectURL(objectUrl)
if (request === textureRequest) loadingElement.hidden = true
}
}
function startSweep() {
if (scriptedMotion) {
pauseScriptedMotion()
return
}
scriptedMotion = true
scriptStartedAt = clock.getElapsedTime()
scriptStartRotation.copy(cardRoot.rotation)
rotationTarget.copy(cardRoot.rotation)
scriptStartLight.copy(lightPosition)
scriptTargetLightX = scriptStartLight.x >= 0 ? -3.2 : 3.2
poseSelect.value = 'Free'
sweepButton.textContent = 'Stop sweep'
}
function resizeRenderer() {
const width = canvas.clientWidth
const height = canvas.clientHeight
const pixelRatio = Math.min(window.devicePixelRatio, 2)
const targetWidth = Math.floor(width * pixelRatio)
const targetHeight = Math.floor(height * pixelRatio)
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)
camera.aspect = width / height
camera.updateProjectionMatrix()
pack?.resize(width, height)
}
}
function setCameraDistance(distance: number) {
const offset = camera.position.clone().sub(orbit.target)
if (offset.lengthSq() < 0.0001) offset.set(0, 0, 1)
offset.setLength(THREE.MathUtils.clamp(distance, orbit.minDistance, orbit.maxDistance))
camera.position.copy(orbit.target).add(offset)
}
function getPinchDistance() {
const pointers = [...activePointers.values()]
return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1])
}
canvas.addEventListener('pointerdown', (event) => {
if (mode === 'Pack') {
pack?.pointerDown(event)
return
}
if (orbit.enabled || event.button !== 0) return
pauseScriptedMotion()
activePointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY))
canvas.setPointerCapture(event.pointerId)
if (activePointers.size === 1) {
dragging = true
poseSelect.value = 'Free'
lastPointer.set(event.clientX, event.clientY)
} else {
dragging = false
lastPinchDistance = getPinchDistance()
}
})
canvas.addEventListener('pointermove', (event) => {
if (mode === 'Pack') {
pack?.pointerMove(event)
return
}
const pointer = activePointers.get(event.pointerId)
if (!pointer) return
pointer.set(event.clientX, event.clientY)
if (activePointers.size >= 2) {
const pinchDistance = getPinchDistance()
if (pinchDistance && lastPinchDistance && pinchDistance > 0) {
const cameraDistance = camera.position.distanceTo(orbit.target)
setCameraDistance(cameraDistance * lastPinchDistance / pinchDistance)
}
lastPinchDistance = pinchDistance
return
}
if (!dragging) return
const dx = event.clientX - lastPointer.x
const dy = event.clientY - lastPointer.y
cardRoot.rotation.y += dx * 0.008
cardRoot.rotation.x += dy * 0.008
cardRoot.rotation.x = THREE.MathUtils.clamp(cardRoot.rotation.x, -1.25, 1.25)
rotationTarget.copy(cardRoot.rotation)
lastPointer.set(event.clientX, event.clientY)
})
canvas.addEventListener('pointerup', (event) => {
if (mode === 'Pack') {
pack?.pointerUp(event)
return
}
activePointers.delete(event.pointerId)
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId)
}
lastPinchDistance = getPinchDistance()
const remainingPointer = activePointers.values().next().value as THREE.Vector2 | undefined
dragging = remainingPointer !== undefined
if (remainingPointer) lastPointer.copy(remainingPointer)
})
canvas.addEventListener('pointercancel', (event) => {
if (mode === 'Pack') {
pack?.pointerUp(event, true)
return
}
activePointers.delete(event.pointerId)
lastPinchDistance = getPinchDistance()
const remainingPointer = activePointers.values().next().value as THREE.Vector2 | undefined
dragging = remainingPointer !== undefined
if (remainingPointer) lastPointer.copy(remainingPointer)
})
canvas.addEventListener('wheel', (event) => {
if (mode === 'Pack') {
event.preventDefault()
pack?.wheel(event)
return
}
if (orbit.enabled) return
event.preventDefault()
pauseScriptedMotion()
const distance = camera.position.distanceTo(orbit.target)
setCameraDistance(distance + event.deltaY * 0.006)
}, { passive: false })
canvas.addEventListener('lostpointercapture', (event) => {
if (mode === 'Pack') pack?.pointerUp(event, true)
})
packPrimaryButton.addEventListener('click', () => {
if (pack) pack.primary()
else void preparePack()
})
packRestartButton.addEventListener('click', () => { void preparePack(true) })
packSkipButton.addEventListener('click', () => pack?.skip())
packStyleSelect.addEventListener('change', () => {
try {
const style = packStyles.find(style => style === packStyleSelect.value)
if (!style || !pack) throw new Error('The selected pack style is unavailable')
selectedPackStyle = style
pack.setStyle(style)
updatePackUI()
} catch (error) {
console.error('Could not change pack style', error)
packStyleSelect.value = pack?.style ?? defaultPackStyle
packStatusElement.textContent = `Pack style unavailable: ${error instanceof Error ? error.message : String(error)}`
}
})
modeSelect.addEventListener('change', () => {
pauseScriptedMotion()
updateMode()
})
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()
})
window.addEventListener('focus', () => { void refreshCardCatalog() })
if (import.meta.hot) {
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
}
substrateSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.substrate = substrateSelect.value as SubstrateName
updateMaterial()
updateEdgeMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateStatus()
})
finishSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.finish = finishSelect.value as FinishName
updateMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateStatus()
})
poseSelect.addEventListener('change', () => {
applyInspectionPose(poseSelect.value as InspectionPose)
})
lightingPresetSelect.addEventListener('change', () => {
const preset = lightingPresetSelect.value as LightingPreset
if (preset === 'Custom') {
controls.lightingPreset = 'Custom'
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
return
}
applyLightingPreset(preset)
})
lightTypeSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.lightType = lightTypeSelect.value as LightType
markLightingCustom()
updateLight()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
})
artworkFileInput.addEventListener('change', () => {
const file = artworkFileInput.files?.[0]
artworkFileInput.value = ''
if (file) {
pauseScriptedMotion()
void loadLocalTexture(file, 'artwork')
}
})
maskFileInput.addEventListener('change', () => {
const file = maskFileInput.files?.[0]
maskFileInput.value = ''
if (file) {
pauseScriptedMotion()
void loadLocalTexture(file, 'mask')
}
})
experimentFileInput.addEventListener('change', () => {
const file = experimentFileInput.files?.[0]
experimentFileInput.value = ''
if (!file) return
void (async () => {
try {
const parsed: unknown = JSON.parse(await file.text())
if (!isExperimentSnapshot(parsed)) {
statusElement.textContent = `${file.name} is not a supported card experiment.`
return
}
await applyExperimentSnapshot(parsed)
} catch (error) {
statusElement.textContent =
`Could not import ${file.name}: ${error instanceof Error ? error.message : String(error)}`
}
})()
})
flipButton.addEventListener('click', () => {
pauseScriptedMotion()
poseSelect.value = 'Free'
const currentYaw = cardRoot.rotation.y
const normalized = THREE.MathUtils.euclideanModulo(currentYaw, Math.PI * 2)
rotationTarget.copy(cardRoot.rotation)
rotationTarget.y = currentYaw +
(normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI)
})
resetButton.addEventListener('click', resetView)
sweepButton.addEventListener('click', startSweep)
controlsToggle.addEventListener('click', () => {
setControlsCollapsed(!topbar.classList.contains('controls-collapsed'))
requestAnimationFrame(resizeRenderer)
})
orbit.addEventListener('start', pauseScriptedMotion)
function updatePerformance(now: number, deltaMs: number) {
frameTimes.push(deltaMs)
if (frameTimes.length > 240) frameTimes.shift()
if (now - lastPerformanceUpdate < 1000 || frameTimes.length < 10) return
const sorted = [...frameTimes].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length * 0.5)]
const p95 = sorted[Math.floor(sorted.length * 0.95)]
const worst = sorted[sorted.length - 1]
performanceElement.textContent =
`${renderer.domElement.width}×${renderer.domElement.height} · median ${median.toFixed(1)} ms · p95 ${p95.toFixed(1)} ms · worst ${worst.toFixed(1)} ms`
lastPerformanceUpdate = now
}
function animate(frameTime: number) {
const deltaSeconds = lastFrameTime === undefined
? 0
: Math.min((frameTime - lastFrameTime) / 1000, 0.1)
if (lastFrameTime !== undefined) {
updatePerformance(frameTime, frameTime - lastFrameTime)
}
lastFrameTime = frameTime
const elapsed = clock.getElapsedTime()
resizeRenderer()
if (mode !== 'Pack') orbit.update()
if (mode !== 'Pack' && !dragging && !scriptedMotion) {
cardRoot.rotation.x = THREE.MathUtils.damp(cardRoot.rotation.x, rotationTarget.x, 9, deltaSeconds)
cardRoot.rotation.y = THREE.MathUtils.damp(cardRoot.rotation.y, rotationTarget.y, 9, deltaSeconds)
cardRoot.rotation.z = THREE.MathUtils.damp(cardRoot.rotation.z, rotationTarget.z, 9, deltaSeconds)
}
if (mode !== 'Pack' && scriptedMotion) {
const progress = (elapsed - scriptStartedAt) / 5
if (progress >= 1) {
scriptedMotion = false
cardRoot.rotation.copy(scriptStartRotation)
rotationTarget.copy(scriptStartRotation)
lightPosition.copy(scriptStartLight)
syncLightControls()
updateLight()
sweepButton.textContent = 'Play sweep'
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
} else {
const eased = progress * progress * (3 - 2 * progress)
const wave = Math.sin(eased * Math.PI)
cardRoot.rotation.x = scriptStartRotation.x - 0.12 * wave
cardRoot.rotation.y = scriptStartRotation.y + THREE.MathUtils.degToRad(24) * wave
lightPosition.set(
THREE.MathUtils.lerp(scriptStartLight.x, scriptTargetLightX, wave),
scriptStartLight.y + wave * 0.8,
scriptStartLight.z,
)
syncRuntimeLightPositions()
}
}
if (mode === 'Pack') pack?.tick(frameTime)
if (packUIDirty) updatePackUI()
renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera)
}
renderer.setAnimationLoop(animate)
window.addEventListener('resize', resizeRenderer)
updateMaterial()
updateEdgeMaterial()
updateLight()
updateMode()
resetView()
loadingElement.hidden = true
updateStatus()