oddlyalive 0.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +158 -0
  5. package/SECURITY.md +29 -0
  6. package/assets/photoreal/PROVENANCE.md +19 -0
  7. package/assets/photoreal/baseball.png +0 -0
  8. package/assets/photoreal/basketball.png +0 -0
  9. package/assets/photoreal/crystal.png +0 -0
  10. package/assets/photoreal/kicking-cleat.png +0 -0
  11. package/assets/photoreal/letter-charm-square.png +0 -0
  12. package/assets/photoreal/letter-charm.png +0 -0
  13. package/assets/photoreal/sneaker.png +0 -0
  14. package/assets/photoreal/soccer-ball.png +0 -0
  15. package/bin/oddlyalive.js +283 -0
  16. package/docs/ARCHITECTURE.md +55 -0
  17. package/docs/DEMO-VIDEOS.md +122 -0
  18. package/docs/QUICKSTART.md +115 -0
  19. package/docs/ROADMAP.md +33 -0
  20. package/examples/ball-lab/PROVENANCE.md +4 -0
  21. package/examples/ball-lab/app.js +33 -0
  22. package/examples/ball-lab/index.html +56 -0
  23. package/examples/ball-lab/scene.json +68 -0
  24. package/examples/crystal-mobile/PROVENANCE.md +4 -0
  25. package/examples/crystal-mobile/app.js +26 -0
  26. package/examples/crystal-mobile/index.html +56 -0
  27. package/examples/crystal-mobile/scene.json +69 -0
  28. package/examples/football-kick/PROVENANCE.md +4 -0
  29. package/examples/football-kick/app.js +33 -0
  30. package/examples/football-kick/index.html +56 -0
  31. package/examples/football-kick/scene.json +44 -0
  32. package/examples/gallery.css +379 -0
  33. package/examples/index.html +109 -0
  34. package/examples/shared/example.css +320 -0
  35. package/examples/shared/player.js +85 -0
  36. package/examples/shoe-splash/PROVENANCE.md +4 -0
  37. package/examples/shoe-splash/app.js +26 -0
  38. package/examples/shoe-splash/index.html +56 -0
  39. package/examples/shoe-splash/scene.json +38 -0
  40. package/examples/string-touch/PROVENANCE.md +11 -0
  41. package/examples/string-touch/app.js +79 -0
  42. package/examples/string-touch/index.html +70 -0
  43. package/examples/string-touch/scene.json +73 -0
  44. package/examples/string-touch/styles.css +273 -0
  45. package/package.json +82 -0
  46. package/schemas/scene.schema.json +336 -0
  47. package/scripts/render-demo-videos.sh +77 -0
  48. package/scripts/serve.js +88 -0
  49. package/scripts/update-visual-fixtures.mjs +60 -0
  50. package/scripts/verify-demo-videos.mjs +138 -0
  51. package/skills/oddlyalive/SKILL.md +68 -0
  52. package/skills/oddlyalive/agents/openai.yaml +4 -0
  53. package/skills/oddlyalive/references/object-models.md +25 -0
  54. package/skills/oddlyalive/references/recipes.md +27 -0
  55. package/skills/oddlyalive/references/scene-schema.md +84 -0
  56. package/src/crystal-renderer.js +338 -0
  57. package/src/gesture.js +58 -0
  58. package/src/index.js +44 -0
  59. package/src/rigid-balls.js +304 -0
  60. package/src/rigid-renderer.js +760 -0
  61. package/src/rigid-scene.js +223 -0
  62. package/src/scene-registry.js +17 -0
  63. package/src/scene.js +206 -0
  64. package/src/state-hash.js +17 -0
  65. package/src/strands.js +773 -0
  66. package/src/surface-wave.js +209 -0
  67. package/src/svg-renderer.js +348 -0
  68. package/src/wave-renderer.js +463 -0
  69. package/src/wave-scene.js +163 -0
  70. package/tests/cli.test.js +101 -0
  71. package/tests/determinism.test.js +57 -0
  72. package/tests/fixtures/visual-baselines.json +27 -0
  73. package/tests/rigid-balls.test.js +77 -0
  74. package/tests/scene.test.js +78 -0
  75. package/tests/surface-wave.test.js +39 -0
  76. package/tests/visual-fixtures.test.js +43 -0
  77. package/videos/oddlyalive-demos/BRIEF.md +40 -0
  78. package/videos/oddlyalive-demos/DEMO-PLAN.md +13 -0
  79. package/videos/oddlyalive-demos/README.md +45 -0
  80. package/videos/oddlyalive-demos/app.js +212 -0
  81. package/videos/oddlyalive-demos/hyperframes.json +9 -0
  82. package/videos/oddlyalive-demos/index.html +246 -0
  83. package/videos/oddlyalive-demos/meta.json +5 -0
  84. package/videos/oddlyalive-demos/package-lock.json +506 -0
  85. package/videos/oddlyalive-demos/package.json +19 -0
  86. package/videos/oddlyalive-demos/prepare-assets.mjs +16 -0
