MORE RE-ORGANIZING

This commit is contained in:
2026-09-08 14:00:27 -07:00
parent 0c1ea70738
commit ad41f0e140
248 changed files with 1091 additions and 0 deletions

View File

@@ -0,0 +1,582 @@
import * as THREE from 'three'
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
import { createFoilWrapper } from './foilWrapper'
import {
applyEdgeMaterialControls,
applyMaterialControls,
createCardMaterial,
type FinishName,
type SubstrateName,
} from './cardMaterial'
export type PackFixture = 'David' | 'Timothy'
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
export const packContents: readonly {
fixture: PackFixture
finish: FinishName
substrate: SubstrateName
}[] = [
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
]
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
const stackDepthSpacing = 0.055
const cardSweepRadius = Math.hypot(
cardSceneDimensions.width, cardSceneDimensions.height, cardSceneDimensions.thickness,
) / 2
// Clearance is based on the full corner sweep, not just the card's thickness or front pose.
const inspectionDepth = Math.max(3, cardSweepRadius + 0.5)
const inspectionCameraRetreat = inspectionDepth - 0.85
interface PackOptions {
canvas: HTMLCanvasElement
textures: Record<PackFixture, { artwork: THREE.Texture; mask: THREE.Texture }>
backMaterial: THREE.Material
normalMap: THREE.Texture
environment: THREE.CubeTexture
lightPosition: THREE.Vector3
onChange: () => void
}
interface Pose {
position: THREE.Vector3
quaternion: THREE.Quaternion
scale: THREE.Vector3
}
interface Track {
object: THREE.Object3D
from: Pose
to: Pose
}
interface Motion {
tracks: Track[]
startedAt: number
elapsed: number
duration: number
finish: () => void
progress?: (value: number) => void
}
function pose(x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale = 1): Pose {
return {
position: new THREE.Vector3(x, y, z),
quaternion: new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)),
scale: new THREE.Vector3(scale, scale, scale),
}
}
function setPose(object: THREE.Object3D, value: Pose) {
object.position.copy(value.position)
object.quaternion.copy(value.quaternion)
object.scale.copy(value.scale)
}
function track(object: THREE.Object3D, to: Pose): Track {
return {
object,
from: {
position: object.position.clone(),
quaternion: object.quaternion.clone(),
scale: object.scale.clone(),
},
to,
}
}
export class PackOpening {
readonly root = new THREE.Group()
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
state: PackState = 'sealed'
paused = false
private readonly options: PackOptions
private readonly wrapper = createFoilWrapper()
private readonly cards
private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
private motion: Motion | undefined
private active = false
private index = 0
private readonly revealed = new Set<number>()
private completed = false
private zoom = 1
private fitDistance = 9.6
private cameraRetreat = 0
private cameraTargetY = 0
private openingProgress = 0
private seamDrag: { id: number; x: number; progress: number; travel: number } | undefined
private readonly touch = new THREE.Vector3()
private touchTarget = 0
private lastTick: number | undefined
private readonly pointers = new Map<number, THREE.Vector2>()
private tap: { id: number; x: number; y: number; at: number } | undefined
private pinchDistance: number | undefined
constructor(options: PackOptions) {
this.options = options
this.root.name = 'PACK_PROTOTYPE'
this.root.position.y = 0.22
this.root.visible = false
this.root.add(this.wrapper.root)
this.cards = packContents.map((spec) => {
const textures = options.textures[spec.fixture]
const material = createCardMaterial(
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
)
applyMaterialControls(material, {
...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: 0.14,
environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
})
const edge = new THREE.MeshPhysicalMaterial()
const object = createCardGeometry(material, options.backMaterial, edge)
const front = object.getObjectByName('CARD_FRONT')!
this.root.add(object)
return { object, material, edge, front }
})
this.restart()
}
get view() {
const number = `${this.index + 1} / ${this.cards.length}`
const spec = packContents[this.index]
const views: Record<PackState, { action: string; status: string; hint: string }> = {
sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' },
opening: {
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
status: this.openingProgress < 0.38
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
: this.openingProgress < 0.52 ? 'Strip curling away'
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
: this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack',
hint: this.openingProgress < 0.38
? 'Pull right along the seam · release to hold · Finish opening resumes'
: 'Touch or wheel pauses motion · Skip finishes this step',
},
stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' },
lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
inspecting: {
action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card',
status: `${number} · ${spec.fixture} · ${spec.substrate} · ${spec.finish}`,
hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue',
},
advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' },
complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' },
}
return this.paused && this.motion
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
: views[this.state]
}
get tearProgress() { return Math.min(1, this.openingProgress / 0.38) }
syncLighting(source: THREE.ShaderMaterial) {
for (const { material } of this.cards) {
for (const name of ['studioEnvironment', 'environmentIntensity', 'lightMode', 'lightIntensity']) {
material.uniforms[name].value = source.uniforms[name].value
}
material.uniforms.lightColor.value.copy(source.uniforms.lightColor.value)
}
}
setActive(active: boolean) {
if (!active) this.pause(performance.now())
this.clearPointers()
this.active = active
this.root.visible = active
this.options.onChange()
}
resize(width: number, height: number) {
if (width <= 0 || height <= 0) return
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
const tangent = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))
this.fitDistance = Math.max(9.6, 3.95 / (2 * tangent * this.camera.aspect))
this.updateCamera()
}
private updateCamera() {
this.camera.position.set(0, this.cameraTargetY, this.fitDistance * this.zoom + this.cameraRetreat)
this.camera.lookAt(0, this.cameraTargetY, 0)
}
private stackPose(offset: number) {
return pose(offset * 0.045, -offset * 0.045, -0.08 - offset * stackDepthSpacing, 0, Math.PI, 0, 0.88)
}
private reservePose(offset: number) {
const target = this.stackPose(offset)
target.position.x += 1.05
target.position.y -= 0.4
return target
}
restart() {
this.clearPointers()
this.motion = undefined
this.paused = false
this.index = 0
this.revealed.clear()
this.completed = false
this.zoom = 1
this.cameraRetreat = 0
this.cameraTargetY = 0
this.openingProgress = 0
this.touch.set(0, 0, 0)
this.touchTarget = 0
this.lastTick = undefined
this.updateCamera()
this.wrapper.root.visible = true
setPose(this.wrapper.root, pose(0, 0, 0))
this.wrapper.deform(0, 0, 0, this.touch)
this.cards.forEach((card, index) => {
card.object.visible = true
card.front.visible = false
applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 })
setPose(card.object, this.stackPose(index))
})
this.setState('sealed')
}
private shapeWrapper() {
const stripExit = (this.camera.position.z + 4)
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2
this.wrapper.deform(this.tearProgress,
THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, stripExit)
}
private setOpeningProgress(value: number) {
this.openingProgress = value
this.shapeWrapper()
const smooth = THREE.MathUtils.smoothstep
const settling = smooth(value, 0.87, 1)
const framing = smooth(value, 0.52, 0.76) * (1 - settling)
this.cameraRetreat = 8 * framing
this.cameraTargetY = 1.6 * framing
this.updateCamera()
this.cards.forEach((card, index) => {
const target = this.stackPose(index)
// All cards clear the mouth before the pouch moves aside. Depth spacing never changes.
target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling)
setPose(card.object, target)
})
const aside = smooth(value, 0.83, 0.87)
const halfViewWidth = this.camera.position.z
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect
this.wrapper.root.position.x = -(halfViewWidth + 2) * aside
this.wrapper.root.visible = value < 0.87
this.options.onChange()
}
private open() {
const start = this.openingProgress
this.begin('opening', [], 3200 * (1 - start), () => {
this.wrapper.root.visible = false
this.setState('stackReady')
}, (value) => this.setOpeningProgress(THREE.MathUtils.lerp(start, 1, value)))
}
private setState(state: PackState) {
this.state = state
const revealPhase = state === 'revealing' || state === 'inspecting' || state === 'advancing'
this.cards.forEach((card, index) => {
card.object.visible = state !== 'complete' && index >= this.index
card.front.visible = revealPhase && index === this.index
})
if (state === 'inspecting' && !this.revealed.has(this.index)) {
this.revealed.add(this.index)
applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 })
this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
detail: { index: this.index + 1, ...packContents[this.index] },
}))
}
if (state === 'complete' && !this.completed) {
this.completed = true
this.options.canvas.dispatchEvent(new CustomEvent('packcomplete', { detail: { count: this.revealed.size } }))
}
this.options.onChange()
}
private begin(
state: PackState, tracks: Track[], duration: number, finish: () => void,
progress?: (value: number) => void,
) {
this.paused = false
this.motion = {
tracks, duration: this.reducedMotion.matches ? 70 : duration,
startedAt: performance.now(), elapsed: 0, finish, progress,
}
this.setState(state)
}
primary() {
if (!this.active) return
this.clearPointers()
if (this.motion) {
if (this.paused) {
this.paused = false
this.motion.startedAt = performance.now()
this.options.onChange()
}
return
}
const card = this.cards[this.index]
switch (this.state) {
case 'sealed':
case 'opening':
this.open()
break
case 'stackReady': {
// This is the stack card itself, not a second lift actor or a cloned placeholder.
const tracks = [track(card.object, pose(0, 0.14, inspectionDepth, 0, Math.PI))]
for (let next = this.index + 1; next < this.cards.length; next++) {
tracks.push(track(this.cards[next].object, this.reservePose(next - this.index - 1)))
}
const startRetreat = this.cameraRetreat
this.begin('lifting', tracks, 620, () => this.setState('lifted'), (value) => {
// Camera framing follows the lift; updateCamera remains the sole camera transform writer.
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, inspectionCameraRetreat, value)
this.updateCamera()
})
break
}
case 'lifted':
this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820,
() => this.setState('inspecting'))
break
case 'inspecting': {
const halfViewWidth = (this.camera.position.z - inspectionDepth)
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect
const exitX = -Math.max(4.8, halfViewWidth + cardSweepRadius + 0.3)
// Stay on the safe plane even when advancing from an arbitrary manually rotated pose.
const tracks = [track(card.object, pose(exitX, 0.3, inspectionDepth, 0.1, -0.5, -0.18, 0.85))]
for (let next = this.index + 1; next < this.cards.length; next++) {
tracks.push(track(this.cards[next].object, this.stackPose(next - this.index - 1)))
}
const startRetreat = this.cameraRetreat
this.begin('advancing', tracks, 650, () => {
if (this.index === this.cards.length - 1) {
this.setState('complete')
} else {
this.index++
this.setState('stackReady')
}
}, (value) => {
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value)
this.updateCamera()
})
break
}
default:
break
}
}
skip() {
if (!this.active) return
this.clearPointers()
if (!this.motion) this.primary()
if (this.motion) this.finishMotion()
}
private finishMotion() {
const motion = this.motion
if (!motion) return
for (const item of motion.tracks) setPose(item.object, item.to)
motion.progress?.(1)
this.motion = undefined
this.paused = false
motion.finish()
}
tick(now: number) {
const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000))
this.lastTick = now
if (!this.active) return
if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
this.touch.z = this.reducedMotion.matches ? this.touchTarget
: THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
this.shapeWrapper()
}
const motion = this.motion
if (!motion || this.paused) return
const progress = THREE.MathUtils.clamp((motion.elapsed + now - motion.startedAt) / motion.duration, 0, 1)
if (progress >= 1) {
this.finishMotion()
return
}
const eased = progress * progress * (3 - 2 * progress)
for (const { object, from, to } of motion.tracks) {
object.position.lerpVectors(from.position, to.position, eased)
object.quaternion.slerpQuaternions(from.quaternion, to.quaternion, eased)
object.scale.lerpVectors(from.scale, to.scale, eased)
}
motion.progress?.(eased)
}
private pause(now: number) {
const interrupted = this.motion !== undefined
this.tick(now)
if (this.motion && !this.paused) {
this.motion.elapsed += now - this.motion.startedAt
this.paused = true
this.options.onChange()
}
return interrupted
}
pointerDown(event: PointerEvent) {
if (!this.active || event.button !== 0) return
const interrupted = this.pause(performance.now())
this.pointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY))
this.options.canvas.setPointerCapture(event.pointerId)
this.tap = this.pointers.size === 1 && !interrupted
? { id: event.pointerId, x: event.clientX, y: event.clientY, at: performance.now() }
: undefined
this.pinchDistance = this.getPinchDistance()
if (this.pointers.size > 1) {
this.endSeamDrag()
this.touchTarget = 0
return
}
if (this.state === 'sealed' || (this.state === 'opening' && this.openingProgress < 0.38)) {
const local = this.wrapperPoint(event.clientX, event.clientY)
if (local && Math.abs(local.x) < 1.8 && local.y > -2.24 && local.y < 2.5) {
this.touch.set(local.x, Math.min(local.y, 1.7), this.touch.z)
this.touchTarget = 1
}
const seam = this.seamScreenBounds()
if (event.clientX >= seam.left - 18 && event.clientX <= seam.right + 18
&& Math.abs(event.clientY - seam.y) <= Math.max(22, seam.height)) {
this.motion = undefined
this.seamDrag = { id: event.pointerId, x: event.clientX,
progress: this.openingProgress, travel: Math.max(80, (seam.right - seam.left) * 0.75) }
this.paused = false
}
}
}
pointerMove(event: PointerEvent) {
const pointer = this.pointers.get(event.pointerId)
if (!pointer) return
const dx = event.clientX - pointer.x
const dy = event.clientY - pointer.y
pointer.set(event.clientX, event.clientY)
if (this.tap && Math.hypot(event.clientX - this.tap.x, event.clientY - this.tap.y) > 6) {
this.tap = undefined
}
if (this.pointers.size >= 2) {
const distance = this.getPinchDistance()
if (distance && this.pinchDistance) this.setZoom(this.zoom * this.pinchDistance / distance)
this.pinchDistance = distance
} else if (this.seamDrag?.id === event.pointerId) {
const drag = this.seamDrag
const progress = THREE.MathUtils.clamp(drag.progress
+ Math.max(0, event.clientX - drag.x) / drag.travel * 0.38, this.openingProgress, 0.38)
if (progress > this.openingProgress && !this.tap) {
if (this.state === 'sealed') this.setState('opening')
this.setOpeningProgress(progress)
}
} else if (this.state === 'inspecting') {
const object = this.cards[this.index].object
object.rotation.y += dx * 0.008
object.rotation.x = THREE.MathUtils.clamp(object.rotation.x + dy * 0.008, -1.25, 1.25)
} else if (this.touchTarget) {
const local = this.wrapperPoint(event.clientX, event.clientY)
if (local) {
this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68)
this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7)
this.shapeWrapper()
}
}
}
pointerUp(event: PointerEvent, cancelled = false) {
if (!this.pointers.has(event.pointerId)) return
const tearing = this.seamDrag?.id === event.pointerId
if (tearing) this.endSeamDrag()
this.touchTarget = 0
const tap = this.tap
this.tap = undefined
this.pointers.delete(event.pointerId)
this.pinchDistance = this.getPinchDistance()
if (this.options.canvas.hasPointerCapture(event.pointerId)) {
this.options.canvas.releasePointerCapture(event.pointerId)
}
if (tearing && !cancelled && this.tearProgress >= 1) {
this.primary()
return
}
if (!cancelled && tap?.id === event.pointerId && performance.now() - tap.at < 600
&& Math.hypot(event.clientX - tap.x, event.clientY - tap.y) <= 6) this.primary()
}
wheel(event: WheelEvent) {
event.preventDefault()
this.pause(performance.now())
this.clearPointers()
const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 240 : 1)
this.setZoom(this.zoom * Math.exp(pixels * 0.001))
}
private setZoom(zoom: number) {
this.zoom = THREE.MathUtils.clamp(zoom, 0.62, 1.45)
this.updateCamera()
}
private getPinchDistance() {
const pointers = [...this.pointers.values()]
return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1])
}
private clearPointers() {
this.endSeamDrag()
this.touchTarget = 0
this.tap = undefined
const ids = [...this.pointers.keys()]
this.pointers.clear()
for (const id of ids) {
if (this.options.canvas.hasPointerCapture(id)) this.options.canvas.releasePointerCapture(id)
}
this.pinchDistance = undefined
}
private endSeamDrag() {
if (!this.seamDrag) return
this.seamDrag = undefined
if (this.state === 'opening' && !this.motion) this.paused = true
this.options.onChange()
}
private seamScreenBounds() {
const rect = this.options.canvas.getBoundingClientRect()
this.root.updateMatrixWorld(true)
this.camera.updateMatrixWorld(true)
const screen = (x: number, y: number) => {
const point = this.wrapper.root.localToWorld(new THREE.Vector3(x, y, -0.09)).project(this.camera)
return { x: rect.left + (point.x + 1) * rect.width / 2,
y: rect.top + (1 - point.y) * rect.height / 2 }
}
const left = screen(-1.68, 2.12)
const right = screen(1.68, 2.12)
return { left: left.x, right: right.x, y: left.y, height: Math.abs(screen(0, 1.97).y - screen(0, 2.28).y) }
}
private wrapperPoint(x: number, y: number) {
const rect = this.options.canvas.getBoundingClientRect()
this.camera.updateMatrixWorld(true)
this.root.updateMatrixWorld(true)
const ray = new THREE.Raycaster()
ray.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1,
1 - (y - rect.top) / rect.height * 2), this.camera)
const point = ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.28), new THREE.Vector3())
return point ? this.wrapper.root.worldToLocal(point) : undefined
}
}