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, type FinishName, type MaterialControls, type SubstrateName, } from './cardMaterial' import { createCardGeometry } from './cardGeometry' import { PackOpening, type PackFixture } from './packOpening' type FixtureName = PackFixture 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] } const fixtureAssets: Record = { David: { artwork: '/reference/david-front.png', mask: '/reference/david-finish-mask.png', }, Timothy: { artwork: '/reference/timothy-front.png', mask: '/reference/timothy-finish-mask.png', }, } function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) { texture.colorSpace = colorSpace texture.anisotropy = renderer.capabilities.getMaxAnisotropy() } document.querySelector('#app')!.innerHTML = `

Renderer proof · runtime look v4

Sanctification Card Lab

Preparing reference assets… Frame time —
Drag the card · pinch or wheel to zoom · flip to inspect the back
` const canvas = document.querySelector('#scene')! const statusElement = document.querySelector('#status')! const performanceElement = document.querySelector('#performance')! const loadingElement = document.querySelector('#loading')! const hintElement = document.querySelector('#hint')! const topbar = document.querySelector('.topbar')! const controlsToggle = document.querySelector('#controls-toggle')! const modeSelect = document.querySelector('#mode')! const fixtureSelect = document.querySelector('#fixture')! const finishSelect = document.querySelector('#finish')! const substrateSelect = document.querySelector('#substrate')! const poseSelect = document.querySelector('#pose')! const lightingPresetSelect = document.querySelector('#lighting-preset')! const lightTypeSelect = document.querySelector('#light-type')! const flipButton = document.querySelector('#flip')! const resetButton = document.querySelector('#reset')! const sweepButton = document.querySelector('#sweep')! const artworkFileInput = document.querySelector('#artwork-file')! const maskFileInput = document.querySelector('#mask-file')! const experimentFileInput = document.querySelector('#experiment-file')! const packControlsElement = document.querySelector('#pack-controls')! const packStatusElement = document.querySelector('#pack-status')! const packTearProgress = document.querySelector('#pack-tear')! const packPrimaryButton = document.querySelector('#pack-primary')! const packRestartButton = document.querySelector('#pack-restart')! const packSkipButton = document.querySelector('#pack-skip')! 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('.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 = { 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 initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork) const initialMask = await loader.loadAsync(fixtureAssets.David.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, { 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', ['Printed ink', 'Foil', 'Holographic']).name('Finish').onChange((value: FinishName) => { pauseScriptedMotion() finishSelect.value = value updateMaterial() updateStatus() }) surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).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 as FixtureName) }, } 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 = 'David' let customArtworkName: string | undefined let customMaskName: string | undefined let dragging = false let lastPointer = new THREE.Vector2() const activePointers = new Map() 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 packError: string | undefined 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 = { 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) { 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() { if (mode !== 'Pack') return const view = pack?.view packControlsElement.dataset.state = pack?.state ?? (packError ? 'error' : 'loading') packControlsElement.dataset.paused = String(pack?.paused ?? false) packTearProgress.hidden = !!pack && pack.state !== 'sealed' && pack.state !== 'opening' packTearProgress.value = pack?.tearProgress ?? 0 packPrimaryButton.textContent = view?.action ?? (packError ? 'Retry loading' : 'Loading…') packPrimaryButton.disabled = packLoading || pack?.state === 'complete' packRestartButton.disabled = !pack packSkipButton.disabled = !pack || pack.state === 'complete' packSkipButton.title = 'Finish this motion or move to the next resting state' const status = view?.status ?? packError ?? 'Preparing the prototype pack…' if (packStatusElement.textContent !== status) packStatusElement.textContent = status statusElement.textContent = 'Foil tear proof · 3 authored cards · no random rewards' hintElement.textContent = view?.hint ?? 'Local reference artwork · approved runtime card materials' } async function preparePack() { if (pack) { pack.syncLighting(frontMaterial) pack.setActive(true) updatePackUI() return } if (packLoading) return packLoading = true packError = undefined updatePackUI() try { // Separate texture ownership keeps local artwork, experiments and fixture disposal independent. const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask, fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask] const 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 } const 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 }) pack = new PackOpening({ canvas, textures: { David: { artwork: textures[0], mask: textures[1] }, Timothy: { artwork: textures[2], mask: textures[3] }, }, backMaterial, normalMap, environment: activeEnvironment, lightPosition, onChange: updatePackUI, }) scene.add(pack.root) pack.syncLighting(frontMaterial) pack.setActive(mode === 'Pack') pack.resize(canvas.clientWidth, canvas.clientHeight) } catch (error) { packError = `Pack assets 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 const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy' const finishValid = snapshot.finish === 'Printed ink' || snapshot.finish === 'Foil' || snapshot.finish === 'Holographic' const substrateValid = snapshot.substrate === 'Paper' || snapshot.substrate === 'Linen' || snapshot.substrate === 'Plastic' || snapshot.substrate === 'Metal' || snapshot.substrate === 'Wood' 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() 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, [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 !== 'David' || 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 = 'David' 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('David') } 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[nextFixture] 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 } } 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', () => pack?.restart()) packSkipButton.addEventListener('click', () => pack?.skip()) modeSelect.addEventListener('change', () => { pauseScriptedMotion() updateMode() }) fixtureSelect.addEventListener('change', () => { pauseScriptedMotion() void loadFixture(fixtureSelect.value as FixtureName) }) 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) 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()