MORE RE-ORGANIZING
This commit is contained in:
582
spikes/card-harness/src/packOpening.ts
Normal file
582
spikes/card-harness/src/packOpening.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user