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/gesture.js ADDED
@@ -0,0 +1,58 @@
1
+ export function clamp(value, minimum = 0, maximum = 1) {
2
+ return Math.max(minimum, Math.min(maximum, value));
3
+ }
4
+
5
+ export function smoothStep(value) {
6
+ const t = clamp(value);
7
+ return t * t * (3 - 2 * t);
8
+ }
9
+
10
+ export function sampleGesture(points, time) {
11
+ if (!Array.isArray(points) || points.length < 2) {
12
+ throw new TypeError("A gesture requires at least two time-ordered points.");
13
+ }
14
+
15
+ const first = points[0];
16
+ const last = points[points.length - 1];
17
+ if (time <= first.time) return { ...first };
18
+ if (time >= last.time) return { ...last };
19
+
20
+ for (let index = 0; index < points.length - 1; index += 1) {
21
+ const a = points[index];
22
+ const b = points[index + 1];
23
+ if (time < a.time || time > b.time) continue;
24
+
25
+ const previous = points[Math.max(0, index - 1)];
26
+ const next = points[Math.min(points.length - 1, index + 2)];
27
+ const segmentDuration = b.time - a.time;
28
+ const progress = (time - a.time) / segmentDuration;
29
+ const tangentAX =
30
+ ((b.x - previous.x) / Math.max(0.001, b.time - previous.time)) *
31
+ segmentDuration;
32
+ const tangentAY =
33
+ ((b.y - previous.y) / Math.max(0.001, b.time - previous.time)) *
34
+ segmentDuration;
35
+ const tangentBX =
36
+ ((next.x - a.x) / Math.max(0.001, next.time - a.time)) *
37
+ segmentDuration;
38
+ const tangentBY =
39
+ ((next.y - a.y) / Math.max(0.001, next.time - a.time)) *
40
+ segmentDuration;
41
+ const p2 = progress * progress;
42
+ const p3 = p2 * progress;
43
+ const h00 = 2 * p3 - 3 * p2 + 1;
44
+ const h10 = p3 - 2 * p2 + progress;
45
+ const h01 = -2 * p3 + 3 * p2;
46
+ const h11 = p3 - p2;
47
+
48
+ return {
49
+ time,
50
+ x: h00 * a.x + h10 * tangentAX + h01 * b.x + h11 * tangentBX,
51
+ y: h00 * a.y + h10 * tangentAY + h01 * b.y + h11 * tangentBY,
52
+ pressure:
53
+ a.pressure + (b.pressure - a.pressure) * smoothStep(progress)
54
+ };
55
+ }
56
+
57
+ return { ...last };
58
+ }
package/src/index.js ADDED
@@ -0,0 +1,44 @@
1
+ export {
2
+ createStringTouchScene,
3
+ defaultStringTouchScene,
4
+ validateStrandScene
5
+ } from "./scene.js";
6
+ export {
7
+ createRigidBallsScene,
8
+ defaultRigidBallsScene,
9
+ validateRigidBallsScene
10
+ } from "./rigid-scene.js";
11
+ export {
12
+ createSurfaceWaveScene,
13
+ defaultSurfaceWaveScene,
14
+ validateSurfaceWaveScene
15
+ } from "./wave-scene.js";
16
+ export { validateScene } from "./scene-registry.js";
17
+ export { clamp, sampleGesture, smoothStep } from "./gesture.js";
18
+ export {
19
+ ContactState,
20
+ simulateStrandField
21
+ } from "./strands.js";
22
+ export { simulateRigidBalls } from "./rigid-balls.js";
23
+ export { simulateSurfaceWave } from "./surface-wave.js";
24
+ export { createSvgRenderer } from "./svg-renderer.js";
25
+ export { createCrystalRenderer } from "./crystal-renderer.js";
26
+ export { createRigidBallRenderer } from "./rigid-renderer.js";
27
+ export { createSurfaceWaveRenderer } from "./wave-renderer.js";
28
+
29
+ import { simulateStrandField } from "./strands.js";
30
+ import { simulateRigidBalls } from "./rigid-balls.js";
31
+ import { simulateSurfaceWave } from "./surface-wave.js";
32
+
33
+ export function simulateScene(scene, options) {
34
+ switch (scene?.type) {
35
+ case "strand-field":
36
+ return simulateStrandField(scene, options);
37
+ case "rigid-balls":
38
+ return simulateRigidBalls(scene, options);
39
+ case "surface-wave":
40
+ return simulateSurfaceWave(scene, options);
41
+ default:
42
+ throw new TypeError(`No solver registered for scene type: ${scene?.type}`);
43
+ }
44
+ }
@@ -0,0 +1,304 @@
1
+ import { validateRigidBallsScene } from "./rigid-scene.js";
2
+ import { createStateHasher } from "./state-hash.js";
3
+
4
+ export function simulateRigidBalls(inputScene, options = {}) {
5
+ const scene = validateRigidBallsScene(inputScene);
6
+ const { fps, substeps, duration } = scene.timing;
7
+ const dt = 1 / (fps * substeps);
8
+ const frameCount = Math.round(duration * fps) + 1;
9
+ const count = scene.bodies.length;
10
+ const includeFrames = options.includeFrames !== false;
11
+
12
+ const x = new Float64Array(count);
13
+ const y = new Float64Array(count);
14
+ const vx = new Float64Array(count);
15
+ const vy = new Float64Array(count);
16
+ const angle = new Float64Array(count);
17
+ const angularVelocity = new Float64Array(count);
18
+ const radius = new Float64Array(count);
19
+ const mass = new Float64Array(count);
20
+ const inverseMass = new Float64Array(count);
21
+ const restitution = new Float64Array(count);
22
+ const friction = new Float64Array(count);
23
+ const idToIndex = new Map();
24
+
25
+ scene.bodies.forEach((body, index) => {
26
+ x[index] = body.x;
27
+ y[index] = body.y;
28
+ vx[index] = body.vx;
29
+ vy[index] = body.vy;
30
+ angle[index] = body.angle;
31
+ angularVelocity[index] = body.angularVelocity;
32
+ radius[index] = body.radius;
33
+ mass[index] = body.mass;
34
+ inverseMass[index] = 1 / body.mass;
35
+ restitution[index] = body.restitution;
36
+ friction[index] = body.friction;
37
+ idToIndex.set(body.id, index);
38
+ });
39
+
40
+ const frames = includeFrames ? new Array(frameCount) : null;
41
+ const diagnostics = {
42
+ kineticEnergy: new Float32Array(frameCount),
43
+ floorContacts: new Uint16Array(frameCount),
44
+ collisionCount: 0,
45
+ firstImpactTime: null,
46
+ maxSpeed: 0,
47
+ maxPenetration: 0,
48
+ finalRestingBodies: 0,
49
+ appliedImpulses: 0
50
+ };
51
+ const hasher = createStateHasher();
52
+ let impulseCursor = 0;
53
+ let previousTime = -dt;
54
+
55
+ function registerImpact(time, speed, penetration) {
56
+ if (speed > 8) {
57
+ diagnostics.collisionCount += 1;
58
+ if (diagnostics.firstImpactTime === null && time >= 0) {
59
+ diagnostics.firstImpactTime = time;
60
+ }
61
+ }
62
+ diagnostics.maxPenetration = Math.max(
63
+ diagnostics.maxPenetration,
64
+ penetration
65
+ );
66
+ }
67
+
68
+ function applyScheduledImpulses(time) {
69
+ while (
70
+ impulseCursor < scene.impulses.length &&
71
+ scene.impulses[impulseCursor].time <= time + dt * 0.5
72
+ ) {
73
+ const impulse = scene.impulses[impulseCursor];
74
+ if (impulse.time > previousTime - dt * 0.5) {
75
+ const index = idToIndex.get(impulse.body);
76
+ vx[index] += impulse.impulseX * inverseMass[index];
77
+ vy[index] += impulse.impulseY * inverseMass[index];
78
+ angularVelocity[index] +=
79
+ (impulse.angularImpulse ?? 0) * inverseMass[index];
80
+ diagnostics.appliedImpulses += 1;
81
+ }
82
+ impulseCursor += 1;
83
+ }
84
+ }
85
+
86
+ function solveBodyPair(a, b, time) {
87
+ let dx = x[b] - x[a];
88
+ let dy = y[b] - y[a];
89
+ let distance = Math.sqrt(dx * dx + dy * dy);
90
+ const minimumDistance = radius[a] + radius[b];
91
+ if (distance >= minimumDistance) return;
92
+ if (distance < 0.0001) {
93
+ dx = a < b ? 1 : -1;
94
+ dy = 0;
95
+ distance = 1;
96
+ }
97
+
98
+ const nx = dx / distance;
99
+ const ny = dy / distance;
100
+ const penetration = minimumDistance - distance;
101
+ const inverseMassTotal = inverseMass[a] + inverseMass[b];
102
+ const correction = Math.max(0, penetration - 0.01) * 0.86;
103
+ x[a] -=
104
+ nx * correction * (inverseMass[a] / Math.max(0.0001, inverseMassTotal));
105
+ y[a] -=
106
+ ny * correction * (inverseMass[a] / Math.max(0.0001, inverseMassTotal));
107
+ x[b] +=
108
+ nx * correction * (inverseMass[b] / Math.max(0.0001, inverseMassTotal));
109
+ y[b] +=
110
+ ny * correction * (inverseMass[b] / Math.max(0.0001, inverseMassTotal));
111
+
112
+ const relativeX = vx[b] - vx[a];
113
+ const relativeY = vy[b] - vy[a];
114
+ const normalVelocity = relativeX * nx + relativeY * ny;
115
+ if (normalVelocity >= 0) return;
116
+
117
+ const combinedRestitution = Math.min(restitution[a], restitution[b]);
118
+ const normalImpulse =
119
+ (-(1 + combinedRestitution) * normalVelocity) / inverseMassTotal;
120
+ const impulseX = normalImpulse * nx;
121
+ const impulseY = normalImpulse * ny;
122
+ vx[a] -= impulseX * inverseMass[a];
123
+ vy[a] -= impulseY * inverseMass[a];
124
+ vx[b] += impulseX * inverseMass[b];
125
+ vy[b] += impulseY * inverseMass[b];
126
+
127
+ const tangentX = -ny;
128
+ const tangentY = nx;
129
+ const tangentVelocity = relativeX * tangentX + relativeY * tangentY;
130
+ const frictionLimit =
131
+ normalImpulse * Math.sqrt(friction[a] * friction[b]);
132
+ const tangentImpulse = Math.max(
133
+ -frictionLimit,
134
+ Math.min(frictionLimit, -tangentVelocity / inverseMassTotal)
135
+ );
136
+ vx[a] -= tangentImpulse * tangentX * inverseMass[a];
137
+ vy[a] -= tangentImpulse * tangentY * inverseMass[a];
138
+ vx[b] += tangentImpulse * tangentX * inverseMass[b];
139
+ vy[b] += tangentImpulse * tangentY * inverseMass[b];
140
+ angularVelocity[a] -=
141
+ (tangentImpulse * radius[a] * inverseMass[a]) /
142
+ Math.max(1, radius[a] * radius[a] * 0.5);
143
+ angularVelocity[b] +=
144
+ (tangentImpulse * radius[b] * inverseMass[b]) /
145
+ Math.max(1, radius[b] * radius[b] * 0.5);
146
+
147
+ registerImpact(time, -normalVelocity, penetration);
148
+ }
149
+
150
+ function solveWorld(index, time) {
151
+ const bodyRadius = radius[index];
152
+ let contacts = 0;
153
+
154
+ if (x[index] - bodyRadius < scene.world.left) {
155
+ const penetration = scene.world.left - (x[index] - bodyRadius);
156
+ x[index] = scene.world.left + bodyRadius;
157
+ if (vx[index] < 0) {
158
+ registerImpact(time, -vx[index], penetration);
159
+ vx[index] = -vx[index] * restitution[index];
160
+ }
161
+ }
162
+ if (x[index] + bodyRadius > scene.world.right) {
163
+ const penetration =
164
+ x[index] + bodyRadius - scene.world.right;
165
+ x[index] = scene.world.right - bodyRadius;
166
+ if (vx[index] > 0) {
167
+ registerImpact(time, vx[index], penetration);
168
+ vx[index] = -vx[index] * restitution[index];
169
+ }
170
+ }
171
+
172
+ if (y[index] + bodyRadius > scene.world.floorY) {
173
+ const penetration =
174
+ y[index] + bodyRadius - scene.world.floorY;
175
+ y[index] = scene.world.floorY - bodyRadius;
176
+ contacts += 1;
177
+ if (vy[index] > 0) {
178
+ const impactSpeed = vy[index];
179
+ registerImpact(time, impactSpeed, penetration);
180
+ vy[index] =
181
+ impactSpeed < 22 ? 0 : -impactSpeed * restitution[index];
182
+ const tangentialLoss = Math.max(
183
+ 0,
184
+ 1 - friction[index] * 0.12
185
+ );
186
+ vx[index] *= tangentialLoss;
187
+ angularVelocity[index] +=
188
+ (vx[index] / Math.max(1, bodyRadius)) *
189
+ friction[index] *
190
+ 0.28;
191
+ }
192
+ if (Math.abs(vy[index]) < 20) {
193
+ const groundDecay = Math.exp(
194
+ -scene.world.groundFriction * friction[index] * dt
195
+ );
196
+ vx[index] *= groundDecay;
197
+ angularVelocity[index] *= Math.exp(
198
+ -scene.world.groundFriction * 0.45 * dt
199
+ );
200
+ if (Math.abs(vx[index]) < 0.35) vx[index] = 0;
201
+ if (Math.abs(angularVelocity[index]) < 0.02) {
202
+ angularVelocity[index] = 0;
203
+ }
204
+ }
205
+ }
206
+ return contacts;
207
+ }
208
+
209
+ function step(time) {
210
+ applyScheduledImpulses(time);
211
+ const airDecay = Math.exp(-scene.world.airDrag * dt);
212
+ for (let index = 0; index < count; index += 1) {
213
+ vy[index] += scene.world.gravity * dt;
214
+ vx[index] *= airDecay;
215
+ vy[index] *= airDecay;
216
+ angularVelocity[index] *= Math.exp(-scene.world.airDrag * 0.35 * dt);
217
+ x[index] += vx[index] * dt;
218
+ y[index] += vy[index] * dt;
219
+ angle[index] += angularVelocity[index] * dt * (180 / Math.PI);
220
+ }
221
+
222
+ for (let pass = 0; pass < 2; pass += 1) {
223
+ for (let a = 0; a < count - 1; a += 1) {
224
+ for (let b = a + 1; b < count; b += 1) {
225
+ solveBodyPair(a, b, time);
226
+ }
227
+ }
228
+ }
229
+ for (let index = 0; index < count; index += 1) {
230
+ solveWorld(index, time);
231
+ const speed = Math.sqrt(vx[index] ** 2 + vy[index] ** 2);
232
+ diagnostics.maxSpeed = Math.max(diagnostics.maxSpeed, speed);
233
+ }
234
+ previousTime = time;
235
+ }
236
+
237
+ function saveFrame(frameIndex) {
238
+ const state = includeFrames
239
+ ? {
240
+ x: Float32Array.from(x),
241
+ y: Float32Array.from(y),
242
+ angle: Float32Array.from(angle)
243
+ }
244
+ : null;
245
+ let energy = 0;
246
+ let floorContacts = 0;
247
+ let resting = 0;
248
+ for (let index = 0; index < count; index += 1) {
249
+ energy +=
250
+ 0.5 *
251
+ mass[index] *
252
+ (vx[index] ** 2 +
253
+ vy[index] ** 2 +
254
+ (angularVelocity[index] * radius[index]) ** 2);
255
+ if (Math.abs(y[index] + radius[index] - scene.world.floorY) < 0.1) {
256
+ floorContacts += 1;
257
+ }
258
+ if (
259
+ Math.abs(vx[index]) < 1 &&
260
+ Math.abs(vy[index]) < 1 &&
261
+ Math.abs(angularVelocity[index]) < 0.05
262
+ ) {
263
+ resting += 1;
264
+ }
265
+ if (frameIndex % 6 === 0) {
266
+ hasher.add(x[index]);
267
+ hasher.add(y[index]);
268
+ hasher.add(angle[index]);
269
+ }
270
+ }
271
+ diagnostics.kineticEnergy[frameIndex] = energy;
272
+ diagnostics.floorContacts[frameIndex] = floorContacts;
273
+ if (frameIndex === frameCount - 1) {
274
+ diagnostics.finalRestingBodies = resting;
275
+ }
276
+ if (frames) frames[frameIndex] = state;
277
+ }
278
+
279
+ const preRollSteps = Math.round(scene.timing.preRoll / dt);
280
+ for (let index = 0; index < preRollSteps; index += 1) {
281
+ step((index - preRollSteps) * dt);
282
+ }
283
+ diagnostics.collisionCount = 0;
284
+ diagnostics.firstImpactTime = null;
285
+ diagnostics.maxSpeed = 0;
286
+ diagnostics.maxPenetration = 0;
287
+ diagnostics.finalRestingBodies = 0;
288
+ diagnostics.appliedImpulses = 0;
289
+
290
+ for (let frame = 0; frame < frameCount; frame += 1) {
291
+ saveFrame(frame);
292
+ if (frame === frameCount - 1) break;
293
+ for (let substep = 0; substep < substeps; substep += 1) {
294
+ step((frame * substeps + substep + 1) * dt);
295
+ }
296
+ }
297
+
298
+ return {
299
+ scene,
300
+ frames,
301
+ diagnostics,
302
+ fingerprint: hasher.digest()
303
+ };
304
+ }