Files
Sanctification/card-harness/src/foilWrapper.ts

165 lines
7.3 KiB
TypeScript

import * as THREE from 'three'
import { createPackTexture, defaultPackStyle, packStyles, type PackStyle } from './packDesigns'
const halfWidth = 1.68
const bottom = -2.24
const seam = 1.97
const top = 2.28
const midDepth = -0.09
const smooth = THREE.MathUtils.smoothstep
/**
* Two joined pouch surfaces and one sealed ribbon, never a label over a shell.
* All deformation is evaluated from immutable UVs, not accumulated frame deltas.
*/
export function createFoilWrapper() {
const root = new THREE.Group()
root.name = 'PACK_FOIL_WRAPPER'
const designs = new Map<PackStyle, { front: THREE.CanvasTexture; back: THREE.CanvasTexture }>()
const textures: THREE.Texture[] = []
try {
for (const style of packStyles) {
const front = createPackTexture(style)
textures.push(front)
const back = createPackTexture(style, true)
textures.push(back)
designs.set(style, { front, back })
}
} catch (error) {
for (const texture of textures) texture.dispose()
throw error
}
let activeStyle = defaultPackStyle
const initialDesign = designs.get(activeStyle)!
const frontMaterial = new THREE.MeshStandardMaterial({
map: initialDesign.front, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
})
const backMaterial = frontMaterial.clone()
backMaterial.map = initialDesign.back
const stripMaterial = frontMaterial.clone()
function setStyle(style: PackStyle) {
const design = designs.get(style)
if (!design) throw new Error(`Unknown pack style: ${style}`)
if (style === activeStyle) return
frontMaterial.map = design.front
backMaterial.map = design.back
stripMaterial.map = design.front
activeStyle = style
}
const sheets = [1, -1].map((side) => {
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
mesh.name = side === 1 ? 'FOIL_FRONT' : 'FOIL_BACK'
// UVs span the original, uncut printed sheet; geometry UVs never change.
const uv = geometry.getAttribute('uv')
for (let i = 0; i < uv.count; i++) uv.setY(i, uv.getY(i) * (seam - bottom) / (top - bottom))
root.add(mesh)
return { mesh, side }
})
// Match the pouch's horizontal tessellation so the attached tear boundary shares every sample.
const stripGeometry = new THREE.PlaneGeometry(halfWidth * 2, top - seam, 64, 10)
const stripUV = stripGeometry.getAttribute('uv')
for (let i = 0; i < stripUV.count; i++) {
stripUV.setY(i, (seam - bottom + stripUV.getY(i) * (top - seam)) / (top - bottom))
}
const strip = new THREE.Mesh(stripGeometry, stripMaterial)
strip.name = 'FOIL_TEAR_STRIP'
root.add(strip)
function tornEdge(u: number) {
return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173)
}
const sheetUV = sheets[0].mesh.geometry.getAttribute('uv')
const sheetSamples = Array.from({ length: sheetUV.count }, (_, i) => {
const u = sheetUV.getX(i)
const y = bottom + sheetUV.getY(i) * (top - bottom)
const x = (u * 2 - 1) * halfWidth
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
const lower = smooth(y, bottom + 0.14, bottom + 0.47)
const neck = 1 - smooth(y, 1.65, seam)
const envelope = across * lower
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5))
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
return { x, y, across, neck, envelope, folds, crimp,
opening: smooth(y, 0.6, seam), lip: smooth(y, 1, seam),
edgeY: y + tornEdge(u) * smooth(y, 1.85, seam) }
})
const stripSamples = Array.from({ length: stripUV.count }, (_, i) => {
const u = stripUV.getX(i)
const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam)
return { u,
y: seam + v * (top - seam) + tornEdge(u) * (1 - v),
z: midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI) }
})
for (const geometry of [...sheets.map(({ mesh }) => mesh.geometry), stripGeometry]) {
for (const name of ['position', 'normal']) {
const attribute = geometry.getAttribute(name)
const buffer = attribute instanceof THREE.InterleavedBufferAttribute ? attribute.data : attribute
buffer.setUsage(THREE.DynamicDrawUsage)
}
}
let lastMouth: number | undefined
let lastTear: number | undefined
let lastDetach: number | undefined
let lastStripExit: number | undefined
const lastTouch = new THREE.Vector3()
function deform(tear: number, detach: number, mouth: number, touch: THREE.Vector3, stripExit = 4.8) {
for (const { mesh, side } of sheets) {
if (mouth === lastMouth && (side === -1 || touch.equals(lastTouch))) continue
const geometry = mesh.geometry
const positions = geometry.getAttribute('position')
for (let i = 0; i < positions.count; i++) {
const sample = sheetSamples[i]
const { x, y, across, neck, envelope, folds, crimp } = sample
const opening = mouth * sample.opening
const dent = side === 1 ? -0.045 * touch.z
* Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0
const depth = 0.37 * neck + 0.64 * opening
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * sample.lip
positions.setXYZ(i, x, sample.edgeY + lip,
midDepth + side * (depth * envelope + folds * neck + crimp) + dent)
}
positions.needsUpdate = true
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
}
lastMouth = mouth
lastTouch.copy(touch)
if (tear === lastTear && detach === lastDetach && (detach === 0 || stripExit === lastStripExit)) return
lastTear = tear
lastDetach = detach
lastStripExit = stripExit
const positions = stripGeometry.getAttribute('position')
const curvature = 0.55 + detach * 0.5
for (let i = 0; i < positions.count; i++) {
const { u, y, z } = stripSamples[i]
const length = Math.max(0, tear - u) * halfWidth * 2
const angle = length * curvature
const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth
const release = smooth(length, 0, 0.3)
// The released end rolls up around the moving tear front; the attached end stays sealed.
positions.setXYZ(i,
anchor - Math.sin(angle) / curvature + detach * stripExit,
y + 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
z - 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
}
positions.needsUpdate = true
stripGeometry.computeVertexNormals()
stripGeometry.computeBoundingSphere()
strip.visible = detach < 1
}
deform(0, 0, 0, new THREE.Vector3())
function dispose() {
for (const { mesh } of sheets) mesh.geometry.dispose()
stripGeometry.dispose()
for (const texture of textures) texture.dispose()
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
}
return { root, deform, dispose, setStyle, textures: Object.freeze(textures), get style() { return activeStyle } }
}