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() 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 } } }