Download tear.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 76.2 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/tear.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/tear.js
-
curl -L -o tear.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/tear.js
76.2 kB
| import { DMG_HEAD as DMG_HEAD_T, DMG_CUT as DMG_CUT_T, DMG_EDGE as DMG_EDGE_T } from './damage.js?v=20261016g'; | |
| // "Tear apart": a just-for-fun brittle fracture toy on top of the unrolled strip. | |
| // Not a real unrolling. Position based dynamics (Verlet + distance constraints) on typed arrays, | |
| // with real cracks: a crack is a jagged polyline in the sheet's own material space that grows from | |
| // its tips. Physics links it crosses break (so nodes on each side separate: no material is removed), | |
| // and a much finer render mesh is cut along the polyline and skinned to the physics nodes on its side. | |
| // | |
| // Material model, qualitative, for carbonized Herculaneum papyrus: | |
| // - Charcoal-like and brittle, falls to pieces with light contact (Parker et al. 2023, EduceLab-Scrolls, | |
| // arXiv:2304.02084; Mocella et al. 2015, Nat. Commun. 6:5895). So: very stiff in stretch and bend, | |
| // failure at about 1 to 2 % strain, and a small bend angle before cracking. Carbonized plant matter | |
| // (charcoal) generally fails near 1 % strain; we use 0.9 % along fibers and 1.8 % across them. | |
| // - Papyrus is two cross-ply fiber layers pressed together; carbonization makes them bubble and | |
| // delaminate (EduceLab-Scrolls, sec. 1). So: recto (horizontal fibers) and verso (vertical fibers) | |
| // are separate sheets joined by breakable glue, each with its own cracks, biased along its fibers. | |
| // - Adjacent windings are pressed together and often fused, worst in the outer scorza | |
| // (Bettuzzi et al. 2021, Sci. Rep. 11:1727). So: neighboring windings share breakable adhesion links, | |
| // and every sheet starts stuck to the rest of the roll (an invisible backing it peels away from). | |
| // - Low density: about 0.3 to 0.5 g/cm3 for charcoal, so strong air drag and flutter for small flakes. | |
| // The numbers are chosen to match that behavior, not measured on these scrolls. | |
| const P = { | |
| cell: 3.8, // mm between physics nodes (desktop): about 18 x 18 per winding and layer | |
| cellMobile: 4.6, | |
| renderCell: 0.95, // mm, render mesh triangle size once a sheet cracks (about 70 x 70 per layer, vs 18 x 18 physics) | |
| layerGap: 0.22, // mm between recto and verso | |
| thickness: 0.15, // mm, dark torn edge face | |
| gravity: 9810, // mm/s^2 | |
| drag: 2.4, // 1/s velocity loss for sheet nodes (low density) | |
| substeps: 1, | |
| iters: 6, | |
| strainAlong: 0.009, // failure strain along the fibers of a layer (crack runs along fibers) | |
| strainAcross: 0.018, | |
| jitter: 0.45, // +/- fraction of random scatter in thresholds | |
| bendCrackDeg: 14, // fold angle at a node that cracks it | |
| glueGap: 0.35, // mm of layer separation that delaminates recto from verso | |
| fuseStrain: 0.012, // adhesion between neighboring windings | |
| peelFrac: 0.85, // a stuck node lets go of the backing above this fraction of its failure strain | |
| peelProb: 0.08, // brittle: it snaps rather than peels | |
| shatterSpeed: 700, // mm/s impact speed that shatters links at a node | |
| tipGrow: 0.8, // a crack tip advances while nearby links are above this fraction of failure | |
| fiberBias: 0.75, // how strongly cracks follow the fibers of their layer | |
| turnJitter: 0.38, // radians of random wander per step | |
| branch: 0.025, // chance per step that a crack forks | |
| stepsPerTip: 4, // propagation steps per tip per frame, at most | |
| stepBudget: 100, // propagation steps per frame, all cracks | |
| maxFlakes: 1800, | |
| maxDust: 260, | |
| }; | |
| export function createTear(ctx) { | |
| const { THREE, scene, camera, controls, canvas, meshes, shared, FRAG, stripZ, ctImage, damage } = ctx; | |
| const MOBILE = ctx.MOBILE; | |
| const cell = MOBILE ? P.cellMobile : P.cell; | |
| // CT mask: which nodes sit on traced papyrus | |
| const mc = document.createElement('canvas'); | |
| const MW = 2048, MH = Math.round(2048 * ctImage.height / ctImage.width); | |
| mc.width = MW; mc.height = MH; | |
| const mg = mc.getContext('2d', { willReadFrequently: true }); | |
| mg.drawImage(ctImage, 0, 0, MW, MH); | |
| const md = mg.getImageData(0, 0, MW, MH).data; | |
| const onPapyrus = (atlas, u, v) => { | |
| const x = Math.round((atlas[0] + u * (atlas[2] - atlas[0])) * (MW - 1)); | |
| const y = Math.round((atlas[1] + v * (atlas[3] - atlas[1])) * (MH - 1)); | |
| let m = 0; | |
| for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { | |
| const xx = Math.min(MW - 1, Math.max(0, x + dx)), yy = Math.min(MH - 1, Math.max(0, y + dy)); | |
| m = Math.max(m, md[(yy * MW + xx) * 4]); | |
| } | |
| return m > 3; | |
| }; | |
| // ------------------------------------------------------------ build nodes | |
| const sheets = []; | |
| let N = 0; | |
| let ground = Infinity; | |
| for (const m of meshes) { | |
| const { w, own } = m.userData; | |
| const W = w.widthMM, H = w.heightMM; | |
| const C = Math.max(4, Math.round(W / cell) + 1), R = Math.max(4, Math.round(H / cell) + 1); | |
| const o = own.uFlatO.value; | |
| const s = { m, w, R, C, x0: o.x, y0: o.y, W, H, cw: W / (C - 1), ch: H / (R - 1), base: [N, N + R * C], atlas: own.uAtlas.value.toArray() }; | |
| s.lay = [0, 1].map((L) => ({ s, L, base: s.base[L], z: stripZ - L * P.layerGap, fib: L === 0 ? [1, 0] : [0, 1] })); | |
| sheets.push(s); | |
| N += 2 * R * C; | |
| ground = Math.min(ground, o.y - H); | |
| } | |
| ground -= 8; // floor close below the strip, so fallen pieces and dust stay in view | |
| const pos = new Float32Array(N * 3), prev = new Float32Array(N * 3), rest = new Float32Array(N * 3); | |
| const inv = new Float32Array(N), stuck = new Uint8Array(N), valid = new Uint8Array(N); | |
| const sheetOf = new Int16Array(N); | |
| const matX = new Float32Array(N), matY = new Float32Array(N); // material coordinates, mm | |
| const uv = new Float32Array(N * 2); | |
| sheets.forEach((s, si) => { | |
| for (let L = 0; L < 2; L++) for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) { | |
| const k = s.base[L] + i * s.C + j; | |
| const u = j / (s.C - 1), v = i / (s.R - 1); | |
| uv[k * 2] = u; uv[k * 2 + 1] = v; | |
| matX[k] = u * s.W; matY[k] = v * s.H; | |
| rest[k * 3] = s.x0 + matX[k]; | |
| rest[k * 3 + 1] = s.y0 - matY[k]; | |
| rest[k * 3 + 2] = stripZ - L * P.layerGap; | |
| valid[k] = onPapyrus(s.atlas, u, v) ? 1 : 0; | |
| sheetOf[k] = si; | |
| } | |
| }); | |
| const validRest = valid.slice(); // crushing removes nodes; Reset brings them back | |
| // ------------------------------------------------------------ constraints | |
| // kinds: 0 structural along fiber, 1 structural across fiber, 2 shear, 3 bend, 4 glue, 5 fuse | |
| const A = [], B = [], K = [], T = [], Lk = []; | |
| const edge = new Map(); | |
| const add = (a, b, kind, thr) => { | |
| if (!valid[a] || !valid[b]) return -1; | |
| const id = A.length; | |
| A.push(a); B.push(b); K.push(kind); T.push(thr); Lk.push(null); | |
| edge.set(a < b ? a * N + b : b * N + a, id); | |
| return id; | |
| }; | |
| const eid = (a, b) => edge.get(a < b ? a * N + b : b * N + a) ?? -1; | |
| const jit = () => 1 + (Math.random() * 2 - 1) * P.jitter; | |
| const cosCrack = Math.cos((P.bendCrackDeg * Math.PI) / 180 / 2); | |
| sheets.forEach((s) => { | |
| s.c0 = A.length; | |
| for (let L = 0; L < 2; L++) { | |
| const id = (i, j) => s.base[L] + i * s.C + j; | |
| // recto fibers run horizontally (along j); verso fibers vertically (along i). | |
| // A layer cracks along its fibers, so the links across the fibers fail first. | |
| const hStrain = L === 0 ? P.strainAcross : P.strainAlong; | |
| const vStrain = L === 0 ? P.strainAlong : P.strainAcross; | |
| for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) { | |
| if (j + 1 < s.C) add(id(i, j), id(i, j + 1), L === 0 ? 0 : 1, 1 + hStrain * jit()); | |
| if (i + 1 < s.R) add(id(i, j), id(i + 1, j), L === 0 ? 1 : 0, 1 + vStrain * jit()); | |
| } | |
| for (let i = 0; i + 1 < s.R; i++) for (let j = 0; j + 1 < s.C; j++) { | |
| add(id(i, j), id(i + 1, j + 1), 2, 1 + 1.6 * P.strainAcross * jit()); | |
| add(id(i, j + 1), id(i + 1, j), 2, 1 + 1.6 * P.strainAcross * jit()); | |
| } | |
| for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) { | |
| if (j + 2 < s.C) add(id(i, j), id(i, j + 2), 3, cosCrack); | |
| if (i + 2 < s.R) add(id(i, j), id(i + 2, j), 3, cosCrack); | |
| } | |
| } | |
| for (let k = 0; k < s.R * s.C; k++) add(s.base[0] + k, s.base[1] + k, 4, P.glueGap * jit()); | |
| s.c1 = A.length; | |
| }); | |
| const f0 = A.length; | |
| for (let n = 0; n + 1 < sheets.length; n++) { | |
| const s = sheets[n], q = sheets[n + 1]; | |
| for (let L = 0; L < 2; L++) for (let i = 0; i < s.R; i++) { | |
| const y = s.y0 - (i / (s.R - 1)) * s.H; | |
| const qi = Math.round(((q.y0 - y) / q.H) * (q.R - 1)); | |
| if (qi < 0 || qi >= q.R) continue; | |
| add(s.base[L] + i * s.C + s.C - 1, q.base[L] + qi * q.C, 5, 1 + P.fuseStrain * jit()); | |
| } | |
| } | |
| const NC = A.length; | |
| const ca = Int32Array.from(A), cb = Int32Array.from(B), kind = Uint8Array.from(K), thr = Float32Array.from(T); | |
| const len0 = new Float32Array(NC), alive = new Uint8Array(NC); | |
| // slow strain (own weight, steady sag) creeps into a baseline; brittle failure is driven by sudden strain | |
| const creep = new Float32Array(NC); | |
| for (let c = 0; c < NC; c++) { | |
| const a = ca[c] * 3, b = cb[c] * 3; | |
| len0[c] = Math.hypot(rest[a] - rest[b], rest[a + 1] - rest[b + 1], rest[a + 2] - rest[b + 2]); | |
| } | |
| // node -> in-layer links (structural, shear, bend), for crack crossing and stress sampling | |
| const deg = new Int32Array(N + 1); | |
| for (let c = 0; c < NC; c++) if (kind[c] <= 3) { deg[ca[c] + 1]++; deg[cb[c] + 1]++; } | |
| for (let i = 0; i < N; i++) deg[i + 1] += deg[i]; | |
| const adj = new Int32Array(deg[N]); | |
| { const f = deg.slice(); for (let c = 0; c < NC; c++) if (kind[c] <= 3) { adj[f[ca[c]]++] = c; adj[f[cb[c]]++] = c; } } | |
| // ------------------------------------------------------------ rendering | |
| const group = new THREE.Group(); | |
| group.visible = false; | |
| scene.add(group); | |
| const VERT = /* glsl */` | |
| attribute vec2 aLocal; | |
| attribute float aKind; | |
| varying vec2 vLocal; varying vec3 vN; varying vec3 vW; varying float vE; varying float vRim; varying float vStrand; | |
| void main() { | |
| vLocal = aLocal; vN = normalize(normal); vE = 1.0; | |
| vRim = aKind == 2.0 ? 1.0 : 0.0; vStrand = aKind == 1.0 ? 1.0 : 0.0; | |
| vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; | |
| gl_Position = projectionMatrix * viewMatrix * w; | |
| }`; | |
| // recto: the page's own shader (ink and X-ray overlays), plus dark torn edge faces and fiber strands | |
| const RECTO = FRAG | |
| .replace('varying float vE;', 'varying float vE;\nvarying float vRim;\nvarying float vStrand;') | |
| .replace('if (ct < 0.012 && ink < 0.02) discard;', 'if (ct < 0.012 && ink < 0.02 && vRim < 0.5) discard;') | |
| .replace('gl_FragColor = vec4(col, 1.0);', | |
| 'col = mix(col, papy * 0.8 + vec3(0.03, 0.022, 0.015), vStrand * 0.7);\n col = mix(col, vec3(0.022, 0.016, 0.012) * (0.6 + 0.4 * diff), vRim);\n gl_FragColor = vec4(col, 1.0);'); | |
| // verso: matte brown-black charcoal with a faint sheen, fibers from the CT | |
| const VERSO = DMG_HEAD_T + /* glsl */` | |
| uniform sampler2D uCT; uniform vec4 uAtlas; | |
| varying vec2 vLocal; varying vec3 vN; varying vec3 vW; varying float vRim; varying float vStrand; | |
| void main() { | |
| vec2 auv = uAtlas.xy + vLocal * (uAtlas.zw - uAtlas.xy); | |
| float ct = texture2D(uCT, auv).r; | |
| if (ct < 0.012 && vRim < 0.5) discard; | |
| vec3 n = normalize(vN), v = normalize(cameraPosition - vW), l = normalize(vec3(-0.35, 0.75, 0.55)); | |
| ${DMG_CUT_T} | |
| float diff = 0.25 + 0.75 * abs(dot(n, l)); | |
| float spec = pow(max(dot(reflect(-l, n * sign(dot(n, v))), v), 0.0), 24.0) * 0.10; | |
| vec3 col = mix(vec3(0.018, 0.013, 0.010), vec3(0.16, 0.11, 0.075), pow(ct, 1.4)) * diff + spec; | |
| col = mix(col, col * 1.25 + vec3(0.02, 0.014, 0.01), vStrand); | |
| col = mix(col, vec3(0.012, 0.009, 0.007) * (0.6 + 0.4 * diff), vRim); | |
| ${DMG_EDGE_T} | |
| gl_FragColor = vec4(col, 1.0); | |
| }`; | |
| const mats = (s, L) => { | |
| const own = s.m.userData.own; | |
| return L === 0 | |
| ? new THREE.ShaderMaterial({ uniforms: { ...shared, uAtlas: own.uAtlas, uGpuTex: own.uGpuTex, uGpuOn: own.uGpuOn, uHi: { value: 0 }, uDmg: own.uDmg, uDmgOn: own.uDmgOn, uDmgMM: own.uDmgMM }, vertexShader: VERT, fragmentShader: RECTO, side: THREE.DoubleSide }) | |
| : new THREE.ShaderMaterial({ uniforms: { uCT: shared.uCT, uAtlas: own.uAtlas, uDmg: own.uDmg, uDmgOn: own.uDmgOn, uDmgMM: own.uDmgMM, uDmgT: shared.uDmgT }, vertexShader: VERT, fragmentShader: VERSO, side: THREE.DoubleSide }); | |
| }; | |
| // coarse mesh: the physics grid itself, used until a sheet cracks | |
| for (const s of sheets) for (const lay of s.lay) { | |
| const n = s.R * s.C, b = lay.base; | |
| const geo = new THREE.BufferGeometry(); | |
| const pa = new THREE.BufferAttribute(pos.subarray(b * 3, (b + n) * 3), 3); | |
| pa.setUsage(THREE.DynamicDrawUsage); | |
| geo.setAttribute('position', pa); | |
| geo.setAttribute('aLocal', new THREE.BufferAttribute(uv.slice(b * 2, (b + n) * 2), 2)); | |
| geo.setAttribute('aKind', new THREE.BufferAttribute(new Float32Array(n), 1)); | |
| const ix = []; | |
| for (let i = 0; i + 1 < s.R; i++) for (let j = 0; j + 1 < s.C; j++) { | |
| const p00 = i * s.C + j, p01 = p00 + 1, p10 = p00 + s.C, p11 = p10 + 1; | |
| if (!valid[b + p00] || !valid[b + p01] || !valid[b + p10] || !valid[b + p11]) continue; | |
| ix.push(p00, p10, p01, p01, p10, p11); | |
| } | |
| geo.setIndex(ix); | |
| const mesh = new THREE.Mesh(geo, mats(s, lay.L)); | |
| mesh.frustumCulled = false; | |
| mesh.userData = { lay }; | |
| group.add(mesh); | |
| lay.coarse = mesh; | |
| } | |
| // ------------------------------------------------------------ fine render mesh, cut along cracks | |
| // A triangle soup in material space (mm). Each vertex is skinned to physics nodes on its own side of | |
| // every crack: bilinear in its cell when all four corners are reachable without crossing a crack, | |
| // otherwise affine on the three nearest reachable nodes, otherwise rigidly to the nearest one. | |
| // kinds: 0 face, 1 fiber strand, 2 torn edge face (offset along the normal: gives thickness) | |
| const cellOfXY = (s, x, y) => { | |
| const ci = Math.min(s.R - 2, Math.max(0, Math.floor(y / s.ch))); | |
| const cj = Math.min(s.C - 2, Math.max(0, Math.floor(x / s.cw))); | |
| return ci * (s.C - 1) + cj; | |
| }; | |
| function makeFine(lay) { | |
| const s = lay.s; | |
| const nx = Math.max(8, Math.round(s.W / P.renderCell)), ny = Math.max(8, Math.round(s.H / P.renderCell)); | |
| const cap = nx * ny * 2 + 24000; | |
| const F = { | |
| n: 0, cap, | |
| tm: new Float32Array(cap * 6), // material xy of 3 vertices | |
| ref: new Float32Array(cap * 2), // the point whose side decides the binding | |
| kd: new Uint8Array(cap), | |
| off: new Float32Array(cap * 3), // per vertex: thickness offset along the normal | |
| bi: new Int32Array(cap * 12), bw: new Float32Array(cap * 12), bo: new Float32Array(cap * 9), | |
| nsg: new Float32Array(cap * 3), // per vertex: sign so the edge face goes toward the back | |
| cellTris: Array.from({ length: (s.R - 1) * (s.C - 1) }, () => []), | |
| dirty: new Set(), | |
| }; | |
| const geo = new THREE.BufferGeometry(); | |
| F.pa = new THREE.BufferAttribute(new Float32Array(cap * 9), 3).setUsage(THREE.DynamicDrawUsage); | |
| F.la = new THREE.BufferAttribute(new Float32Array(cap * 6), 2).setUsage(THREE.DynamicDrawUsage); | |
| F.ka = new THREE.BufferAttribute(new Float32Array(cap * 3), 1).setUsage(THREE.DynamicDrawUsage); | |
| F.na = new THREE.BufferAttribute(new Float32Array(cap * 9), 3).setUsage(THREE.DynamicDrawUsage); | |
| geo.setAttribute('normal', F.na); | |
| geo.setAttribute('position', F.pa); geo.setAttribute('aLocal', F.la); geo.setAttribute('aKind', F.ka); | |
| F.geo = geo; | |
| const mesh = new THREE.Mesh(geo, lay.coarse.material); | |
| mesh.frustumCulled = false; | |
| mesh.userData = { lay, fine: true }; | |
| group.add(mesh); | |
| F.mesh = mesh; | |
| lay.F = F; | |
| const dx = s.W / nx, dy = s.H / ny; | |
| for (let i = 0; i < ny; i++) for (let j = 0; j < nx; j++) { | |
| const x0 = j * dx, y0 = i * dy, x1 = x0 + dx, y1 = y0 + dy; | |
| addTri(lay, x0, y0, x0, y1, x1, y0, 0); | |
| addTri(lay, x1, y0, x0, y1, x1, y1, 0); | |
| } | |
| for (let t = 0; t < F.n; t++) bindTri(lay, t); | |
| F.attrsDirty = true; | |
| lay.coarse.visible = false; | |
| } | |
| function addTri(lay, ax, ay, bx, by, cx, cy, k, rx, ry, offs) { | |
| const F = lay.F, s = lay.s; | |
| if (F.n >= F.cap) return -1; | |
| const t = F.n++; | |
| setTri(lay, t, ax, ay, bx, by, cx, cy, k, rx, ry, offs); | |
| // register in every physics cell the triangle overlaps | |
| const ci0 = Math.max(0, Math.floor(Math.min(ay, by, cy) / s.ch)), ci1 = Math.min(s.R - 2, Math.floor(Math.max(ay, by, cy) / s.ch)); | |
| const cj0 = Math.max(0, Math.floor(Math.min(ax, bx, cx) / s.cw)), cj1 = Math.min(s.C - 2, Math.floor(Math.max(ax, bx, cx) / s.cw)); | |
| for (let i = ci0; i <= ci1; i++) for (let j = cj0; j <= cj1; j++) F.cellTris[i * (s.C - 1) + j].push(t); | |
| return t; | |
| } | |
| function setTri(lay, t, ax, ay, bx, by, cx, cy, k, rx, ry, offs) { | |
| const F = lay.F, s = lay.s, o = t * 6; | |
| F.tm[o] = ax; F.tm[o + 1] = ay; F.tm[o + 2] = bx; F.tm[o + 3] = by; F.tm[o + 4] = cx; F.tm[o + 5] = cy; | |
| F.ref[t * 2] = rx ?? (ax + bx + cx) / 3; F.ref[t * 2 + 1] = ry ?? (ay + by + cy) / 3; | |
| F.kd[t] = k; | |
| F.off[t * 3] = offs ? offs[0] : 0; F.off[t * 3 + 1] = offs ? offs[1] : 0; F.off[t * 3 + 2] = offs ? offs[2] : 0; | |
| const la = F.la.array; | |
| for (let v = 0; v < 3; v++) { la[o + v * 2] = F.tm[o + v * 2] / s.W; la[o + v * 2 + 1] = F.tm[o + v * 2 + 1] / s.H; F.ka.array[t * 3 + v] = k; } | |
| F.attrsDirty = true; | |
| } | |
| const node = (s, L, i, j) => s.base[L] + i * s.C + j; | |
| function reach(lay, x, y, n) { return valid[n] && !crossesCrack(lay, x, y, matX[n], matY[n]); } | |
| function bindTri(lay, t) { | |
| const F = lay.F, s = lay.s, L = lay.L; | |
| const rx = F.ref[t * 2], ry = F.ref[t * 2 + 1]; | |
| const ci = Math.min(s.R - 2, Math.max(0, Math.floor(ry / s.ch))), cj = Math.min(s.C - 2, Math.max(0, Math.floor(rx / s.cw))); | |
| const c4 = [node(s, L, ci, cj), node(s, L, ci, cj + 1), node(s, L, ci + 1, cj), node(s, L, ci + 1, cj + 1)]; | |
| const o12 = t * 12, o9 = t * 9, o3 = t * 3; | |
| const noCrack = !lay.nseg; | |
| if (c4.every((n) => (noCrack ? valid[n] : reach(lay, rx, ry, n)))) { | |
| for (let v = 0; v < 3; v++) { | |
| const fx = F.tm[t * 6 + v * 2] / s.cw - cj, fy = F.tm[t * 6 + v * 2 + 1] / s.ch - ci; | |
| const w = [(1 - fx) * (1 - fy), fx * (1 - fy), (1 - fx) * fy, fx * fy]; | |
| for (let q = 0; q < 4; q++) { F.bi[o12 + v * 4 + q] = c4[q]; F.bw[o12 + v * 4 + q] = w[q]; } | |
| F.bo[o9 + v * 3] = F.bo[o9 + v * 3 + 1] = F.bo[o9 + v * 3 + 2] = 0; | |
| F.nsg[o3 + v] = 1; // corners in order c00, c01, c10: rest normal is -z, the back | |
| } | |
| return; | |
| } | |
| // nearest reachable nodes in the 4 x 4 block around the cell | |
| const cand = []; | |
| for (let i = ci - 1; i <= ci + 2; i++) for (let j = cj - 1; j <= cj + 2; j++) { | |
| if (i < 0 || j < 0 || i >= s.R || j >= s.C) continue; | |
| const n = node(s, L, i, j); | |
| if (!reach(lay, rx, ry, n)) continue; | |
| cand.push([n, (matX[n] - rx) ** 2 + (matY[n] - ry) ** 2]); | |
| } | |
| cand.sort((a, b) => a[1] - b[1]); | |
| let a = -1, b = -1, c = -1; | |
| if (cand.length) a = cand[0][0]; | |
| if (cand.length > 1) b = cand[1][0]; | |
| for (let q = 2; q < cand.length && c < 0; q++) { | |
| const n = cand[q][0]; | |
| const ar = (matX[b] - matX[a]) * (matY[n] - matY[a]) - (matY[b] - matY[a]) * (matX[n] - matX[a]); | |
| if (Math.abs(ar) > 0.25 * s.cw * s.ch) c = n; | |
| } | |
| for (let v = 0; v < 3; v++) { | |
| const x = F.tm[t * 6 + v * 2], y = F.tm[t * 6 + v * 2 + 1]; | |
| const bi = o12 + v * 4; | |
| F.bw[bi] = F.bw[bi + 1] = F.bw[bi + 2] = F.bw[bi + 3] = 0; | |
| F.bi[bi] = F.bi[bi + 1] = F.bi[bi + 2] = F.bi[bi + 3] = Math.max(0, a); | |
| F.bo[o9 + v * 3] = F.bo[o9 + v * 3 + 1] = F.bo[o9 + v * 3 + 2] = 0; | |
| F.nsg[o3 + v] = 0; | |
| if (c >= 0) { | |
| const x1 = matX[a], y1 = matY[a], x2 = matX[b], y2 = matY[b], x3 = matX[c], y3 = matY[c]; | |
| const den = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); | |
| const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / den; | |
| const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / den; | |
| F.bi[bi] = a; F.bi[bi + 1] = b; F.bi[bi + 2] = c; | |
| F.bw[bi] = l1; F.bw[bi + 1] = l2; F.bw[bi + 2] = 1 - l1 - l2; | |
| // orient the edge-face offset toward the back at rest | |
| const ux = rest[b * 3] - rest[a * 3], uy = rest[b * 3 + 1] - rest[a * 3 + 1]; | |
| const vx = rest[c * 3] - rest[a * 3], vy = rest[c * 3 + 1] - rest[a * 3 + 1]; | |
| F.nsg[o3 + v] = ux * vy - uy * vx < 0 ? 1 : -1; | |
| } else if (a >= 0) { | |
| // rigid: follow the nearest node on this side | |
| F.bw[bi] = 1; | |
| F.bo[o9 + v * 3] = s.x0 + x - rest[a * 3]; | |
| F.bo[o9 + v * 3 + 1] = s.y0 - y - rest[a * 3 + 1]; | |
| F.bo[o9 + v * 3 + 2] = lay.z - rest[a * 3 + 2]; | |
| } | |
| // no reachable node: weights stay 0, the triangle collapses out of sight | |
| } | |
| } | |
| function skin(lay) { | |
| const F = lay.F, p = F.pa.array; | |
| const nv = F.n * 3; | |
| for (let v = 0; v < nv; v++) { | |
| const bi = v * 4; | |
| const n0 = F.bi[bi] * 3, n1 = F.bi[bi + 1] * 3, n2 = F.bi[bi + 2] * 3, n3 = F.bi[bi + 3] * 3; | |
| const w0 = F.bw[bi], w1 = F.bw[bi + 1], w2 = F.bw[bi + 2], w3 = F.bw[bi + 3]; | |
| let x = w0 * pos[n0] + w1 * pos[n1] + w2 * pos[n2] + w3 * pos[n3] + F.bo[v * 3]; | |
| let y = w0 * pos[n0 + 1] + w1 * pos[n1 + 1] + w2 * pos[n2 + 1] + w3 * pos[n3 + 1] + F.bo[v * 3 + 1]; | |
| let z = w0 * pos[n0 + 2] + w1 * pos[n1 + 2] + w2 * pos[n2 + 2] + w3 * pos[n3 + 2] + F.bo[v * 3 + 2]; | |
| const off = F.off[v]; | |
| if (off && F.nsg[v]) { | |
| const ux = pos[n1] - pos[n0], uy = pos[n1 + 1] - pos[n0 + 1], uz = pos[n1 + 2] - pos[n0 + 2]; | |
| const vx = pos[n2] - pos[n0], vy = pos[n2 + 1] - pos[n0 + 1], vz = pos[n2 + 2] - pos[n0 + 2]; | |
| let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; | |
| const l = Math.hypot(nx, ny, nz) || 1, k = (off * F.nsg[v]) / l; | |
| x += nx * k; y += ny * k; z += nz * k; | |
| } else if (off) z -= off; | |
| p[v * 3] = x; p[v * 3 + 1] = y; p[v * 3 + 2] = z; | |
| } | |
| // guard: a triangle stretched past 2.2x its rest size, or with a bad number, is a binding glitch: hide it. | |
| // Then flat normals, written straight into the attribute (cheaper than computeVertexNormals). | |
| const m = F.tm, nr = F.na.array; | |
| for (let t = 0; t < F.n; t++) { | |
| const o = t * 9, q = t * 6; | |
| const thick = F.kd[t] === 2 ? P.thickness : 0; | |
| let bad = F.kd[t] === 3; // crushed away | |
| // bound to a node that is gone (crushed or retired as debris) or to nothing at all: that node no longer moves, | |
| // so the triangle would hang frozen in mid-air | |
| for (let q = t * 12, qe = q + 12; q < qe && !bad; q++) if (F.bw[q] !== 0 && !valid[F.bi[q]]) bad = true; | |
| if (!bad && F.bw[t * 12] === 0 && F.bw[t * 12 + 1] === 0 && F.bw[t * 12 + 2] === 0 && F.bw[t * 12 + 3] === 0) bad = true; | |
| for (let e = 0; e < 3 && !bad; e++) { | |
| const i = e, j = e === 2 ? 0 : e + 1; | |
| const rx = m[q + i * 2] - m[q + j * 2], ry = m[q + i * 2 + 1] - m[q + j * 2 + 1]; | |
| const r = Math.sqrt(rx * rx + ry * ry) + thick; | |
| const dx = p[o + i * 3] - p[o + j * 3], dy = p[o + i * 3 + 1] - p[o + j * 3 + 1], dz = p[o + i * 3 + 2] - p[o + j * 3 + 2]; | |
| const lim = 2.2 * r + 0.05; // brittle: real carbon never stretches this far, so it is a glitch | |
| if (!(dx * dx + dy * dy + dz * dz <= lim * lim)) bad = true; // also catches NaN | |
| } | |
| if (bad) { | |
| if (!Number.isFinite(p[o] + p[o + 1] + p[o + 2])) { p[o] = p[o + 1] = p[o + 2] = 0; } | |
| for (let v = 1; v < 3; v++) { p[o + v * 3] = p[o]; p[o + v * 3 + 1] = p[o + 1]; p[o + v * 3 + 2] = p[o + 2]; } | |
| } | |
| const ux = p[o + 3] - p[o], uy = p[o + 4] - p[o + 1], uz = p[o + 5] - p[o + 2]; | |
| const vx = p[o + 6] - p[o], vy = p[o + 7] - p[o + 1], vz = p[o + 8] - p[o + 2]; | |
| let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; | |
| const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; | |
| nx /= l; ny /= l; nz /= l; | |
| nr[o] = nr[o + 3] = nr[o + 6] = nx; nr[o + 1] = nr[o + 4] = nr[o + 7] = ny; nr[o + 2] = nr[o + 5] = nr[o + 8] = nz; | |
| } | |
| F.na.needsUpdate = true; | |
| F.pa.needsUpdate = true; | |
| F.geo.setDrawRange(0, nv); | |
| if (F.attrsDirty) { F.la.needsUpdate = true; F.ka.needsUpdate = true; F.attrsDirty = false; } | |
| } | |
| // ------------------------------------------------------------ cracks | |
| // Per layer: segments in material space, hashed by physics cell, and growing tips. | |
| const segX = (ax, ay, bx, by, cx, cy, dx, dy) => { | |
| const d1 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx); | |
| const d2 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx); | |
| if ((d1 > 0) === (d2 > 0) || d1 === 0 || d2 === 0) return false; | |
| const d3 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); | |
| const d4 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax); | |
| return (d3 > 0) !== (d4 > 0) && d3 !== 0 && d4 !== 0; | |
| }; | |
| function hashCells(s, ax, ay, bx, by, pad, fn) { | |
| const ci0 = Math.max(0, Math.floor(Math.min(ay, by) / s.ch) - pad), ci1 = Math.min(s.R - 2, Math.floor(Math.max(ay, by) / s.ch) + pad); | |
| const cj0 = Math.max(0, Math.floor(Math.min(ax, bx) / s.cw) - pad), cj1 = Math.min(s.C - 2, Math.floor(Math.max(ax, bx) / s.cw) + pad); | |
| for (let i = ci0; i <= ci1; i++) for (let j = cj0; j <= cj1; j++) fn(i * (s.C - 1) + j, i, j); | |
| } | |
| function crossesCrack(lay, ax, ay, bx, by) { | |
| if (!lay.nseg) return false; | |
| const s = lay.s, sg = lay.seg; | |
| let hit = false; | |
| hashCells(s, ax, ay, bx, by, 0, (c) => { | |
| if (hit) return; | |
| const l = lay.hash[c]; | |
| if (!l) return; | |
| for (let q = 0; q < l.length; q++) { | |
| const o = l[q] * 4; | |
| if (segX(ax, ay, bx, by, sg[o], sg[o + 1], sg[o + 2], sg[o + 3])) { hit = true; return; } | |
| } | |
| }); | |
| return hit; | |
| } | |
| function firstCrossing(lay, ax, ay, bx, by) { | |
| // nearest point where a new step would run into an existing crack (cracks join, they do not cross) | |
| if (!lay.nseg) return null; | |
| const s = lay.s, sg = lay.seg; | |
| let best = null, bt = 2; | |
| hashCells(s, ax, ay, bx, by, 0, (c) => { | |
| const l = lay.hash[c]; | |
| if (!l) return; | |
| for (const q of l) { | |
| const o = q * 4; | |
| if (!segX(ax, ay, bx, by, sg[o], sg[o + 1], sg[o + 2], sg[o + 3])) continue; | |
| const ex = bx - ax, ey = by - ay, fx = sg[o + 2] - sg[o], fy = sg[o + 3] - sg[o + 1]; | |
| const den = ex * fy - ey * fx; | |
| const tt = ((sg[o] - ax) * fy - (sg[o + 1] - ay) * fx) / den; | |
| if (tt < bt) { bt = tt; best = [ax + ex * tt, ay + ey * tt]; } | |
| } | |
| }); | |
| return best; | |
| } | |
| function worldAt(lay, x, y) { | |
| const s = lay.s, L = lay.L; | |
| const ci = Math.min(s.R - 2, Math.max(0, Math.floor(y / s.ch))), cj = Math.min(s.C - 2, Math.max(0, Math.floor(x / s.cw))); | |
| const fx = x / s.cw - cj, fy = y / s.ch - ci; | |
| const n = [node(s, L, ci, cj), node(s, L, ci, cj + 1), node(s, L, ci + 1, cj), node(s, L, ci + 1, cj + 1)]; | |
| const w = [(1 - fx) * (1 - fy), fx * (1 - fy), (1 - fx) * fy, fx * fy]; | |
| const r = [0, 0, 0]; | |
| for (let q = 0; q < 4; q++) for (let d = 0; d < 3; d++) r[d] += w[q] * pos[n[q] * 3 + d]; | |
| return r; | |
| } | |
| function splitTri(lay, t, px, py, nx, ny, ax, ay, bx, by) { | |
| // cut triangle t by the line of segment a-b, if the segment actually reaches into it | |
| const F = lay.F, o = t * 6, m = F.tm; | |
| const d = [0, 1, 2].map((v) => (m[o + v * 2] - px) * nx + (m[o + v * 2 + 1] - py) * ny); | |
| const eps = 1e-5; | |
| if ((d[0] > eps && d[1] > eps && d[2] > eps) || (d[0] < -eps && d[1] < -eps && d[2] < -eps)) return; | |
| // segment vs triangle overlap: an endpoint inside, or crossing an edge | |
| const V = [[m[o], m[o + 1]], [m[o + 2], m[o + 3]], [m[o + 4], m[o + 5]]]; | |
| const inside = (x, y) => { | |
| const s1 = (V[1][0] - V[0][0]) * (y - V[0][1]) - (V[1][1] - V[0][1]) * (x - V[0][0]); | |
| const s2 = (V[2][0] - V[1][0]) * (y - V[1][1]) - (V[2][1] - V[1][1]) * (x - V[1][0]); | |
| const s3 = (V[0][0] - V[2][0]) * (y - V[2][1]) - (V[0][1] - V[2][1]) * (x - V[2][0]); | |
| return (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0); | |
| }; | |
| if (!inside(ax, ay) && !inside(bx, by) && | |
| !segX(ax, ay, bx, by, V[0][0], V[0][1], V[1][0], V[1][1]) && | |
| !segX(ax, ay, bx, by, V[1][0], V[1][1], V[2][0], V[2][1]) && | |
| !segX(ax, ay, bx, by, V[2][0], V[2][1], V[0][0], V[0][1])) return; | |
| // lone vertex on one side | |
| const sg = d.map((x) => (x > eps ? 1 : x < -eps ? -1 : 0)); | |
| let lone = -1; | |
| for (let v = 0; v < 3; v++) if (sg[v] !== 0 && sg[v] !== sg[(v + 1) % 3] && sg[v] !== sg[(v + 2) % 3]) lone = v; | |
| if (lone < 0) return; // the line runs through a vertex: nothing to cut | |
| const Lv = V[lone], X = V[(lone + 1) % 3], Y = V[(lone + 2) % 3]; | |
| const dl = d[lone], dX = d[(lone + 1) % 3], dY = d[(lone + 2) % 3]; | |
| const k = F.kd[t], offs = [F.off[t * 3 + lone], F.off[t * 3 + (lone + 1) % 3], F.off[t * 3 + (lone + 2) % 3]]; | |
| if (k !== 0) return; // only faces get cut; strands and edge faces stay whole | |
| const lerp = (P0, P1, a, b) => { const tt = a / (a - b); return [P0[0] + (P1[0] - P0[0]) * tt, P0[1] + (P1[1] - P0[1]) * tt]; }; | |
| if (sg[(lone + 1) % 3] === 0 || sg[(lone + 2) % 3] === 0) { | |
| // the line passes through one other vertex: split in two | |
| const Z = sg[(lone + 1) % 3] === 0 ? Y : X; | |
| const I = sg[(lone + 1) % 3] === 0 ? lerp(Lv, Y, dl, dY) : lerp(Lv, X, dl, dX); | |
| if (sg[(lone + 1) % 3] === 0) { setTri(lay, t, Lv[0], Lv[1], X[0], X[1], I[0], I[1], 0); addTri(lay, I[0], I[1], X[0], X[1], Y[0], Y[1], 0); } | |
| else { setTri(lay, t, Lv[0], Lv[1], I[0], I[1], Y[0], Y[1], 0); addTri(lay, I[0], I[1], X[0], X[1], Y[0], Y[1], 0); } | |
| void Z; void offs; | |
| return; | |
| } | |
| const I1 = lerp(Lv, X, dl, dX), I2 = lerp(Lv, Y, dl, dY); | |
| setTri(lay, t, Lv[0], Lv[1], I1[0], I1[1], I2[0], I2[1], 0); | |
| addTri(lay, I1[0], I1[1], X[0], X[1], Y[0], Y[1], 0); | |
| addTri(lay, I1[0], I1[1], Y[0], Y[1], I2[0], I2[1], 0); | |
| } | |
| let segBudget = 0; | |
| function addSeg(lay, ax, ay, bx, by) { | |
| const s = lay.s; | |
| if (lay.nseg > 2400) return; // a sheet that has cracked this much is in pieces already | |
| if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); } | |
| if (!lay.seg) { lay.seg = new Float32Array(4096); lay.nseg = 0; lay.hash = []; } | |
| if (lay.nseg * 4 + 4 > lay.seg.length) { const g = new Float32Array(lay.seg.length * 2); g.set(lay.seg); lay.seg = g; } | |
| if (damage && lay.L === 0) damage.tearMM(s.m, ax, ay, bx, by); // ragged, fibrous lips along the cut | |
| const id = lay.nseg++, o = id * 4; | |
| lay.seg[o] = ax; lay.seg[o + 1] = ay; lay.seg[o + 2] = bx; lay.seg[o + 3] = by; | |
| hashCells(s, ax, ay, bx, by, 0, (c) => { (lay.hash[c] ||= []).push(id); }); | |
| // physics: every in-layer link the crack crosses breaks; the nodes on each side are free to part | |
| const L = lay.L; | |
| hashCells(s, ax, ay, bx, by, 1, (c, i, j) => { | |
| for (const n of [node(s, L, i, j), node(s, L, i, j + 1), node(s, L, i + 1, j), node(s, L, i + 1, j + 1)]) { | |
| for (let e = deg[n]; e < deg[n + 1]; e++) { | |
| const l = adj[e]; | |
| if (!alive[l]) continue; | |
| const p = ca[l], q = cb[l]; | |
| if (segX(ax, ay, bx, by, matX[p], matY[p], matX[q], matY[q])) { | |
| alive[l] = 0; | |
| } | |
| } | |
| } | |
| }); | |
| // render: cut the faces along the segment, then rebind everything nearby | |
| const F = lay.F; | |
| const len = Math.hypot(bx - ax, by - ay) || 1e-6; | |
| const nx = -(by - ay) / len, ny = (bx - ax) / len; | |
| const near = new Set(); | |
| hashCells(s, ax, ay, bx, by, 0, (c) => { for (const t of F.cellTris[c]) near.add(t); }); | |
| for (const t of near) splitTri(lay, t, ax, ay, nx, ny, ax, ay, bx, by); | |
| // dark torn edge faces on both sides, and fiber strands sticking out of the edge | |
| for (const sd of [1, -1]) { | |
| const rx = (ax + bx) / 2 + nx * sd * 0.06, ry = (ay + by) / 2 + ny * sd * 0.06; | |
| addTri(lay, ax, ay, bx, by, bx, by, 2, rx, ry, [0, 0, P.thickness]); | |
| addTri(lay, ax, ay, bx, by, ax, ay, 2, rx, ry, [0, P.thickness, P.thickness]); | |
| const nStr = Math.random() < len * 1.6 ? 1 + (Math.random() < 0.35 ? 1 : 0) : 0; | |
| for (let q = 0; q < nStr; q++) { | |
| const tt = Math.random(); | |
| const bxp = ax + (bx - ax) * tt, byp = ay + (by - ay) * tt; | |
| // fibers cross the gap along the layer's fiber direction when it cuts the edge, else straight out | |
| let fx = lay.fib[0], fy = lay.fib[1]; | |
| const dn = fx * nx + fy * ny; | |
| if (Math.abs(dn) < 0.3) { fx = nx; fy = ny; } | |
| const sgn = (fx * nx + fy * ny) * -sd > 0 ? 1 : -1; | |
| const ang = (Math.random() - 0.5) * 0.5; | |
| const ex = (fx * Math.cos(ang) - fy * Math.sin(ang)) * sgn, ey = (fx * Math.sin(ang) + fy * Math.cos(ang)) * sgn; | |
| const Lf = 0.25 + Math.random() * Math.random() * 1.3, wd = 0.05 + Math.random() * 0.09; | |
| const tx = (bx - ax) / len, ty = (by - ay) / len; | |
| addTri(lay, bxp - tx * wd, byp - ty * wd, bxp + tx * wd, byp + ty * wd, bxp + ex * Lf, byp + ey * Lf, 1, | |
| bxp + nx * sd * 0.08, byp + ny * sd * 0.08); | |
| } | |
| } | |
| hashCells(s, ax, ay, bx, by, 2, (c) => F.dirty.add(c)); | |
| // other layer may need its fine mesh rebound too (glue keeps it, nothing else to do) | |
| // carbon flakes and crumbs along the crack, in proportion to its length | |
| const nf = Math.floor(len * 0.9 + Math.random()); | |
| if (nf) { | |
| const w = worldAt(lay, (ax + bx) / 2, (ay + by) / 2); | |
| const k = node(s, L, Math.min(s.R - 1, Math.round(ay / s.ch)), Math.min(s.C - 1, Math.round(ax / s.cw))) * 3; | |
| spawnDebris(w[0], w[1], w[2], (pos[k] - prev[k]) * 60, (pos[k + 1] - prev[k + 1]) * 60, (pos[k + 2] - prev[k + 2]) * 60, nf); | |
| } | |
| breaks++; | |
| crackle(len); | |
| } | |
| function rebindDirty(lay) { | |
| const F = lay.F; | |
| if (!F || !F.dirty.size) return; | |
| const seen = new Set(); | |
| for (const c of F.dirty) for (const t of F.cellTris[c]) if (!seen.has(t)) { seen.add(t); bindTri(lay, t); } | |
| F.dirty.clear(); | |
| } | |
| // one propagation step: jagged, wandering, biased along the fibers of the layer | |
| function stepPoly(lay, tip, len) { | |
| const s = lay.s; | |
| let x1 = tip.x + tip.dx * len, y1 = tip.y + tip.dy * len; | |
| let dead = false; | |
| if (x1 < 0 || y1 < 0 || x1 > s.W || y1 > s.H) { | |
| x1 = Math.min(s.W, Math.max(0, x1)); y1 = Math.min(s.H, Math.max(0, y1)); dead = true; | |
| } | |
| const j = firstCrossing(lay, tip.x, tip.y, x1, y1); | |
| if (j) { x1 = j[0]; y1 = j[1]; dead = true; } | |
| // fractal edge: split the step in three with decreasing sideways noise | |
| const nx = -tip.dy, ny = tip.dx; | |
| const a1 = (Math.random() - 0.5) * len * 0.36, a2 = (Math.random() - 0.5) * len * 0.36; | |
| const pts = [[tip.x, tip.y], | |
| [tip.x + (x1 - tip.x) / 3 + nx * a1, tip.y + (y1 - tip.y) / 3 + ny * a1], | |
| [tip.x + (2 * (x1 - tip.x)) / 3 + nx * a2, tip.y + (2 * (y1 - tip.y)) / 3 + ny * a2], | |
| [x1, y1]]; | |
| if (dead) { pts[1] = [tip.x + (x1 - tip.x) / 3, tip.y + (y1 - tip.y) / 3]; pts[2] = [tip.x + (2 * (x1 - tip.x)) / 3, tip.y + (2 * (y1 - tip.y)) / 3]; } | |
| for (let q = 0; q < 3; q++) { | |
| const [ax, ay] = pts[q], [bx, by] = pts[q + 1]; | |
| // a little finer wiggle inside each third | |
| const mx = (ax + bx) / 2 + (Math.random() - 0.5) * 0.08, my = (ay + by) / 2 + (Math.random() - 0.5) * 0.08; | |
| if (!dead && (crossesCrack(lay, ax, ay, mx, my) || crossesCrack(lay, mx, my, bx, by))) { tip.dead = true; addSeg(lay, ax, ay, bx, by); return; } | |
| addSeg(lay, ax, ay, mx, my); | |
| addSeg(lay, mx, my, bx, by); | |
| } | |
| tip.x = x1; tip.y = y1; | |
| if (dead) tip.dead = true; | |
| } | |
| function stressAt(lay, x, y) { | |
| // links around the tip: the worst one decides whether the crack runs, all of them its direction | |
| const s = lay.s, L = lay.L; | |
| const ci = Math.floor(y / s.ch), cj = Math.floor(x / s.cw); | |
| let worst = 0, sxx = 0, sxy = 0, syy = 0; | |
| const r2 = (1.6 * Math.max(s.cw, s.ch)) ** 2; | |
| for (let i = ci - 1; i <= ci + 2; i++) for (let j = cj - 1; j <= cj + 2; j++) { | |
| if (i < 0 || j < 0 || i >= s.R || j >= s.C) continue; | |
| const n = node(s, L, i, j); | |
| for (let e = deg[n]; e < deg[n + 1]; e++) { | |
| const l = adj[e]; | |
| if (ca[l] !== n || !alive[l] || kind[l] > 2) continue; | |
| const b = cb[l]; | |
| const mx = (matX[n] + matX[b]) / 2 - x, my = (matY[n] + matY[b]) / 2 - y; | |
| if (mx * mx + my * my > r2) continue; | |
| const ao = n * 3, bo = b * 3; | |
| const dx = pos[bo] - pos[ao], dy = pos[bo + 1] - pos[ao + 1], dz = pos[bo + 2] - pos[ao + 2]; | |
| const st = Math.sqrt(dx * dx + dy * dy + dz * dz) / len0[l] - 1 - 0.9 * creep[l]; | |
| if (st <= 0) continue; | |
| worst = Math.max(worst, st / (thr[l] - 1)); | |
| const ux = matX[b] - matX[n], uy = matY[b] - matY[n], ul = Math.hypot(ux, uy); | |
| const wgt = st / (1 + (mx * mx + my * my) / r2); | |
| sxx += wgt * ux * ux / (ul * ul); sxy += wgt * ux * uy / (ul * ul); syy += wgt * uy * uy / (ul * ul); | |
| } | |
| } | |
| // principal tensile direction of the 2 x 2 tensor | |
| const ang = 0.5 * Math.atan2(2 * sxy, sxx - syy); | |
| return { worst, ex: Math.cos(ang), ey: Math.sin(ang) }; | |
| } | |
| function turn(lay, tip, ex, ey) { | |
| // crack runs perpendicular to the pull, keeps its heading, leans along the fibers, wanders | |
| let cx = -ey, cy = ex; | |
| if (cx * tip.dx + cy * tip.dy < 0) { cx = -cx; cy = -cy; } | |
| let fx = lay.fib[0], fy = lay.fib[1]; | |
| if (fx * tip.dx + fy * tip.dy < 0) { fx = -fx; fy = -fy; } | |
| const b = P.fiberBias; | |
| let dx = cx * (1 - b) + fx * b + tip.dx * 0.9, dy = cy * (1 - b) + fy * b + tip.dy * 0.9; | |
| const a = (Math.random() + Math.random() + Math.random() - 1.5) * P.turnJitter * 1.4; | |
| const l = Math.hypot(dx, dy) || 1; | |
| dx /= l; dy /= l; | |
| tip.dx = dx * Math.cos(a) - dy * Math.sin(a); tip.dy = dx * Math.sin(a) + dy * Math.cos(a); | |
| } | |
| function seedCrack(lay, x, y, dx, dy) { | |
| if (!lay.tips) lay.tips = []; | |
| if (lay.tips.length > 160) return; | |
| const l = Math.hypot(dx, dy) || 1; | |
| dx /= l; dy /= l; | |
| const a = { x, y, dx, dy }, b = { x, y, dx: -dx, dy: -dy }; | |
| lay.tips.push(a, b); | |
| // the seed itself: a short step each way, so it crosses the overloaded link | |
| stepPoly(lay, a, 0.5 + Math.random() * 0.4); | |
| stepPoly(lay, b, 0.5 + Math.random() * 0.4); | |
| } | |
| function nearTip(lay, x, y, r) { | |
| if (!lay.tips) return false; | |
| for (const t of lay.tips) if (!t.dead && (t.x - x) ** 2 + (t.y - y) ** 2 < r * r) return true; | |
| return false; | |
| } | |
| function growCracks() { | |
| segBudget = P.stepBudget; | |
| for (const s of sheets) { | |
| if (!s.cracked) continue; | |
| for (const lay of s.lay) { | |
| if (!lay.tips) continue; | |
| for (let q = 0; q < lay.tips.length && segBudget > 0; q++) { | |
| const tip = lay.tips[q]; | |
| for (let k = 0; k < P.stepsPerTip && !tip.dead && segBudget > 0; k++) { | |
| const st = stressAt(lay, tip.x, tip.y); | |
| if (st.worst < P.tipGrow) break; | |
| turn(lay, tip, st.ex, st.ey); | |
| stepPoly(lay, tip, 0.35 + Math.random() * 0.55); | |
| segBudget--; | |
| if (Math.random() < P.branch && lay.tips.length < 160) { | |
| const a = (Math.random() < 0.5 ? -1 : 1) * (0.45 + Math.random() * 0.45); | |
| lay.tips.push({ x: tip.x, y: tip.y, dx: tip.dx * Math.cos(a) - tip.dy * Math.sin(a), dy: tip.dx * Math.sin(a) + tip.dy * Math.cos(a) }); | |
| } | |
| } | |
| } | |
| lay.tips = lay.tips.filter((t) => !t.dead); | |
| } | |
| } | |
| } | |
| // ------------------------------------------------------------ debris: carbon flakes and dust | |
| const FL = P.maxFlakes, DU = P.maxDust; | |
| const fp = new Float32Array(FL * 3).fill(-1e5), fv = new Float32Array(FL * 3), fs = new Float32Array(FL), fph = new Float32Array(FL); | |
| let fHead = 0; | |
| const flakeGeo = new THREE.BufferGeometry(); | |
| flakeGeo.setAttribute('position', new THREE.BufferAttribute(fp, 3).setUsage(THREE.DynamicDrawUsage)); | |
| flakeGeo.setAttribute('aSize', new THREE.BufferAttribute(fs, 1).setUsage(THREE.DynamicDrawUsage)); | |
| flakeGeo.setAttribute('aPh', new THREE.BufferAttribute(fph, 1)); | |
| const flakes = new THREE.Points(flakeGeo, new THREE.ShaderMaterial({ | |
| uniforms: { uScale: { value: 1 } }, | |
| vertexShader: `attribute float aSize; attribute float aPh; varying float vPh; uniform float uScale; | |
| void main(){ vPh = aPh; vec4 mv = modelViewMatrix * vec4(position,1.0); gl_Position = projectionMatrix * mv; gl_PointSize = aSize * uScale / -mv.z; }`, | |
| fragmentShader: `varying float vPh; | |
| void main(){ vec2 d = gl_PointCoord - 0.5; float a = atan(d.y, d.x) * 3.0 + vPh * 6.28; | |
| float r = 0.36 + 0.12 * sin(a) + 0.06 * sin(a * 2.3); if (length(d) > r) discard; | |
| vec3 c = mix(vec3(0.06,0.045,0.032), vec3(0.22,0.16,0.11), fract(vPh * 7.3)); gl_FragColor = vec4(c, 1.0); }`, | |
| })); | |
| flakes.frustumCulled = false; | |
| group.add(flakes); | |
| const dp = new Float32Array(DU * 3).fill(-1e5), dv = new Float32Array(DU * 3), dl = new Float32Array(DU); | |
| let dHead = 0; | |
| const dustGeo = new THREE.BufferGeometry(); | |
| dustGeo.setAttribute('position', new THREE.BufferAttribute(dp, 3).setUsage(THREE.DynamicDrawUsage)); | |
| dustGeo.setAttribute('aLife', new THREE.BufferAttribute(dl, 1).setUsage(THREE.DynamicDrawUsage)); | |
| const dust = new THREE.Points(dustGeo, new THREE.ShaderMaterial({ | |
| uniforms: { uScale: { value: 1 } }, transparent: true, depthWrite: false, | |
| vertexShader: `attribute float aLife; varying float vL; uniform float uScale; | |
| void main(){ vL = aLife; vec4 mv = modelViewMatrix * vec4(position,1.0); gl_Position = projectionMatrix * mv; gl_PointSize = (1.0 + (1.0 - aLife) * 2.5) * 9.0 * uScale / -mv.z; }`, | |
| fragmentShader: `varying float vL; | |
| void main(){ float d = length(gl_PointCoord - 0.5) * 2.0; float a = smoothstep(1.0, 0.0, d) * vL * 0.55; if (a < 0.01) discard; | |
| gl_FragColor = vec4(vec3(0.34,0.29,0.24), a); }`, | |
| })); | |
| dust.frustumCulled = false; | |
| group.add(dust); | |
| let spawned = 0; | |
| function spawnDebris(x, y, z, vx, vy, vz, n) { | |
| for (let i = 0; i < n; i++) { | |
| const k = fHead++ % FL; | |
| fp[k * 3] = x + (Math.random() - 0.5) * cell; fp[k * 3 + 1] = y + (Math.random() - 0.5) * cell; fp[k * 3 + 2] = z + 0.5; | |
| fv[k * 3] = vx * 0.4 + (Math.random() - 0.5) * 160; fv[k * 3 + 1] = vy * 0.4 + Math.random() * 120; fv[k * 3 + 2] = vz * 0.4 + Math.random() * 140; | |
| fs[k] = 4 + Math.random() * Math.random() * 26; // mostly crumbs, a few flakes | |
| fph[k] = Math.random(); | |
| } | |
| if (Math.random() < 0.5) { | |
| const k = dHead++ % DU; | |
| dp[k * 3] = x; dp[k * 3 + 1] = y; dp[k * 3 + 2] = z + 1; | |
| dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 10 + Math.random() * 20; dv[k * 3 + 2] = 20 + Math.random() * 25; | |
| dl[k] = 1; | |
| } | |
| spawned += n; | |
| } | |
| function stepDebris(dt, time) { | |
| for (let k = 0; k < FL; k++) { | |
| const o = k * 3; | |
| if (fp[o + 1] < -1e4) continue; | |
| if (fp[o + 1] <= ground + 0.2) { // landed: shrink away (same hold and fade as everything else) | |
| fp[o + 1] = ground + 0.2; | |
| fs[k] *= Math.exp(-dt * 2.5); if (fs[k] < 0.6) fp[o + 1] = -1e5; | |
| continue; | |
| } | |
| // tiny and light: fall fast at first, then terminal velocity with a little flutter | |
| const drag = 2.5 + 40 / fs[k]; | |
| fv[o + 1] -= P.gravity * 0.35 * dt; | |
| fv[o] += Math.sin(time * 9 + fph[k] * 40) * 260 * dt * (8 / fs[k]); | |
| fv[o + 2] += Math.cos(time * 7 + fph[k] * 30) * 160 * dt * (8 / fs[k]); | |
| const f = Math.exp(-drag * dt); | |
| fv[o] *= f; fv[o + 1] *= f; fv[o + 2] *= f; | |
| fp[o] += fv[o] * dt; fp[o + 1] += fv[o + 1] * dt; fp[o + 2] += fv[o + 2] * dt; | |
| } | |
| for (let k = 0; k < DU; k++) { | |
| if (dl[k] <= 0) continue; | |
| dl[k] -= dt * 0.55; | |
| const o = k * 3, f = Math.exp(-1.8 * dt); | |
| dv[o] *= f; dv[o + 1] *= f; dv[o + 2] *= f; | |
| dp[o] += dv[o] * dt; dp[o + 1] += dv[o + 1] * dt; dp[o + 2] += dv[o + 2] * dt; | |
| if (dp[o + 1] < ground) { dp[o + 1] = ground; dv[o] = dv[o + 1] = dv[o + 2] = 0; } // never through the floor | |
| if (dl[k] <= 0) dp[o + 1] = -1e5; | |
| } | |
| flakeGeo.attributes.position.needsUpdate = true; | |
| flakeGeo.attributes.aSize.needsUpdate = true; | |
| dustGeo.attributes.position.needsUpdate = true; | |
| dustGeo.attributes.aLife.needsUpdate = true; | |
| } | |
| // ------------------------------------------------------------ sound: soft crackle, off by default | |
| let audio = null, soundOn = false, lastSnd = 0, pendingSnd = 0; | |
| function crackle(n) { | |
| if (!soundOn) return; | |
| pendingSnd += n; | |
| const now = performance.now(); | |
| if (now - lastSnd < 45) return; | |
| lastSnd = now; | |
| if (!audio) { | |
| audio = new AudioContext(); | |
| const len = Math.round(audio.sampleRate * 0.06); | |
| audio.buf = audio.createBuffer(1, len, audio.sampleRate); | |
| const d = audio.buf.getChannelData(0); | |
| for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 6) * (Math.random() < 0.3 ? 1 : 0.25); | |
| } | |
| const src = audio.createBufferSource(); | |
| src.buffer = audio.buf; | |
| src.playbackRate.value = 0.7 + Math.random() * 0.8; | |
| const bp = audio.createBiquadFilter(); | |
| bp.type = 'bandpass'; bp.frequency.value = 2500 + Math.random() * 2500; bp.Q.value = 0.9; | |
| const g = audio.createGain(); | |
| g.gain.value = Math.min(0.12, 0.025 + pendingSnd * 0.004); | |
| src.connect(bp).connect(g).connect(audio.destination); | |
| src.start(); | |
| pendingSnd = 0; | |
| } | |
| // ------------------------------------------------------------ simulation | |
| const active = new Uint8Array(sheets.length); | |
| const calm = new Uint16Array(sheets.length); // frames a sheet has been still: it sleeps after a second | |
| let grabbed = -1; | |
| const grabTarget = new THREE.Vector3(); | |
| const grabVel = new THREE.Vector3(); | |
| const hand = new THREE.Vector3(); | |
| let pinch = [], pinchOff = new Float32Array(0); | |
| let breaks = 0; | |
| const layOfNode = (k) => { const s = sheets[sheetOf[k]]; return s.lay[k >= s.base[1] ? 1 : 0]; }; | |
| function reset() { | |
| pos.set(rest); prev.set(rest); | |
| inv.fill(0); stuck.fill(1); | |
| for (let c = 0; c < NC; c++) alive[c] = 1; | |
| creep.fill(0); | |
| active.fill(0); | |
| fp.fill(-1e5); dp.fill(-1e5); dl.fill(0); | |
| grabbed = -1; pinch = []; controls.enabled = true; | |
| for (const s of sheets) { | |
| s.cracked = false; | |
| for (const lay of s.lay) { | |
| if (lay.F) { group.remove(lay.F.mesh); lay.F.geo.dispose(); lay.F = null; } | |
| lay.seg = null; lay.nseg = 0; lay.hash = null; lay.tips = null; | |
| lay.coarse.visible = true; | |
| lay.coarse.geometry.attributes.position.needsUpdate = true; | |
| lay.coarse.geometry.computeVertexNormals(); | |
| } | |
| } | |
| stepDebris(0, 0); | |
| valid.set(validRest); | |
| clive.fill(0); cCount = 0; crumbs.count = 0; | |
| } | |
| function unstick(k) { | |
| if (!stuck[k]) return; | |
| stuck[k] = 0; inv[k] = 1; | |
| active[sheetOf[k]] = 1; | |
| calm[sheetOf[k]] = 0; | |
| } | |
| function breakLink(c) { | |
| // glue and fuse links simply let go; in-layer links fail by starting a crack across them | |
| if (!alive[c]) return; | |
| const kd = kind[c]; | |
| if (kd >= 4) { alive[c] = 0; return; } | |
| const lay = layOfNode(ca[c]); | |
| const a = ca[c], b = cb[c]; | |
| let mx = (matX[a] + matX[b]) / 2, my = (matY[a] + matY[b]) / 2; | |
| let ux = matX[b] - matX[a], uy = matY[b] - matY[a]; | |
| if (kd === 3) { | |
| // a fold: crack across the bend near its middle node | |
| mx = matX[a] + ux * (0.35 + Math.random() * 0.3); my = matY[a] + uy * (0.35 + Math.random() * 0.3); | |
| } | |
| mx += (Math.random() - 0.5) * 0.3; my += (Math.random() - 0.5) * 0.3; | |
| seedCrack(lay, mx, my, -uy, ux); | |
| if (alive[c]) alive[c] = 0; // the seed missed it (it ran through a node): let it go anyway | |
| } | |
| function shatterAt(k, speed) { | |
| // impact: a small star of cracks from the point of contact | |
| const lay = layOfNode(k); | |
| const n = Math.min(3, 1 + Math.floor(speed / P.shatterSpeed)); | |
| for (let q = 0; q < n; q++) { | |
| if (Math.random() > 0.6) continue; | |
| const a = Math.random() * Math.PI * 2; | |
| seedCrack(lay, matX[k] + Math.cos(a) * 0.4, matY[k] + Math.sin(a) * 0.4, Math.cos(a + 1.3), Math.sin(a + 1.3)); | |
| } | |
| } | |
| function integrate(h, time) { | |
| const g = P.gravity * h * h, damp = Math.exp(-P.drag * h); | |
| for (let si = 0; si < sheets.length; si++) { | |
| if (!active[si]) continue; | |
| const s = sheets[si], k0 = s.base[0], k1 = s.base[1] + s.R * s.C; | |
| let vmax = 0; | |
| for (let k = k0; k < k1; k++) { | |
| if (!inv[k] || !valid[k]) continue; | |
| const o = k * 3; | |
| vmax = Math.max(vmax, Math.abs(pos[o] - prev[o]) + Math.abs(pos[o + 1] - prev[o + 1]) + Math.abs(pos[o + 2] - prev[o + 2])); | |
| const vx = (pos[o] - prev[o]) * damp, vy = (pos[o + 1] - prev[o + 1]) * damp, vz = (pos[o + 2] - prev[o + 2]) * damp; | |
| prev[o] = pos[o]; prev[o + 1] = pos[o + 1]; prev[o + 2] = pos[o + 2]; | |
| const fl = 0.00006 * Math.sin(time * 5 + k * 0.37); | |
| pos[o] += vx + fl; pos[o + 1] += vy - g; pos[o + 2] += vz + fl * 0.6; | |
| } | |
| s.vmax = Math.max(s.vmax || 0, vmax); | |
| } | |
| } | |
| const impacts = []; | |
| function collide(h) { | |
| for (let si = 0; si < sheets.length; si++) { | |
| if (!active[si]) continue; | |
| const s = sheets[si], k0 = s.base[0], k1 = s.base[1] + s.R * s.C; | |
| for (let k = k0; k < k1; k++) { | |
| if (!inv[k]) continue; | |
| const o = k * 3; | |
| if (pos[o + 2] < stripZ - 2) pos[o + 2] = stripZ - 2; | |
| if (pos[o + 1] < ground) { | |
| const vy = (pos[o + 1] - prev[o + 1]) / h; | |
| pos[o + 1] = ground; | |
| prev[o] = pos[o] - (pos[o] - prev[o]) * 0.3; | |
| prev[o + 2] = pos[o + 2] - (pos[o + 2] - prev[o + 2]) * 0.3; | |
| prev[o + 1] = ground; | |
| if (-vy > 1.5 * P.shatterSpeed && impacts.length < 4) impacts.push([k, -vy]); | |
| } | |
| } | |
| } | |
| } | |
| function solve() { | |
| const run = (c0, c1) => { | |
| for (let c = c0; c < c1; c++) { | |
| if (!alive[c]) continue; | |
| const a = ca[c], b = cb[c], wa = inv[a], wb = inv[b], ws = wa + wb; | |
| if (ws === 0) continue; | |
| const ao = a * 3, bo = b * 3; | |
| const dx = pos[bo] - pos[ao], dy = pos[bo + 1] - pos[ao + 1], dz = pos[bo + 2] - pos[ao + 2]; | |
| const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-9; | |
| const f = (d - len0[c]) / (d * ws); | |
| pos[ao] += dx * f * wa; pos[ao + 1] += dy * f * wa; pos[ao + 2] += dz * f * wa; | |
| pos[bo] -= dx * f * wb; pos[bo + 1] -= dy * f * wb; pos[bo + 2] -= dz * f * wb; | |
| } | |
| }; | |
| for (let si = 0; si < sheets.length; si++) if (active[si]) run(sheets[si].c0, sheets[si].c1); | |
| run(f0, NC); | |
| } | |
| function check() { | |
| let seeds = 0; | |
| const test = (c0, c1) => { | |
| for (let c = c0; c < c1; c++) { | |
| if (!alive[c]) continue; | |
| const a = ca[c], b = cb[c]; | |
| const kd = kind[c]; | |
| if (kd === 5 && !active[sheetOf[a]] && !active[sheetOf[b]]) continue; | |
| const ao = a * 3, bo = b * 3; | |
| const dx = pos[bo] - pos[ao], dy = pos[bo + 1] - pos[ao + 1], dz = pos[bo + 2] - pos[ao + 2]; | |
| const d = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (kd === 3) { | |
| if (d / len0[c] < thr[c] && seeds < 6) { | |
| const lay = layOfNode(a); | |
| if (!nearTip(lay, (matX[a] + matX[b]) / 2, (matY[a] + matY[b]) / 2, 2.5 * cell)) { breakLink(c); seeds++; } | |
| } | |
| continue; | |
| } | |
| if (kd === 4) { | |
| if (d - len0[c] > thr[c]) alive[c] = 0; // delamination | |
| else if (d - len0[c] > thr[c] * P.peelFrac) { if (stuck[a]) unstick(a); if (stuck[b]) unstick(b); } | |
| continue; | |
| } | |
| const st0 = d / len0[c] - 1; | |
| creep[c] += (Math.max(0, st0) - creep[c]) * 0.03; | |
| const r = 1 + st0 - 0.9 * creep[c]; | |
| const fail = thr[c] - 1; | |
| if (r - 1 > fail * P.peelFrac && (stuck[a] || stuck[b])) { | |
| if (Math.random() < P.peelProb) { if (stuck[a]) unstick(a); if (stuck[b]) unstick(b); } | |
| if (kd === 5) { if (!stuck[a]) unstick(b); if (!stuck[b]) unstick(a); } | |
| } | |
| if (r > thr[c]) { | |
| if (kd === 5) { alive[c] = 0; continue; } | |
| // a crack starts here unless a running crack tip is already close (it will get here) | |
| const lay = layOfNode(a); | |
| const near = nearTip(lay, (matX[a] + matX[b]) / 2, (matY[a] + matY[b]) / 2, 2.5 * cell); | |
| if ((!near || r - 1 > 3 * fail) && seeds < 6) { breakLink(c); seeds++; } | |
| } | |
| } | |
| }; | |
| for (let si = 0; si < sheets.length; si++) if (active[si]) test(sheets[si].c0, sheets[si].c1); | |
| test(f0, NC); | |
| } | |
| // ------------------------------------------------------------ loose bits | |
| // Cracks and crushing cut the sheet into pieces. A piece too small to read as papyrus (a few nodes, often a | |
| // jagged clump of overlapping triangles) becomes crumbs that fall, settle and fade like all other debris; a | |
| // small piece still glued in place by a stuck node lets go and falls, so nothing is left hanging in mid-air. | |
| const ISLAND_MIN = 48; // nodes in both layers together | |
| const compId = new Int32Array(N).fill(-1), queue = new Int32Array(N); | |
| let islandsRetired = 0, islandsReleased = 0; | |
| function sweepIslands(si, measure = false) { | |
| let floating = 0; | |
| const s = sheets[si], k0 = s.base[0], k1 = s.base[1] + s.R * s.C; | |
| let total = 0; | |
| for (let k = k0; k < k1; k++) { compId[k] = -1; if (valid[k]) total++; } | |
| let tag = 0; | |
| for (let k = k0; k < k1; k++) { | |
| if (!valid[k] || compId[k] >= 0) continue; | |
| let qh = 0, qt = 0, stuckN = 0, minY = Infinity; | |
| queue[qt++] = k; compId[k] = tag; | |
| while (qh < qt) { | |
| const n = queue[qh++]; | |
| if (stuck[n]) stuckN++; | |
| if (pos[n * 3 + 1] < minY) minY = pos[n * 3 + 1]; | |
| // in-layer structural and shear links, plus the glue to the other layer | |
| for (let e = deg[n]; e < deg[n + 1]; e++) { | |
| const c = adj[e]; if (!alive[c] || kind[c] > 2) continue; | |
| const m = ca[c] === n ? cb[c] : ca[c]; | |
| if (valid[m] && compId[m] < 0) { compId[m] = tag; queue[qt++] = m; } | |
| } | |
| const other = n < s.base[1] ? n + (s.base[1] - s.base[0]) : n - (s.base[1] - s.base[0]); | |
| if (valid[other] && compId[other] < 0) { | |
| const c = eid(n, other); | |
| if (c >= 0 && alive[c]) { compId[other] = tag; queue[qt++] = other; } | |
| } | |
| } | |
| tag++; | |
| if (!stuckN && minY > ground + 1) floating++; // a free piece entirely off the floor | |
| if (measure) continue; | |
| if (qt < ISLAND_MIN) { | |
| // too small: turn it into crumbs at its own position and speed | |
| let made = 0; | |
| for (let q = 0; q < qt; q++) { | |
| const n = queue[q], o = n * 3; | |
| if (made < 12 && q % 3 === 0) { | |
| const h = 1 / 60; | |
| addCrumb(pos[o], pos[o + 1], pos[o + 2] + 0.2, (pos[o] - prev[o]) / h * 0.02, (pos[o + 1] - prev[o + 1]) / h * 0.02, (pos[o + 2] - prev[o + 2]) / h * 0.02, | |
| 0.3 + Math.random() * 0.6); | |
| made++; | |
| } | |
| valid[n] = 0; inv[n] = 0; stuck[n] = 0; | |
| for (let e = deg[n]; e < deg[n + 1]; e++) alive[adj[e]] = 0; | |
| const c = eid(n, n < s.base[1] ? n + (s.base[1] - s.base[0]) : n - (s.base[1] - s.base[0])); | |
| if (c >= 0) alive[c] = 0; | |
| } | |
| islandsRetired++; active[si] = 1; calm[si] = 0; | |
| } else if (stuckN && qt < total * 0.12) { | |
| // a small piece held only by a few glued nodes: free it so it falls to the floor | |
| for (let q = 0; q < qt; q++) { const n = queue[q]; if (stuck[n]) { stuck[n] = 0; inv[n] = 1; } } | |
| active[si] = 1; calm[si] = 0; | |
| islandsReleased++; | |
| } | |
| } | |
| return floating; | |
| } | |
| // audit: free pieces entirely off the floor on sheets that are asleep (they would hang in mid-air) | |
| function hangingNodes() { | |
| let n = 0; | |
| for (let si = 0; si < sheets.length; si++) if (!active[si] && sheets[si].cracked) n += sweepIslands(si, true); | |
| return n; | |
| } | |
| let time = 0, frameNo = 0; | |
| let simMs = 0, lastMs = 0, prof = null; | |
| function step(dt) { | |
| const t0 = performance.now(); | |
| frameNo++; | |
| dt = Math.min(dt, 1 / 30); | |
| const h = dt / P.substeps; | |
| for (let s = 0; s < P.substeps; s++) { | |
| time += h; | |
| integrate(h, time); | |
| if (grabbed >= 0) { | |
| hand.lerp(grabTarget, 0.5); | |
| for (let n = 0; n < pinch.length; n++) { | |
| const o = pinch[n] * 3; | |
| prev[o] = pos[o]; prev[o + 1] = pos[o + 1]; prev[o + 2] = pos[o + 2]; | |
| pos[o] = hand.x + pinchOff[n * 3]; pos[o + 1] = hand.y + pinchOff[n * 3 + 1]; pos[o + 2] = hand.z + pinchOff[n * 3 + 2]; | |
| } | |
| } | |
| for (let i = 0; i < P.iters; i++) solve(); | |
| collide(h); | |
| } | |
| const tA = performance.now(); | |
| check(); | |
| const tB = performance.now(); | |
| for (const [k, v] of impacts) shatterAt(k, v); | |
| impacts.length = 0; | |
| growCracks(); | |
| const tC = performance.now(); | |
| // every half second, on sheets that have cracked: retire loose bits, free small held pieces | |
| if (frameNo % 30 === 0) for (let si = 0; si < sheets.length; si++) if (sheets[si].cracked && (active[si] || sheets[si].islandDirty)) { sweepIslands(si); sheets[si].islandDirty = false; } | |
| stepDebris(dt, time); | |
| stepCrumbs(dt, time); | |
| for (let si = 0; si < sheets.length; si++) { | |
| const s = sheets[si]; | |
| if (active[si]) { | |
| const still = (s.vmax || 0) < 0.004 && (grabbed < 0 || sheetOf[grabbed] !== si); | |
| calm[si] = still ? calm[si] + 1 : 0; | |
| s.vmax = 0; | |
| } | |
| const wake = active[si] || s.lay.some((l) => l.F && (l.F.dirty.size || l.F.attrsDirty)); | |
| if (!wake) continue; | |
| // sleep only when still and nothing free is left off the floor (a piece caught at the top of a bounce keeps falling) | |
| if (active[si] && calm[si] > 60) { const fl = sheets[si].cracked ? sweepIslands(si) : 0; if (calm[si] > 60 && !fl) { active[si] = 0; calm[si] = 0; } else if (fl) calm[si] = 0; } | |
| for (const lay of s.lay) { | |
| if (lay.F) { | |
| rebindDirty(lay); | |
| skin(lay); | |
| } else { | |
| const g = lay.coarse.geometry; | |
| g.attributes.position.needsUpdate = true; | |
| if ((frameNo + si) % 2 === 0 || !active[si]) g.computeVertexNormals(); | |
| } | |
| } | |
| } | |
| const sc = (innerHeight * 0.5) / Math.tan((camera.fov * Math.PI) / 360); | |
| flakes.material.uniforms.uScale.value = sc * 0.06; | |
| dust.material.uniforms.uScale.value = sc * 0.06; | |
| lastMs = performance.now() - t0; | |
| prof = { phys: +(tA - t0).toFixed(2), check: +(tB - tA).toFixed(2), grow: +(tC - tB).toFixed(2), render: +(performance.now() - tC).toFixed(2) }; | |
| simMs = simMs * 0.95 + (performance.now() - t0) * 0.05; | |
| } | |
| // ------------------------------------------------------------ crush: mash it into flakes, crumbs and dust | |
| // Carbonized papyrus does not tear like cloth under a press: it crumbles. A press removes a ragged disc of | |
| // sheet and turns it into GPU-instanced flakes; flakes that land hard break into smaller fragments, and the | |
| // smallest end as dust. Capped so phones keep their frame rate. | |
| const CMAX = MOBILE ? 450 : 1400; | |
| const cp = new Float32Array(CMAX * 3), cv = new Float32Array(CMAX * 3), csz = new Float32Array(CMAX); | |
| const crot = new Float32Array(CMAX * 3), cav = new Float32Array(CMAX * 3), clive = new Uint8Array(CMAX), crest = new Uint8Array(CMAX); | |
| let cCount = 0, crushOn = false, crushing = false, lastCrush = 0; | |
| // settle rule shared with the main view (damage.js): lie still for REST_HOLD s, then fade out over REST_FADE s | |
| const REST_HOLD = 1.0, REST_FADE = 1.2; | |
| const crestT = new Float32Array(CMAX), cStill = new Float32Array(CMAX); | |
| let crumbStillAirMax = 0; | |
| const flakeShape = new THREE.BufferGeometry(); | |
| { | |
| // an irregular, slightly bent flake: a fan of 7 triangles | |
| const v = [0, 0, 0]; | |
| const n = 7; | |
| for (let i = 0; i <= n; i++) { | |
| const a = (i / n) * Math.PI * 2, r = 0.5 * (0.65 + 0.35 * Math.sin(i * 2.7 + 1.3) * Math.sin(i * 1.3)); | |
| v.push(Math.cos(a) * r, Math.sin(a) * r * 0.75, (i % 2) * 0.05); | |
| } | |
| const idx = []; | |
| for (let i = 1; i <= n; i++) idx.push(0, i, i + 1); | |
| flakeShape.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); | |
| flakeShape.setIndex(idx); | |
| flakeShape.computeVertexNormals(); | |
| } | |
| const crumbMat = new THREE.ShaderMaterial({ | |
| side: THREE.DoubleSide, | |
| vertexShader: /* glsl */` | |
| varying vec3 vN; varying vec3 vW; varying float vTone; | |
| void main() { | |
| mat4 m = modelMatrix * instanceMatrix; | |
| vec4 w = m * vec4(position, 1.0); vW = w.xyz; | |
| vN = normalize(mat3(m) * normal); | |
| vTone = fract(sin(dot(instanceMatrix[3].xyz, vec3(12.9898, 78.233, 37.719))) * 43758.5453); | |
| gl_Position = projectionMatrix * viewMatrix * w; | |
| }`, | |
| fragmentShader: /* glsl */` | |
| varying vec3 vN; varying vec3 vW; varying float vTone; | |
| void main() { | |
| vec3 n = normalize(vN), v = normalize(cameraPosition - vW), l = normalize(vec3(-0.35, 0.75, 0.55)); | |
| n *= sign(dot(n, v)); | |
| float diff = 0.3 + 0.7 * max(dot(n, l), 0.0); | |
| // black carbon with a faint glassy sheen on fresh fracture faces | |
| float spec = pow(max(dot(reflect(-l, n), v), 0.0), 24.0) * 0.38; | |
| vec3 base = mix(vec3(0.035, 0.026, 0.019), vec3(0.17, 0.12, 0.085), vTone); | |
| diff += 0.25 * pow(max(1.0 - abs(dot(n, v)), 0.0), 2.0); // rim light so dark flakes read against the dark floor | |
| gl_FragColor = vec4(base * diff + spec, 1.0); | |
| }`, | |
| }); | |
| const crumbs = new THREE.InstancedMesh(flakeShape, crumbMat, CMAX); | |
| crumbs.instanceMatrix.setUsage(THREE.DynamicDrawUsage); | |
| crumbs.frustumCulled = false; | |
| crumbs.count = 0; | |
| group.add(crumbs); | |
| const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _e = new THREE.Euler(), _p = new THREE.Vector3(), _s = new THREE.Vector3(); | |
| function addCrumb(x, y, z, vx, vy, vz, size) { | |
| let k = -1; | |
| if (cCount < CMAX) k = cCount++; | |
| else { for (let i = 0; i < CMAX; i++) if (!clive[i]) { k = i; break; } } | |
| if (k < 0) k = Math.floor(Math.random() * CMAX); // full: recycle a random one (usually a settled crumb) | |
| clive[k] = 1; crest[k] = 0; crestT[k] = 0; cStill[k] = 0; | |
| cp[k * 3] = x; cp[k * 3 + 1] = y; cp[k * 3 + 2] = z; | |
| cv[k * 3] = vx; cv[k * 3 + 1] = vy; cv[k * 3 + 2] = vz; | |
| csz[k] = size; | |
| crot[k * 3] = Math.random() * 6.3; crot[k * 3 + 1] = Math.random() * 6.3; crot[k * 3 + 2] = Math.random() * 6.3; | |
| const spin = 4 + 10 / size; | |
| cav[k * 3] = (Math.random() - 0.5) * spin; cav[k * 3 + 1] = (Math.random() - 0.5) * spin; cav[k * 3 + 2] = (Math.random() - 0.5) * spin; | |
| } | |
| function stepCrumbs(dt, time) { | |
| if (!cCount) return; | |
| for (let k = 0; k < cCount; k++) { | |
| if (!clive[k]) { _m.makeScale(0, 0, 0); crumbs.setMatrixAt(k, _m); continue; } | |
| const o = k * 3, sz = csz[k]; | |
| if (!crest[k]) { | |
| // light and flat: quick fall, then terminal speed set by size, with a tumbling flutter | |
| cv[o + 1] -= P.gravity * 0.45 * dt; | |
| const drag = 1.5 + 2.2 / sz, f = Math.exp(-drag * dt); | |
| cv[o] = cv[o] * f + Math.sin(time * 7 + k) * 40 * dt / sz; | |
| cv[o + 1] *= f; | |
| cv[o + 2] = cv[o + 2] * f + Math.cos(time * 5 + k * 1.7) * 30 * dt / sz; | |
| cp[o] += cv[o] * dt; cp[o + 1] += cv[o + 1] * dt; cp[o + 2] += cv[o + 2] * dt; | |
| crot[o] += cav[o] * dt; crot[o + 1] += cav[o + 1] * dt; crot[o + 2] += cav[o + 2] * dt; | |
| if (cp[o + 1] < ground + sz * 0.1) { | |
| const vy = -cv[o + 1]; | |
| cp[o + 1] = ground + sz * 0.1; | |
| if (vy > 260 && sz > 0.45 && Math.random() < 0.7) { | |
| // hits the floor hard: breaks into smaller fragments, the finest go up as dust | |
| const n = 2 + Math.floor(Math.random() * 2); | |
| for (let q = 0; q < n; q++) addCrumb(cp[o], cp[o + 1] + 0.1, cp[o + 2], (Math.random() - 0.5) * 120, vy * 0.15 * Math.random(), (Math.random() - 0.5) * 120, sz * (0.35 + Math.random() * 0.25)); | |
| spawnDust(cp[o], cp[o + 1], cp[o + 2]); | |
| clive[k] = 0; | |
| continue; | |
| } | |
| if (sz < 0.3) { spawnDust(cp[o], cp[o + 1], cp[o + 2]); clive[k] = 0; continue; } | |
| // settle: lie flat, slide a little | |
| crest[k] = 1; | |
| crot[o] = Math.PI / 2 + (Math.random() - 0.5) * 0.3; crot[o + 1] = (Math.random() - 0.5) * 0.3; | |
| cv[o] *= 0.2; cv[o + 2] *= 0.2; | |
| } | |
| } else { | |
| if (Math.abs(cv[o]) + Math.abs(cv[o + 2]) > 1) { | |
| cp[o] += cv[o] * dt; cp[o + 2] += cv[o + 2] * dt; | |
| cv[o] *= Math.exp(-8 * dt); cv[o + 2] *= Math.exp(-8 * dt); | |
| } | |
| crestT[k] += dt; | |
| if (crestT[k] > REST_HOLD + REST_FADE) { clive[k] = 0; _m.makeScale(0, 0, 0); crumbs.setMatrixAt(k, _m); continue; } | |
| } | |
| // audit: airborne and not moving | |
| if (!crest[k]) { cStill[k] = Math.abs(cv[o]) + Math.abs(cv[o + 1]) + Math.abs(cv[o + 2]) < 0.05 ? cStill[k] + dt : 0; crumbStillAirMax = Math.max(crumbStillAirMax, cStill[k]); if (cStill[k] > 2.5) { clive[k] = 0; _m.makeScale(0, 0, 0); crumbs.setMatrixAt(k, _m); continue; } } | |
| const fadeC = crest[k] ? Math.min(1, Math.max(0, 1 - (crestT[k] - REST_HOLD) / REST_FADE)) : 1; | |
| _e.set(crot[o], crot[o + 1], crot[o + 2]); | |
| _q.setFromEuler(_e); | |
| _p.set(cp[o], cp[o + 1], cp[o + 2]); | |
| _s.set(sz * fadeC, sz * fadeC, sz * fadeC); | |
| _m.compose(_p, _q, _s); | |
| crumbs.setMatrixAt(k, _m); | |
| } | |
| crumbs.count = cCount; | |
| crumbs.instanceMatrix.needsUpdate = true; | |
| } | |
| function spawnDust(x, y, z) { | |
| if (damage) { damage.dust(x, y, z); return; } // size-dependent drag and drift, shared with the rolled view | |
| const k = dHead++ % DU; | |
| dp[k * 3] = x; dp[k * 3 + 1] = y; dp[k * 3 + 2] = z + 0.5; | |
| dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 8 + Math.random() * 20; dv[k * 3 + 2] = 10 + Math.random() * 25; | |
| dl[k] = 1; | |
| } | |
| function crushAt(lay, k0, point, rScale = 1) { | |
| const s = lay.s; | |
| if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); } | |
| const F = lay.F; | |
| const mx = matX[k0] + (Math.random() - 0.5) * s.cw * 0.6, my = matY[k0] + (Math.random() - 0.5) * s.ch * 0.6; | |
| // map the actual hit point into material space using the nearest node's displacement | |
| const hx = mx + (point.x - pos[k0 * 3]) * 0.9, hy = my - (point.y - pos[k0 * 3 + 1]) * 0.9; | |
| const R0 = (2.0 + Math.random() * 1.6) * rScale; | |
| const ph1 = Math.random() * 6.3, ph2 = Math.random() * 6.3; | |
| const rAt = (a) => R0 * (0.78 + 0.16 * Math.sin(3 * a + ph1) + 0.1 * Math.sin(7 * a + ph2) + 0.06 * Math.random()); | |
| // the visible hole is drawn by the damage mask in UV space: a smooth, fibrous edge with no triangle staircase. | |
| // physics stays separate: the nodes inside still let go below | |
| if (damage) damage.crushMM(s.m, hx, hy, R0 * 1.15); | |
| const removed = Math.round((Math.PI * R0 * R0) / (P.renderCell * P.renderCell * 0.5)); | |
| // physics: nodes inside are gone; their links let go, the rim is free to flake further | |
| for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) { | |
| const n = node(s, lay.L, i, j); | |
| if (!valid[n]) continue; | |
| const dx = matX[n] - hx, dy = matY[n] - hy; | |
| if (dx * dx + dy * dy > rAt(Math.atan2(dy, dx)) ** 2) continue; | |
| valid[n] = 0; inv[n] = 0; | |
| for (let e = deg[n]; e < deg[n + 1]; e++) alive[adj[e]] = 0; | |
| } | |
| hashCells(s, hx - R0, hy - R0, hx + R0, hy + R0, 2, (c) => F.dirty.add(c)); | |
| if (Math.random() < 0.5) { | |
| const a = Math.random() * 6.3; | |
| seedCrack(lay, hx + Math.cos(a) * R0, hy + Math.sin(a) * R0, Math.cos(a), Math.sin(a)); | |
| } | |
| // the removed area becomes flakes and crumbs: a few big, many small (roughly area conserving) | |
| const area = removed * P.renderCell * P.renderCell * 0.5; | |
| let left = area; | |
| const w = worldAt(lay, hx, hy); | |
| let made = 0; | |
| while (left > 0 && made < 40) { | |
| const sz = (Math.random() < 0.12 ? 0.8 + Math.random() * 1.0 : 0.2 + Math.random() * 0.45) * rScale; | |
| left -= sz * sz * 0.6; | |
| const a = Math.random() * 6.3, r = Math.random() * R0; | |
| addCrumb(w[0] + Math.cos(a) * r, w[1] + Math.sin(a) * r, w[2] + 0.3 + Math.random() * 0.4, | |
| // charcoal powders under a press: pieces barely spring back, they slump out and fall | |
| Math.cos(a) * (8 + Math.random() * 22), Math.random() * 12, 6 + Math.random() * 20, sz); | |
| made++; | |
| } | |
| for (let q = 0; q < 8; q++) spawnDust(w[0] + (Math.random() - 0.5) * R0, w[1] + (Math.random() - 0.5) * R0, w[2]); // mostly black dust | |
| active[sheetOf[k0]] = 1; calm[sheetOf[k0]] = 0; s.islandDirty = true; | |
| crunch(); | |
| breaks++; | |
| return made; | |
| } | |
| // crumbling sound: a short burst of fine crackles, only when Sound is on | |
| function crunch() { | |
| if (!soundOn) return; | |
| for (let i = 0; i < 4; i++) setTimeout(() => { lastSnd = 0; crackle(2 + Math.random() * 3); }, i * 18 + Math.random() * 12); | |
| } | |
| const pickables = group.children; | |
| // ------------------------------------------------------------ interaction | |
| const ray = new THREE.Raycaster(); | |
| const ndc = new THREE.Vector2(); | |
| const plane = new THREE.Plane(); | |
| let lastMove = null; | |
| function toNdc(e) { ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); } | |
| function onDown(e) { | |
| if (!group.visible || e.button > 0) return; | |
| toNdc(e); | |
| ray.setFromCamera(ndc, camera); | |
| const pk = pickables.filter((m) => m.isMesh && m.visible); | |
| for (const m of pk) m.geometry.computeBoundingSphere(); | |
| const hit = ray.intersectObjects(pk, false)[0]; | |
| if (!hit) return; | |
| const lay = hit.object.userData.lay, s = lay.s, L = lay.L; | |
| let best = -1, bd = Infinity; | |
| for (let k = lay.base; k < lay.base + s.R * s.C; k++) { | |
| if (!valid[k]) continue; | |
| const o = k * 3; | |
| const d = (pos[o] - hit.point.x) ** 2 + (pos[o + 1] - hit.point.y) ** 2 + (pos[o + 2] - hit.point.z) ** 2; | |
| if (d < bd) { bd = d; best = k; } | |
| } | |
| if (best < 0) return; | |
| if (crushOn) { | |
| crushing = true; lastCrush = performance.now(); | |
| crushAt(lay, best, hit.point); | |
| controls.enabled = false; | |
| try { canvas.setPointerCapture(e.pointerId); } catch {} // synthetic API pointers have no capture | |
| e.stopImmediatePropagation(); | |
| return; | |
| } | |
| // carbonized papyrus does not stretch or peel under fingers: it snaps and crumbles where it is grabbed | |
| crushAt(lay, best, hit.point, 0.55); | |
| for (let q = 0; q < 2; q++) { // and a snap runs off along the fibers of this layer | |
| const along = L === 0 ? [1, 0] : [0, 1], sg = q ? -1 : 1; | |
| seedCrack(lay, matX[best] + along[0] * sg * 1.2, matY[best] + along[1] * sg * 1.2, along[0] * sg, along[1] * sg); | |
| } | |
| if (!valid[best]) { // the grab point itself went to dust: hold the nearest piece that is left | |
| let nb = -1, nd = Infinity; | |
| for (let k = lay.base; k < lay.base + s.R * s.C; k++) { if (!valid[k]) continue; const d = (matX[k] - matX[best]) ** 2 + (matY[k] - matY[best]) ** 2; if (d < nd) { nd = d; nb = k; } } | |
| if (nb < 0) return; | |
| best = nb; | |
| } | |
| grabbed = best; | |
| active[sheetOf[best]] = 1; calm[sheetOf[best]] = 0; | |
| // pinch everything within about 1.6 nodes of the hit, on the layer that was touched | |
| pinch = []; const offs = []; | |
| // an irregular blob, longer along the layer's fibers, so the torn piece is not a neat disc | |
| const bo = best * 3, fib = lay.fib, ph = Math.random() * 6.3; | |
| for (let k = s.base[L]; k < s.base[L] + s.R * s.C; k++) { | |
| if (!valid[k]) continue; | |
| const o = k * 3, dx = pos[o] - pos[bo], dy = pos[o + 1] - pos[bo + 1], dz = pos[o + 2] - pos[bo + 2]; | |
| const along = Math.abs(dx * fib[0] - dy * fib[1]), across = Math.abs(dx * fib[1] + dy * fib[0]); | |
| const a = Math.atan2(dy, dx); | |
| const r = cell * (1.25 + 0.45 * Math.sin(2 * a + ph) + 0.3 * Math.sin(5 * a + ph * 2) + 0.35 * Math.random()); | |
| if ((along / 1.45) ** 2 + across ** 2 + dz * dz > r * r) continue; | |
| pinch.push(k); offs.push(dx, dy, dz); | |
| unstick(k); inv[k] = 0; // held by the pointer | |
| } | |
| pinchOff = Float32Array.from(offs); | |
| hand.set(pos[bo], pos[bo + 1], pos[bo + 2]); | |
| grabTarget.copy(hand); | |
| plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).negate(), hit.point); | |
| controls.enabled = false; | |
| try { canvas.setPointerCapture(e.pointerId); } catch {} // synthetic API pointers have no capture | |
| lastMove = { t: performance.now(), p: hit.point.clone() }; | |
| grabVel.set(0, 0, 0); | |
| e.stopImmediatePropagation(); | |
| } | |
| function onMove(e) { | |
| if (crushing) { | |
| // dragging while crushing grinds along the path, a few presses per second | |
| const now = performance.now(); | |
| if (now - lastCrush < 45) return; | |
| lastCrush = now; | |
| toNdc(e); | |
| ray.setFromCamera(ndc, camera); | |
| const pk = pickables.filter((m) => m.isMesh && m.visible && m !== crumbs); | |
| for (const m of pk) m.geometry.computeBoundingSphere(); | |
| const hit = ray.intersectObjects(pk, false)[0]; | |
| if (!hit) return; | |
| const lay = hit.object.userData.lay, s = lay.s; | |
| let best = -1, bd = Infinity; | |
| for (let k = lay.base; k < lay.base + s.R * s.C; k++) { | |
| const o = k * 3; | |
| const d = (pos[o] - hit.point.x) ** 2 + (pos[o + 1] - hit.point.y) ** 2 + (pos[o + 2] - hit.point.z) ** 2; | |
| if (d < bd) { bd = d; best = k; } | |
| } | |
| if (best >= 0) crushAt(lay, best, hit.point); | |
| return; | |
| } | |
| if (grabbed < 0) return; | |
| toNdc(e); | |
| ray.setFromCamera(ndc, camera); | |
| const p = new THREE.Vector3(); | |
| if (!ray.ray.intersectPlane(plane, p)) return; | |
| // keep the pull in front of the backing | |
| p.z = Math.max(p.z, stripZ - 1); | |
| const now = performance.now(); | |
| const dtm = Math.max(1, now - lastMove.t) / 1000; | |
| grabVel.lerp(p.clone().sub(lastMove.p).divideScalar(dtm), 0.5); | |
| lastMove = { t: now, p: p.clone() }; | |
| grabTarget.copy(p); | |
| } | |
| function onUp() { | |
| if (crushing) { crushing = false; controls.enabled = true; return; } | |
| if (grabbed < 0) return; | |
| // a fling: hand the pointer velocity to the pinched patch (capped), the links decide what rips | |
| const v = grabVel.clampLength(0, 4000); | |
| const h = 1 / 60 / P.substeps; | |
| for (const k of pinch) { | |
| const o = k * 3; | |
| inv[k] = 1; | |
| prev[o] = pos[o] - v.x * h; prev[o + 1] = pos[o + 1] - v.y * h; prev[o + 2] = pos[o + 2] - v.z * h; | |
| } | |
| pinch = []; | |
| grabbed = -1; | |
| controls.enabled = true; | |
| } | |
| canvas.addEventListener('pointerdown', onDown, true); | |
| canvas.addEventListener('pointermove', onMove); | |
| canvas.addEventListener('pointerup', onUp); | |
| canvas.addEventListener('pointercancel', onUp); | |
| function shake() { | |
| // a knock on the table: a random set of nodes lets go and gets a kick | |
| // only the sheets in view get knocked: keeps the frame rate up and the effect where you are looking | |
| const cx = controls.target.x; | |
| const near = sheets.map((s, si) => [Math.abs(s.x0 + s.W / 2 - cx), si]).sort((a, b) => a[0] - b[0]).slice(0, 4).map((x) => x[1]); | |
| for (const si of near) { | |
| const s = sheets[si]; | |
| const kick = 250 + Math.random() * 500; | |
| for (let k = s.base[0]; k < s.base[1] + s.R * s.C; k++) { | |
| if (!valid[k] || Math.random() > 0.05) continue; | |
| unstick(k); | |
| const o = k * 3, hh = 1 / 60 / P.substeps; | |
| prev[o] = pos[o] - (Math.random() - 0.5) * kick * hh; | |
| prev[o + 1] = pos[o + 1] - Math.random() * kick * 0.5 * hh; | |
| prev[o + 2] = pos[o + 2] - Math.random() * kick * hh; | |
| } | |
| } | |
| } | |
| reset(); | |
| return { | |
| group, step, reset, shake, | |
| setCrush(v) { crushOn = v; }, | |
| setSound(v) { soundOn = v; if (v && audio && audio.state === 'suspended') audio.resume(); }, | |
| stats: () => ({ crumbs: clive.reduce((a, b) => a + b, 0), crumbStillAirMax: +crumbStillAirMax.toFixed(2), islandsRetired, islandsReleased, hanging: hangingNodes(), nodes: N, links: NC, activeSheets: active.reduce((a, b) => a + b, 0), simMs: +simMs.toFixed(2), lastMs: +lastMs.toFixed(2), prof, breaks, debris: spawned }), | |
| isGrabbing: () => grabbed >= 0, | |
| }; | |
| } | |