package/src/strands.js ADDED
@@ -0,0 +1,773 @@
1
+ import { sampleGesture, smoothStep } from "./gesture.js";
2
+ import { validateScene } from "./scene.js";
3
+
4
+ export const ContactState = Object.freeze({
5
+ FREE: 0,
6
+ STICK: 1,
7
+ SLIP: 2,
8
+ COOLDOWN: 3
9
+ });
10
+
11
+ function hash01(value, seed) {
12
+ const wave = Math.sin(value * 12.9898 + seed * 78.233) * 43758.5453123;
13
+ return wave - Math.floor(wave);
14
+ }
15
+
16
+ function createHasher() {
17
+ let hash = 0x811c9dc5;
18
+ return {
19
+ add(value) {
20
+ const rounded = Math.round(value * 1000);
21
+ hash ^= rounded & 0xff;
22
+ hash = Math.imul(hash, 0x01000193);
23
+ hash ^= (rounded >>> 8) & 0xff;
24
+ hash = Math.imul(hash, 0x01000193);
25
+ hash ^= (rounded >>> 16) & 0xff;
26
+ hash = Math.imul(hash, 0x01000193);
27
+ },
28
+ digest() {
29
+ return (hash >>> 0).toString(16).padStart(8, "0");
30
+ }
31
+ };
32
+ }
33
+
34
+ export function simulateStrandField(inputScene, options = {}) {
35
+ const scene = validateScene(inputScene);
36
+ const { columns, rows, originX, originY, spacingX, spacingY } = scene.field;
37
+ const { duration, fps, substeps, preRoll } = scene.timing;
38
+ const count = columns * rows;
39
+ const frameCount = Math.round(duration * fps) + 1;
40
+ const constraintPasses = options.constraintPasses ?? 5;
41
+ const dt = 1 / (fps * substeps);
42
+ const dtSquared = dt * dt;
43
+ const seed = scene.seed;
44
+
45
+ const positionsX = new Float64Array(count);
46
+ const positionsY = new Float64Array(count);
47
+ const velocitiesX = new Float64Array(count);
48
+ const velocitiesY = new Float64Array(count);
49
+ const previousX = new Float64Array(count);
50
+ const previousY = new Float64Array(count);
51
+ const baseX = new Float64Array(count);
52
+ const baseY = new Float64Array(count);
53
+ const inverseMass = new Float64Array(count);
54
+ const rotation = new Float64Array(count);
55
+ const rotationVelocity = new Float64Array(count);
56
+ const anchorX = new Float64Array(columns);
57
+ const anchorY = new Float64Array(columns);
58
+ const anchorRest = new Float64Array(columns);
59
+ const linkRest = new Float64Array(count);
60
+ const bendRest = new Float64Array(count);
61
+ const linkLambda = new Float64Array(count);
62
+ const bendLambda = new Float64Array(count);
63
+ const anchorLambda = new Float64Array(columns);
64
+
65
+ const lengthCompliance = new Float64Array(columns);
66
+ const compressionCompliance = new Float64Array(columns);
67
+ const bendCompliance = new Float64Array(columns);
68
+ const drag = new Float64Array(columns);
69
+ const staticLimit = new Float64Array(columns);
70
+ const stressBudget = new Float64Array(columns);
71
+ const stickGain = new Float64Array(columns);
72
+ const stickCap = new Float64Array(columns);
73
+ const kineticGain = new Float64Array(columns);
74
+ const kineticCap = new Float64Array(columns);
75
+ const slipBudget = new Float64Array(columns);
76
+ const captureQ = new Float64Array(columns);
77
+ const contactRadiusX = new Float64Array(columns);
78
+ const contactRadiusY = new Float64Array(columns);
79
+ const contactLane = new Float64Array(columns);
80
+ const rotationGain = new Float64Array(columns);
81
+
82
+ const contactState = new Uint8Array(columns);
83
+ const contactRow = new Int16Array(columns);
84
+ const contactOffsetX = new Float64Array(columns);
85
+ const contactOffsetY = new Float64Array(columns);
86
+ const contactReaction = new Float64Array(columns);
87
+ const contactStress = new Float64Array(columns);
88
+ const contactSlipTravel = new Float64Array(columns);
89
+ const contactAge = new Float64Array(columns);
90
+ const contactBridge = new Float64Array(columns);
91
+ const candidateRow = new Int16Array(columns);
92
+ const candidateQ = new Float64Array(columns);
93
+ const frames = options.includeFrames === false ? null : new Array(frameCount);
94
+ const diagnostics = {
95
+ sticking: new Uint8Array(frameCount),
96
+ slipping: new Uint8Array(frameCount),
97
+ free: new Uint8Array(frameCount),
98
+ kineticEnergy: new Float32Array(frameCount),
99
+ maxStretch: new Float32Array(frameCount),
100
+ firstGripTime: null,
101
+ lastGripTime: null,
102
+ observedMaxStaticGrip: 0,
103
+ observedMaxCombinedContact: 0,
104
+ observedMaxStretch: 1
105
+ };
106
+ const hasher = createHasher();
107
+ let previousForce = sampleGesture(scene.gesture.points, -2);
108
+
109
+ function correlatedNoise(column, salt) {
110
+ const left = hash01((column - 1) * 19.19 + salt, seed);
111
+ const center = hash01(column * 19.19 + salt, seed);
112
+ const right = hash01((column + 1) * 19.19 + salt, seed);
113
+ return left * 0.22 + center * 0.56 + right * 0.22;
114
+ }
115
+
116
+ for (let column = 0; column < columns; column += 1) {
117
+ const curve = Math.sin((column / Math.max(1, columns - 1)) * Math.PI) * 2.4;
118
+ anchorX[column] = originX + column * spacingX;
119
+ anchorY[column] = originY - spacingY * 0.68 + curve;
120
+ }
121
+
122
+ for (let index = 0; index < count; index += 1) {
123
+ const column = index % columns;
124
+ const row = (index - column) / columns;
125
+ baseX[index] =
126
+ originX +
127
+ column * spacingX +
128
+ (correlatedNoise(column, 17 + row * 0.13) - 0.5) * 0.55;
129
+ baseY[index] = originY + row * spacingY;
130
+ positionsX[index] = baseX[index];
131
+ positionsY[index] = baseY[index];
132
+ const terminal = smoothStep((row - (rows - 5)) / 4);
133
+ const terminalMass = scene.payload.terminalMass ?? 1;
134
+ inverseMass[index] =
135
+ 1 /
136
+ (1 +
137
+ terminal *
138
+ (0.22 + correlatedNoise(column, 181) * 0.24) *
139
+ terminalMass);
140
+ }
141
+
142
+ for (let column = 0; column < columns; column += 1) {
143
+ const rough = correlatedNoise(column, 41);
144
+ const soft = correlatedNoise(column, 73);
145
+ const stressNoise = correlatedNoise(column, 211);
146
+ const edgeRelease = smoothStep((column - (columns - 5)) / 4);
147
+ lengthCompliance[column] =
148
+ scene.material.lengthCompliance * (1 + soft * 0.72);
149
+ compressionCompliance[column] =
150
+ scene.material.compressionCompliance * (1 + soft * 0.46);
151
+ bendCompliance[column] =
152
+ scene.material.bendCompliance *
153
+ (1 + soft * 0.42) *
154
+ (1 - edgeRelease * 0.55);
155
+ drag[column] =
156
+ scene.material.linearDrag * (0.92 + rough * 0.16);
157
+ staticLimit[column] = (4.6 + rough * 2.1) * (1 - edgeRelease * 0.28);
158
+ stressBudget[column] =
159
+ (0.13 + stressNoise * 0.11) * (1 - edgeRelease * 0.5);
160
+ stickGain[column] = (0.33 + soft * 0.105) * (1 - edgeRelease * 0.1);
161
+ stickCap[column] = (1.92 + soft * 0.64) * (1 - edgeRelease * 0.18);
162
+ kineticGain[column] = 0.13 + rough * 0.11;
163
+ kineticCap[column] = 0.38 + rough * 0.25;
164
+ slipBudget[column] = (10 + rough * 8) * (1 - edgeRelease * 0.35);
165
+ captureQ[column] = (0.91 + rough * 0.055) * (1 - edgeRelease * 0.38);
166
+ contactRadiusX[column] = scene.contact.radiusX + soft * 6;
167
+ contactRadiusY[column] = scene.contact.radiusY + rough * 6;
168
+ contactLane[column] = (correlatedNoise(column, 109) - 0.5) * 28;
169
+ rotationGain[column] = 0.11 + soft * 0.05;
170
+ const dx = baseX[column] - anchorX[column];
171
+ const dy = baseY[column] - anchorY[column];
172
+ anchorRest[column] =
173
+ Math.sqrt(dx * dx + dy * dy) * (1.004 + rough * 0.004);
174
+ }
175
+
176
+ for (let index = 0; index < count; index += 1) {
177
+ const column = index % columns;
178
+ const row = (index - column) / columns;
179
+ if (row > 0) {
180
+ const above = index - columns;
181
+ const dx = baseX[index] - baseX[above];
182
+ const dy = baseY[index] - baseY[above];
183
+ const lowerSlack = smoothStep((row - rows * 0.5) / (rows * 0.5));
184
+ const edgeSlack = smoothStep((column - (columns - 5)) / 4);
185
+ const slack =
186
+ 1.026 +
187
+ lowerSlack * 0.022 +
188
+ (correlatedNoise(column, 127) - 0.5) * 0.006 -
189
+ edgeSlack * (0.008 + lowerSlack * 0.012);
190
+ linkRest[index] = Math.sqrt(dx * dx + dy * dy) * slack;
191
+ }
192
+ if (row > 1) {
193
+ const twoAbove = index - columns * 2;
194
+ const dx = baseX[index] - baseX[twoAbove];
195
+ const dy = baseY[index] - baseY[twoAbove];
196
+ bendRest[index] = Math.sqrt(dx * dx + dy * dy) * 1.018;
197
+ }
198
+ }
199
+
200
+ function solvePair(
201
+ a,
202
+ b,
203
+ restDistance,
204
+ extensionCompliance,
205
+ compressedCompliance,
206
+ lambdaStore,
207
+ lambdaIndex,
208
+ maxRatio
209
+ ) {
210
+ let dx = positionsX[b] - positionsX[a];
211
+ let dy = positionsY[b] - positionsY[a];
212
+ let distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
213
+ const constraint = distance - restDistance;
214
+ const compliance =
215
+ constraint >= 0 ? extensionCompliance : compressedCompliance;
216
+ const alpha = compliance / dtSquared;
217
+ const weightA = inverseMass[a];
218
+ const weightB = inverseMass[b];
219
+ const deltaLambda =
220
+ (-constraint - alpha * lambdaStore[lambdaIndex]) /
221
+ (weightA + weightB + alpha);
222
+ lambdaStore[lambdaIndex] += deltaLambda;
223
+ const nx = dx / distance;
224
+ const ny = dy / distance;
225
+ positionsX[a] -= nx * weightA * deltaLambda;
226
+ positionsY[a] -= ny * weightA * deltaLambda;
227
+ positionsX[b] += nx * weightB * deltaLambda;
228
+ positionsY[b] += ny * weightB * deltaLambda;
229
+
230
+ const capped = restDistance * maxRatio;
231
+ dx = positionsX[b] - positionsX[a];
232
+ dy = positionsY[b] - positionsY[a];
233
+ distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
234
+ if (distance <= capped) return;
235
+ const excess = distance - capped;
236
+ const totalWeight = weightA + weightB;
237
+ positionsX[a] += (dx / distance) * excess * (weightA / totalWeight);
238
+ positionsY[a] += (dy / distance) * excess * (weightA / totalWeight);
239
+ positionsX[b] -= (dx / distance) * excess * (weightB / totalWeight);
240
+ positionsY[b] -= (dy / distance) * excess * (weightB / totalWeight);
241
+ }
242
+
243
+ function solveAnchor(column) {
244
+ let dx = positionsX[column] - anchorX[column];
245
+ let dy = positionsY[column] - anchorY[column];
246
+ let distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
247
+ const restDistance = anchorRest[column];
248
+ const constraint = distance - restDistance;
249
+ const compliance = constraint >= 0 ? 0.0000007 : 0.00018;
250
+ const alpha = compliance / dtSquared;
251
+ const weight = inverseMass[column];
252
+ const deltaLambda =
253
+ (-constraint - alpha * anchorLambda[column]) / (weight + alpha);
254
+ anchorLambda[column] += deltaLambda;
255
+ positionsX[column] += (dx / distance) * weight * deltaLambda;
256
+ positionsY[column] += (dy / distance) * weight * deltaLambda;
257
+
258
+ dx = positionsX[column] - anchorX[column];
259
+ dy = positionsY[column] - anchorY[column];
260
+ distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
261
+ const capped = restDistance * 1.035;
262
+ if (distance > capped) {
263
+ positionsX[column] -= (dx / distance) * (distance - capped);
264
+ positionsY[column] -= (dy / distance) * (distance - capped);
265
+ }
266
+ }
267
+
268
+ function enforceHardLink(a, b, restDistance, maxRatio) {
269
+ const dx = positionsX[b] - positionsX[a];
270
+ const dy = positionsY[b] - positionsY[a];
271
+ const distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
272
+ const capped = restDistance * maxRatio;
273
+ if (distance <= capped) return;
274
+ const weightA = inverseMass[a];
275
+ const weightB = inverseMass[b];
276
+ const totalWeight = weightA + weightB;
277
+ const excess = distance - capped;
278
+ positionsX[a] += (dx / distance) * excess * (weightA / totalWeight);
279
+ positionsY[a] += (dy / distance) * excess * (weightA / totalWeight);
280
+ positionsX[b] -= (dx / distance) * excess * (weightB / totalWeight);
281
+ positionsY[b] -= (dy / distance) * excess * (weightB / totalWeight);
282
+ }
283
+
284
+ function enforceHardAnchor(column) {
285
+ const dx = positionsX[column] - anchorX[column];
286
+ const dy = positionsY[column] - anchorY[column];
287
+ const distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
288
+ const capped = anchorRest[column] * 1.035;
289
+ if (distance > capped) {
290
+ positionsX[column] -= (dx / distance) * (distance - capped);
291
+ positionsY[column] -= (dy / distance) * (distance - capped);
292
+ }
293
+ }
294
+
295
+ function findContactCandidates(force) {
296
+ const start = Math.min(rows - 1, scene.contact.captureStartRow);
297
+ const end = Math.min(rows - 1, scene.contact.captureEndRow);
298
+ for (let column = 0; column < columns; column += 1) {
299
+ let bestQ = Number.POSITIVE_INFINITY;
300
+ let bestRow = start;
301
+ for (let row = start; row <= end; row += 1) {
302
+ const index = row * columns + column;
303
+ const nx = (positionsX[index] - force.x) / contactRadiusX[column];
304
+ const ny = (positionsY[index] - force.y) / contactRadiusY[column];
305
+ const q = Math.sqrt(nx * nx + ny * ny);
306
+ if (q < bestQ) {
307
+ bestQ = q;
308
+ bestRow = row;
309
+ }
310
+ }
311
+ candidateRow[column] = bestRow;
312
+ candidateQ[column] = bestQ;
313
+ }
314
+ }
315
+
316
+ function releaseToSlip(column) {
317
+ contactState[column] = ContactState.SLIP;
318
+ contactSlipTravel[column] = 0;
319
+ contactReaction[column] = 0;
320
+ contactStress[column] = 0;
321
+ contactBridge[column] = 1;
322
+ }
323
+
324
+ function updateContacts(force, handDeltaX, handDeltaY, handSpeed) {
325
+ findContactCandidates(force);
326
+ let stickingCount = 0;
327
+ for (let column = 0; column < columns; column += 1) {
328
+ if (contactState[column] === ContactState.STICK) stickingCount += 1;
329
+ }
330
+
331
+ for (let column = 0; column < columns; column += 1) {
332
+ let state = contactState[column];
333
+ const row =
334
+ state === ContactState.STICK || state === ContactState.SLIP
335
+ ? contactRow[column]
336
+ : candidateRow[column];
337
+ const index = row * columns + column;
338
+ const radiusX = contactRadiusX[column];
339
+ const radiusY = contactRadiusY[column];
340
+ let dx = positionsX[index] - force.x;
341
+ let dy = positionsY[index] - force.y;
342
+ let q = Math.sqrt((dx / radiusX) ** 2 + (dy / radiusY) ** 2);
343
+
344
+ if (state === ContactState.COOLDOWN && q > 1.25) {
345
+ contactState[column] = ContactState.FREE;
346
+ state = ContactState.FREE;
347
+ }
348
+
349
+ if (
350
+ state === ContactState.FREE &&
351
+ force.pressure > 0.2 &&
352
+ handSpeed > 18 &&
353
+ stickingCount < scene.contact.maxStaticGrip
354
+ ) {
355
+ const approach = dx * handDeltaX + dy * handDeltaY;
356
+ if (candidateQ[column] < captureQ[column] && approach > 0) {
357
+ contactState[column] = ContactState.STICK;
358
+ contactRow[column] = candidateRow[column];
359
+ const held = contactRow[column] * columns + column;
360
+ dx = positionsX[held] - force.x;
361
+ dy = positionsY[held] - force.y;
362
+ contactOffsetX[column] = dx;
363
+ contactOffsetY[column] = Math.max(
364
+ -radiusY * 0.72,
365
+ Math.min(radiusY * 0.72, dy + contactLane[column] * 0.55)
366
+ );
367
+ contactReaction[column] = 0;
368
+ contactStress[column] = 0;
369
+ contactSlipTravel[column] = 0;
370
+ contactAge[column] = 0;
371
+ contactBridge[column] = 0;
372
+ stickingCount += 1;
373
+ state = ContactState.STICK;
374
+ }
375
+ }
376
+
377
+ if (state === ContactState.STICK) {
378
+ if (force.pressure < 0.035 || q > 1.05) {
379
+ releaseToSlip(column);
380
+ continue;
381
+ }
382
+ contactAge[column] += dt;
383
+ const desiredX = force.x + contactOffsetX[column];
384
+ const desiredY = force.y + contactOffsetY[column];
385
+ const errorX = desiredX - positionsX[index];
386
+ const errorY = desiredY - positionsY[index];
387
+ const errorLength = Math.sqrt(errorX * errorX + errorY * errorY) || 0.0001;
388
+ const pressureGain = 0.22 + force.pressure * 0.78;
389
+ const maxCorrection = stickCap[column] * pressureGain;
390
+ const correctionScale = Math.min(
391
+ stickGain[column] * pressureGain,
392
+ maxCorrection / errorLength
393
+ );
394
+ positionsX[index] += errorX * correctionScale;
395
+ positionsY[index] += errorY * correctionScale;
396
+ } else if (state === ContactState.SLIP) {
397
+ const bridge = contactBridge[column];
398
+ if (bridge > 0.01) {
399
+ const errorX = force.x + contactOffsetX[column] - positionsX[index];
400
+ const errorY = force.y + contactOffsetY[column] - positionsY[index];
401
+ const errorLength = Math.sqrt(errorX * errorX + errorY * errorY) || 0.0001;
402
+ const bridgeGain = Math.min(
403
+ 0.16 * bridge * (0.3 + force.pressure * 0.7),
404
+ 0.62 / errorLength
405
+ );
406
+ positionsX[index] += errorX * bridgeGain;
407
+ positionsY[index] += errorY * bridgeGain;
408
+ contactBridge[column] *= Math.exp(-dt / 0.075);
409
+ } else {
410
+ contactBridge[column] = 0;
411
+ }
412
+
413
+ const particleDeltaX = velocitiesX[index] * dt;
414
+ const particleDeltaY = velocitiesY[index] * dt;
415
+ const relativeX = handDeltaX - particleDeltaX;
416
+ const relativeY = handDeltaY - particleDeltaY;
417
+ const relativeLength =
418
+ Math.sqrt(relativeX * relativeX + relativeY * relativeY) || 0.0001;
419
+ const contactPressure =
420
+ smoothStep((1.14 - q) / 0.24) * force.pressure;
421
+ const frictionStep =
422
+ Math.min(
423
+ relativeLength * kineticGain[column],
424
+ kineticCap[column]
425
+ ) * contactPressure;
426
+ positionsX[index] += (relativeX / relativeLength) * frictionStep;
427
+ positionsY[index] += (relativeY / relativeLength) * frictionStep;
428
+ contactSlipTravel[column] += Math.max(
429
+ 0,
430
+ relativeLength - frictionStep
431
+ );
432
+ if (
433
+ q > 1.16 ||
434
+ contactSlipTravel[column] > slipBudget[column] ||
435
+ force.pressure < 0.02
436
+ ) {
437
+ contactState[column] = ContactState.COOLDOWN;
438
+ contactBridge[column] = 0;
439
+ }
440
+ }
441
+
442
+ state = contactState[column];
443
+ dx = positionsX[index] - force.x;
444
+ dy = positionsY[index] - force.y;
445
+ q = Math.sqrt((dx / radiusX) ** 2 + (dy / radiusY) ** 2);
446
+ if (state !== ContactState.STICK && q < 1 && force.pressure > 0.02) {
447
+ const normalizedLength = Math.max(q, 0.001);
448
+ const nx = (dx / radiusX) / normalizedLength;
449
+ const ny = (dy / radiusY) / normalizedLength;
450
+ const surfaceX = force.x + nx * radiusX;
451
+ const surfaceY = force.y + ny * radiusY;
452
+ const pushX = surfaceX - positionsX[index];
453
+ const pushY = surfaceY - positionsY[index];
454
+ const pushLength = Math.sqrt(pushX * pushX + pushY * pushY) || 0.0001;
455
+ const pushScale = Math.min(
456
+ 0.38 * force.pressure,
457
+ (2.2 * force.pressure) / pushLength
458
+ );
459
+ positionsX[index] += pushX * pushScale;
460
+ positionsY[index] += pushY * pushScale;
461
+ }
462
+ }
463
+ }
464
+
465
+ function separateHeldStrands() {
466
+ for (let aColumn = 0; aColumn < columns - 1; aColumn += 1) {
467
+ if (contactState[aColumn] !== ContactState.STICK) continue;
468
+ const aIndex = contactRow[aColumn] * columns + aColumn;
469
+ for (let bColumn = aColumn + 1; bColumn < columns; bColumn += 1) {
470
+ if (contactState[bColumn] !== ContactState.STICK) continue;
471
+ const bIndex = contactRow[bColumn] * columns + bColumn;
472
+ let dx = positionsX[bIndex] - positionsX[aIndex];
473
+ let dy = positionsY[bIndex] - positionsY[aIndex];
474
+ let distance = Math.sqrt(dx * dx + dy * dy);
475
+ const minimum =
476
+ 4.4 + correlatedNoise(aColumn + bColumn, 149) * 1.4;
477
+ if (distance >= minimum) continue;
478
+ if (distance < 0.001) {
479
+ dx = 0;
480
+ dy = hash01(aColumn * 31 + bColumn * 17, seed) < 0.5 ? -1 : 1;
481
+ distance = 1;
482
+ }
483
+ const correction = (minimum - distance) * 0.28;
484
+ positionsX[aIndex] -= (dx / distance) * correction;
485
+ positionsY[aIndex] -= (dy / distance) * correction;
486
+ positionsX[bIndex] += (dx / distance) * correction;
487
+ positionsY[bIndex] += (dy / distance) * correction;
488
+ }
489
+ }
490
+ }
491
+
492
+ function measureContactReaction(force) {
493
+ for (let column = 0; column < columns; column += 1) {
494
+ if (contactState[column] !== ContactState.STICK) continue;
495
+ const index = contactRow[column] * columns + column;
496
+ const reactionX =
497
+ positionsX[index] - (force.x + contactOffsetX[column]);
498
+ const reactionY =
499
+ positionsY[index] - (force.y + contactOffsetY[column]);
500
+ const rawReaction = Math.sqrt(
501
+ reactionX * reactionX + reactionY * reactionY
502
+ );
503
+ contactReaction[column] =
504
+ contactReaction[column] * 0.64 + rawReaction * 0.36;
505
+ const frictionLimit =
506
+ staticLimit[column] * (0.22 + force.pressure * 0.78);
507
+ const loadRatio =
508
+ contactReaction[column] / Math.max(0.35, frictionLimit);
509
+ if (loadRatio > 0.32) {
510
+ contactStress[column] += (loadRatio - 0.32) * dt;
511
+ } else {
512
+ contactStress[column] = Math.max(
513
+ 0,
514
+ contactStress[column] - 0.24 * dt
515
+ );
516
+ }
517
+ if (
518
+ contactAge[column] > 0.075 &&
519
+ (loadRatio > 1.13 ||
520
+ contactStress[column] > stressBudget[column])
521
+ ) {
522
+ releaseToSlip(column);
523
+ }
524
+ }
525
+ }
526
+
527
+ function stepSubstep(time) {
528
+ const force = sampleGesture(scene.gesture.points, time);
529
+ const forceDeltaX = force.x - previousForce.x;
530
+ const forceDeltaY = force.y - previousForce.y;
531
+ const handSpeed =
532
+ Math.sqrt(forceDeltaX * forceDeltaX + forceDeltaY * forceDeltaY) / dt;
533
+
534
+ for (let index = 0; index < count; index += 1) {
535
+ const column = index % columns;
536
+ previousX[index] = positionsX[index];
537
+ previousY[index] = positionsY[index];
538
+ velocitiesY[index] += scene.material.gravity * dt;
539
+ const speed = Math.sqrt(
540
+ velocitiesX[index] ** 2 + velocitiesY[index] ** 2
541
+ );
542
+ const damping =
543
+ Math.exp(-drag[column] * dt) /
544
+ (1 + scene.material.quadraticDrag * speed * dt);
545
+ velocitiesX[index] *= damping;
546
+ velocitiesY[index] *= damping;
547
+ positionsX[index] += velocitiesX[index] * dt;
548
+ positionsY[index] += velocitiesY[index] * dt;
549
+ }
550
+
551
+ updateContacts(force, forceDeltaX, forceDeltaY, handSpeed);
552
+ linkLambda.fill(0);
553
+ bendLambda.fill(0);
554
+ anchorLambda.fill(0);
555
+
556
+ for (let pass = 0; pass < constraintPasses; pass += 1) {
557
+ for (let column = 0; column < columns; column += 1) {
558
+ solveAnchor(column);
559
+ }
560
+ for (let column = 0; column < columns; column += 1) {
561
+ for (let row = 1; row < rows; row += 1) {
562
+ const index = row * columns + column;
563
+ solvePair(
564
+ index - columns,
565
+ index,
566
+ linkRest[index],
567
+ lengthCompliance[column],
568
+ compressionCompliance[column],
569
+ linkLambda,
570
+ index,
571
+ scene.material.maxStretch
572
+ );
573
+ }
574
+ }
575
+ for (let column = 0; column < columns; column += 1) {
576
+ for (let row = 2; row < rows; row += 1) {
577
+ const index = row * columns + column;
578
+ solvePair(
579
+ index - columns * 2,
580
+ index,
581
+ bendRest[index],
582
+ bendCompliance[column],
583
+ bendCompliance[column] * 1.7,
584
+ bendLambda,
585
+ index,
586
+ 1.18
587
+ );
588
+ }
589
+ }
590
+ }
591
+
592
+ separateHeldStrands();
593
+ for (let safetyPass = 0; safetyPass < 2; safetyPass += 1) {
594
+ for (let column = 0; column < columns; column += 1) {
595
+ enforceHardAnchor(column);
596
+ for (let row = 1; row < rows; row += 1) {
597
+ const index = row * columns + column;
598
+ enforceHardLink(
599
+ index - columns,
600
+ index,
601
+ linkRest[index],
602
+ scene.material.maxStretch
603
+ );
604
+ }
605
+ }
606
+ }
607
+ measureContactReaction(force);
608
+
609
+ for (let index = 0; index < count; index += 1) {
610
+ velocitiesX[index] = (positionsX[index] - previousX[index]) / dt;
611
+ velocitiesY[index] = (positionsY[index] - previousY[index]) / dt;
612
+ const speed = Math.sqrt(
613
+ velocitiesX[index] ** 2 + velocitiesY[index] ** 2
614
+ );
615
+ if (speed > 0.01) {
616
+ const cappedSpeed = 820 * Math.tanh(speed / 820);
617
+ velocitiesX[index] *= cappedSpeed / speed;
618
+ velocitiesY[index] *= cappedSpeed / speed;
619
+ }
620
+ }
621
+
622
+ const axialAmount = 1 - Math.exp(-5.2 * dt);
623
+ for (let column = 0; column < columns; column += 1) {
624
+ for (let row = 1; row < rows; row += 1) {
625
+ const b = row * columns + column;
626
+ const a = b - columns;
627
+ const dx = positionsX[b] - positionsX[a];
628
+ const dy = positionsY[b] - positionsY[a];
629
+ const distance = Math.sqrt(dx * dx + dy * dy) || 0.0001;
630
+ if (distance < linkRest[b] * 0.992) continue;
631
+ const nx = dx / distance;
632
+ const ny = dy / distance;
633
+ const relative =
634
+ (velocitiesX[b] - velocitiesX[a]) * nx +
635
+ (velocitiesY[b] - velocitiesY[a]) * ny;
636
+ const impulse = relative * axialAmount * 0.5;
637
+ velocitiesX[a] += nx * impulse;
638
+ velocitiesY[a] += ny * impulse;
639
+ velocitiesX[b] -= nx * impulse;
640
+ velocitiesY[b] -= ny * impulse;
641
+ }
642
+ }
643
+ previousForce = force;
644
+ }
645
+
646
+ function saveState(frame) {
647
+ const state = frames
648
+ ? {
649
+ x: new Float32Array(count),
650
+ y: new Float32Array(count),
651
+ rotation: new Float32Array(count)
652
+ }
653
+ : null;
654
+
655
+ for (let index = 0; index < count; index += 1) {
656
+ if (state) {
657
+ state.x[index] = positionsX[index] - baseX[index];
658
+ state.y[index] = positionsY[index] - baseY[index];
659
+ }
660
+ if (frame % 6 === 0) {
661
+ hasher.add(positionsX[index]);
662
+ hasher.add(positionsY[index]);
663
+ }
664
+ }
665
+
666
+ for (let column = 0; column < columns; column += 1) {
667
+ for (let row = 0; row < rows; row += 1) {
668
+ const index = row * columns + column;
669
+ const before = Math.max(0, row - 1) * columns + column;
670
+ const after = Math.min(rows - 1, row + 1) * columns + column;
671
+ const tangentX = positionsX[after] - positionsX[before];
672
+ const tangentY = positionsY[after] - positionsY[before] || 0.0001;
673
+ const target = (Math.atan2(tangentX, tangentY) * 180) / Math.PI;
674
+ let delta = target - rotation[index];
675
+ if (delta > 180) delta -= 360;
676
+ else if (delta < -180) delta += 360;
677
+ delta = Math.max(-22, Math.min(22, delta));
678
+ rotationVelocity[index] =
679
+ rotationVelocity[index] * 0.86 + delta * rotationGain[column];
680
+ rotation[index] += rotationVelocity[index];
681
+ if (state) {
682
+ state.rotation[index] = 78 * Math.tanh(rotation[index] / 78);
683
+ }
684
+ }
685
+ }
686
+
687
+ let sticking = 0;
688
+ let slipping = 0;
689
+ let kineticEnergy = 0;
690
+ let maxStretch = 1;
691
+ for (let column = 0; column < columns; column += 1) {
692
+ if (contactState[column] === ContactState.STICK) sticking += 1;
693
+ else if (contactState[column] === ContactState.SLIP) slipping += 1;
694
+ for (let row = 1; row < rows; row += 1) {
695
+ const index = row * columns + column;
696
+ const above = index - columns;
697
+ const dx = positionsX[index] - positionsX[above];
698
+ const dy = positionsY[index] - positionsY[above];
699
+ maxStretch = Math.max(
700
+ maxStretch,
701
+ Math.sqrt(dx * dx + dy * dy) / linkRest[index]
702
+ );
703
+ }
704
+ }
705
+ for (let index = 0; index < count; index += 1) {
706
+ kineticEnergy +=
707
+ (0.5 / inverseMass[index]) *
708
+ (velocitiesX[index] ** 2 + velocitiesY[index] ** 2);
709
+ }
710
+
711
+ diagnostics.sticking[frame] = sticking;
712
+ diagnostics.slipping[frame] = slipping;
713
+ diagnostics.free[frame] = columns - sticking - slipping;
714
+ diagnostics.kineticEnergy[frame] = kineticEnergy;
715
+ diagnostics.maxStretch[frame] = maxStretch;
716
+ diagnostics.observedMaxStaticGrip = Math.max(
717
+ diagnostics.observedMaxStaticGrip,
718
+ sticking
719
+ );
720
+ diagnostics.observedMaxCombinedContact = Math.max(
721
+ diagnostics.observedMaxCombinedContact,
722
+ sticking + slipping
723
+ );
724
+ diagnostics.observedMaxStretch = Math.max(
725
+ diagnostics.observedMaxStretch,
726
+ maxStretch
727
+ );
728
+ if (sticking > 0) {
729
+ const time = frame / fps;
730
+ if (diagnostics.firstGripTime === null) {
731
+ diagnostics.firstGripTime = time;
732
+ }
733
+ diagnostics.lastGripTime = time;
734
+ }
735
+ if (frames) frames[frame] = state;
736
+ }
737
+
738
+ const preSteps = Math.round(preRoll * fps * substeps);
739
+ for (let preStep = preSteps; preStep > 0; preStep -= 1) {
740
+ stepSubstep(-preStep / (fps * substeps));
741
+ }
742
+
743
+ for (let index = 0; index < count; index += 1) {
744
+ const column = index % columns;
745
+ const row = (index - column) / columns;
746
+ const before = Math.max(0, row - 1) * columns + column;
747
+ const after = Math.min(rows - 1, row + 1) * columns + column;
748
+ const tangentX = positionsX[after] - positionsX[before];
749
+ const tangentY = positionsY[after] - positionsY[before] || 0.0001;
750
+ rotation[index] = (Math.atan2(tangentX, tangentY) * 180) / Math.PI;
751
+ }
752
+
753
+ for (let frame = 0; frame < frameCount; frame += 1) {
754
+ saveState(frame);
755
+ if (frame === frameCount - 1) break;
756
+ for (let substep = 0; substep < substeps; substep += 1) {
757
+ stepSubstep((frame * substeps + substep + 1) / (fps * substeps));
758
+ }
759
+ }
760
+
761
+ return {
762
+ scene,
763
+ frames,
764
+ base: {
765
+ x: Float32Array.from(baseX),
766
+ y: Float32Array.from(baseY),
767
+ anchorX: Float32Array.from(anchorX),
768
+ anchorY: Float32Array.from(anchorY)
769
+ },
770
+ diagnostics,
771
+ fingerprint: hasher.digest()
772
+ };
773
+ }