1611 lines
59 KiB
TypeScript
1611 lines
59 KiB
TypeScript
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()
|