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
@@ -0,0 +1,138 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const expected = [
9
+ {
10
+ label: "overview",
11
+ path: "videos/oddlyalive-demos/renders/oddlyalive-demos-overview.mp4",
12
+ duration: 32,
13
+ frames: 1920
14
+ },
15
+ {
16
+ label: "string-touch",
17
+ path: "videos/oddlyalive-demos/renders/demos/01-string-touch.mp4",
18
+ duration: 6.4,
19
+ frames: 384
20
+ },
21
+ {
22
+ label: "crystal-mobile",
23
+ path: "videos/oddlyalive-demos/renders/demos/02-crystal-mobile.mp4",
24
+ duration: 6.4,
25
+ frames: 384
26
+ },
27
+ {
28
+ label: "ball-lab",
29
+ path: "videos/oddlyalive-demos/renders/demos/03-ball-lab.mp4",
30
+ duration: 6.4,
31
+ frames: 384
32
+ },
33
+ {
34
+ label: "football-kick",
35
+ path: "videos/oddlyalive-demos/renders/demos/04-football-kick.mp4",
36
+ duration: 6.4,
37
+ frames: 384
38
+ },
39
+ {
40
+ label: "shoe-splash",
41
+ path: "videos/oddlyalive-demos/renders/demos/05-shoe-splash.mp4",
42
+ duration: 6.4,
43
+ frames: 384
44
+ }
45
+ ];
46
+
47
+ function run(command, args) {
48
+ const result = spawnSync(command, args, {
49
+ cwd: root,
50
+ encoding: "utf8"
51
+ });
52
+ if (result.status !== 0) {
53
+ throw new Error(
54
+ `${command} failed:\n${result.stderr || result.stdout || "unknown error"}`
55
+ );
56
+ }
57
+ return result.stdout;
58
+ }
59
+
60
+ function almostEqual(actual, expectedValue, tolerance = 0.001) {
61
+ return Math.abs(actual - expectedValue) <= tolerance;
62
+ }
63
+
64
+ function assert(condition, message) {
65
+ if (!condition) throw new Error(message);
66
+ }
67
+
68
+ const rows = [];
69
+
70
+ for (const item of expected) {
71
+ const absolutePath = resolve(root, item.path);
72
+ const probe = JSON.parse(
73
+ run("ffprobe", [
74
+ "-v",
75
+ "error",
76
+ "-show_streams",
77
+ "-show_format",
78
+ "-of",
79
+ "json",
80
+ absolutePath
81
+ ])
82
+ );
83
+ const videoStreams = probe.streams.filter(
84
+ (stream) => stream.codec_type === "video"
85
+ );
86
+ const audioStreams = probe.streams.filter(
87
+ (stream) => stream.codec_type === "audio"
88
+ );
89
+
90
+ assert(videoStreams.length === 1, `${item.label}: expected one video stream`);
91
+ assert(audioStreams.length === 0, `${item.label}: expected no audio stream`);
92
+
93
+ const video = videoStreams[0];
94
+ const [fpsNumerator, fpsDenominator] = video.avg_frame_rate
95
+ .split("/")
96
+ .map(Number);
97
+ const fps = fpsNumerator / fpsDenominator;
98
+ const duration = Number(probe.format.duration);
99
+ const frameCount = Number(video.nb_frames);
100
+
101
+ assert(video.codec_name === "h264", `${item.label}: codec is not H.264`);
102
+ assert(video.profile === "High", `${item.label}: H.264 profile is not High`);
103
+ assert(video.pix_fmt === "yuv420p", `${item.label}: pixel format is not yuv420p`);
104
+ assert(video.width === 1920, `${item.label}: width is not 1920`);
105
+ assert(video.height === 1080, `${item.label}: height is not 1080`);
106
+ assert(almostEqual(fps, 60), `${item.label}: frame rate is not 60 fps`);
107
+ assert(
108
+ almostEqual(duration, item.duration),
109
+ `${item.label}: duration ${duration} does not match ${item.duration}`
110
+ );
111
+ assert(
112
+ frameCount === item.frames,
113
+ `${item.label}: frame count ${frameCount} does not match ${item.frames}`
114
+ );
115
+
116
+ run("ffmpeg", [
117
+ "-hide_banner",
118
+ "-loglevel",
119
+ "error",
120
+ "-i",
121
+ absolutePath,
122
+ "-f",
123
+ "null",
124
+ "-"
125
+ ]);
126
+
127
+ const bytes = readFileSync(absolutePath);
128
+ rows.push({
129
+ file: item.path,
130
+ seconds: duration.toFixed(1),
131
+ frames: frameCount,
132
+ mebibytes: (bytes.byteLength / 1024 / 1024).toFixed(2),
133
+ sha256: createHash("sha256").update(bytes).digest("hex")
134
+ });
135
+ }
136
+
137
+ console.table(rows);
138
+ console.log("PASS: all demo videos match the encoding contract and decode cleanly.");
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: oddlyalive
3
+ description: Create, modify, simulate, and validate deterministic physics-driven object animations with the OddlyAlive CLI and scene recipes. Use when a user asks to make an object feel naturally touched, tugged, plucked, kicked, dropped, shaken, or otherwise animated through physical behavior; to build a strand-field animation; or to turn a prompt into an OddlyAlive scene, demo, or render-ready project.
4
+ ---
5
+
6
+ # OddlyAlive
7
+
8
+ Use AI to author physical intent. Use the deterministic solver to author motion.
9
+
10
+ ## Route the request
11
+
12
+ 1. Identify the object family and interaction.
13
+ 2. Route to the closest implemented recipe:
14
+ - `string-touch` for strings, garlands, hanging text, and flexible strands;
15
+ - `crystal-mobile` for weighted pendants and independent hanging objects;
16
+ - `ball-lab` for round bodies with different bounce/friction materials;
17
+ - `football-kick` for a timed hit, kick, or throw impulse;
18
+ - `shoe-splash` for a single prop disturbing a stylized water surface.
19
+ 3. Do not disguise unsupported rigid-body or cloth behavior as a strand scene.
20
+ State that the recipe is not implemented or add the correct solver first.
21
+ 4. Read [references/scene-schema.md](references/scene-schema.md) when creating or
22
+ changing scene JSON.
23
+ 5. Read [references/object-models.md](references/object-models.md) when deciding
24
+ which physical model a new object needs.
25
+
26
+ ## Start from a recipe
27
+
28
+ Create a starter:
29
+
30
+ ```bash
31
+ npx oddlyalive list
32
+ npx oddlyalive new my-motion --preset football-kick
33
+ ```
34
+
35
+ Read [references/recipes.md](references/recipes.md) before selecting parameters.
36
+ Edit the generated `scene.json`. Preserve a fixed simulation rate and seed.
37
+ For strand touch, express contact as a time-ordered pressure path and never
38
+ author per-strand release timestamps.
39
+
40
+ Validate before visual review:
41
+
42
+ ```bash
43
+ npx oddlyalive inspect examples/football-kick/scene.json
44
+ npx oddlyalive simulate examples/football-kick/scene.json
45
+ npm test
46
+ ```
47
+
48
+ Run the reference demo:
49
+
50
+ ```bash
51
+ npx oddlyalive play
52
+ ```
53
+
54
+ Inspect the opening, first contact/impact, maximum displacement, rebounds, and
55
+ final settle. Check the solver-specific diagnostics and confirm that no renderer
56
+ mutates simulation state.
57
+
58
+ ## Generate video
59
+
60
+ Keep OddlyAlive as the simulation source. When MP4 or motion-graphic delivery is
61
+ requested, use the environment's HyperFrames workflow to drive the playhead and
62
+ render deterministic frames. Do not replace simulation with wall-clock animation.
63
+
64
+ ## Extend the engine
65
+
66
+ Add a new recipe only when its physical model is explicit. Keep the solver
67
+ renderer-neutral, expose diagnostics, seed every material variation, add a
68
+ determinism test, and ship one visually inspectable example.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "OddlyAlive"
3
+ short_description: "Create natural, physics-driven object motion"
4
+ default_prompt: "Use $oddlyalive to turn this object idea into a deterministic physics-driven animation."
@@ -0,0 +1,25 @@
1
+ # Object model selection
2
+
3
+ | Requested object | Start with | Required behavior |
4
+ |---|---|---|
5
+ | strings, garlands, necklaces | strand XPBD | length, bend, friction, peel |
6
+ | crystal pendants, simple hanging charms | weighted strands | terminal mass, lag, overswing |
7
+ | flower stems | articulated strands (planned) | bend limits, attached petals |
8
+ | flags, curtains, fabric | cloth XPBD | surface shear and collision |
9
+ | balls | rigid circles | collision, restitution, friction, spin |
10
+ | blocks, rackets, signs | rigid shapes + joints (planned) | angular inertia, hinges, springs |
11
+ | stylized water ripple | damped 1D wave | surface propagation, drag, buoyancy |
12
+ | full water, smoke, sand | particles or fluid model (planned) | neighborhood interaction |
13
+ | walking | articulated skeleton + IK (planned) | contacts, balance, joint limits |
14
+
15
+ Map interaction language to physics:
16
+
17
+ - `touch`, `drag`: kinematic contact shape plus pressure
18
+ - `pluck`: short static capture followed by rapid pressure loss
19
+ - `kick`, `hit`: impulse at a contact point
20
+ - `drop`: gravity with an initial pose
21
+ - `shake`: moving or rotating kinematic support
22
+ - `throw`: initial linear and angular velocity
23
+
24
+ Do not use an LLM to invent frame transforms. Let it select the model and bounded
25
+ parameters; let the solver produce the motion.
@@ -0,0 +1,27 @@
1
+ # Built-in recipes
2
+
3
+ | Preset | Scene type | Change these first |
4
+ |---|---|---|
5
+ | `string-touch` | `strand-field` | payload text, touch path, drag, compliance |
6
+ | `crystal-mobile` | `strand-field` | terminal mass, cord length, touch path |
7
+ | `ball-lab` | `rigid-balls` | radius, mass, restitution, friction |
8
+ | `football-kick` | `rigid-balls` | impulse, mass, restitution, turf friction |
9
+ | `shoe-splash` | `surface-wave` | drop pose, coupling, damping, buoyancy |
10
+
11
+ Run any recipe:
12
+
13
+ ```bash
14
+ npx oddlyalive play ball-lab
15
+ ```
16
+
17
+ Create a local project:
18
+
19
+ ```bash
20
+ npx oddlyalive new my-world --preset shoe-splash
21
+ cd my-world
22
+ npm start
23
+ ```
24
+
25
+ The reference scenes use 60fps with four substeps. Keep that 240Hz solver rate
26
+ unless tests demonstrate that a different rate remains stable. Use `inspect` to
27
+ validate the scene and `simulate` to record its deterministic fingerprint.
@@ -0,0 +1,84 @@
1
+ # Scene schema
2
+
3
+ ## Required identity
4
+
5
+ - `version`: currently `1`
6
+ - `type`: `strand-field`, `rigid-balls`, or `surface-wave`
7
+ - `name`: human-readable scene label
8
+ - `seed`: finite number controlling material variation
9
+
10
+ ## Canvas and timing
11
+
12
+ - `canvas.width`, `canvas.height`: logical render dimensions
13
+ - `timing.duration`: seconds, `0.1–60`
14
+ - `timing.fps`: sampled output frames per second
15
+ - `timing.substeps`: simulation steps per output frame
16
+ - `timing.preRoll`: seconds of pressure-free settling before frame zero
17
+
18
+ Keep `fps × substeps` fixed. The reference strand feel uses `60 × 4 = 240Hz`.
19
+
20
+ ## Strand field
21
+
22
+ - `field.columns`, `field.rows`: particle grid dimensions
23
+ - `field.originX`, `field.originY`: first payload particle
24
+ - `field.spacingX`, `field.spacingY`: rest layout
25
+
26
+ Each column is an independent strand. Rows are connected vertically with length
27
+ and bend constraints.
28
+
29
+ ## Material
30
+
31
+ - `gravity`: downward acceleration
32
+ - `linearDrag`, `quadraticDrag`: air resistance
33
+ - `lengthCompliance`: stretch softness
34
+ - `compressionCompliance`: compression softness
35
+ - `bendCompliance`: resistance across two links
36
+ - `maxStretch`: per-projection hard ratio
37
+
38
+ Avoid tuning all values simultaneously. Change gravity and drag for weight,
39
+ compliance for softness, then contact values for touch.
40
+
41
+ ## Contact
42
+
43
+ - `radiusX`, `radiusY`: invisible elliptical fingertip
44
+ - `maxStaticGrip`: upper bound on simultaneous `STICK` strands
45
+ - `captureStartRow`, `captureEndRow`: eligible rows
46
+
47
+ The state machine is `FREE → STICK → SLIP → COOLDOWN`. Load and seeded friction
48
+ must cause release. Never add a release timestamp array.
49
+
50
+ ## Gesture
51
+
52
+ `gesture.points` is a strictly time-ordered list:
53
+
54
+ ```json
55
+ { "time": 1.08, "x": 210, "y": 360, "pressure": 1 }
56
+ ```
57
+
58
+ Use pressure only for normal force. Use trajectory and speed for direction and
59
+ energy. Begin and end at zero pressure.
60
+
61
+ ## Payload and render
62
+
63
+ - `payload.text`, `fontFamily`, `fontSize`
64
+ - `render.background`, `ink`, `accent`, `filament`
65
+
66
+ These values belong to the reference SVG renderer. They must not change physics.
67
+
68
+ ## Rigid balls
69
+
70
+ - `world`: gravity, air drag, floor and horizontal bounds, ground friction
71
+ - `bodies[]`: radius, mass, restitution, friction, velocity, angle and spin
72
+ - `impulses[]`: ordered time, target body, linear impulse and optional angular
73
+ impulse
74
+
75
+ Use impulses for a kick or hit. Do not author the resulting arc.
76
+
77
+ ## Surface wave
78
+
79
+ - `surface`: horizontal extent, rest height, sample count, speed, damping and
80
+ object-to-surface coupling
81
+ - `world`: gravity, air drag, water drag and buoyancy
82
+ - `body`: one rigid prop with size, mass, pose, velocity and restitution
83
+
84
+ This is a stylized 1D surface recipe. Do not claim volumetric fluid behavior.
@@ -0,0 +1,338 @@
1
+ const SVG_NS = "http://www.w3.org/2000/svg";
2
+
3
+ function svgNode(name, attributes = {}) {
4
+ const node = document.createElementNS(SVG_NS, name);
5
+ for (const [key, value] of Object.entries(attributes)) {
6
+ node.setAttribute(key, String(value));
7
+ }
8
+ return node;
9
+ }
10
+
11
+ function pathThrough(points) {
12
+ if (points.length === 0) return "";
13
+ let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
14
+ for (let index = 1; index < points.length - 1; index += 1) {
15
+ const point = points[index];
16
+ const next = points[index + 1];
17
+ path += ` Q ${point.x.toFixed(2)} ${point.y.toFixed(2)} ${((point.x + next.x) * 0.5).toFixed(2)} ${((point.y + next.y) * 0.5).toFixed(2)}`;
18
+ }
19
+ const last = points[points.length - 1];
20
+ path += ` T ${last.x.toFixed(2)} ${last.y.toFixed(2)}`;
21
+ return path;
22
+ }
23
+
24
+ function addGradient(defs, id, stops, attributes = {}) {
25
+ const gradient = svgNode("linearGradient", {
26
+ id,
27
+ x1: "0%",
28
+ y1: "0%",
29
+ x2: "100%",
30
+ y2: "100%",
31
+ ...attributes
32
+ });
33
+ for (const stop of stops) {
34
+ gradient.appendChild(svgNode("stop", stop));
35
+ }
36
+ defs.appendChild(gradient);
37
+ }
38
+
39
+ function createCrystal(column, size, color, gradientId, photoHref) {
40
+ const group = svgNode("g", { class: "oa-crystal" });
41
+ if (photoHref) {
42
+ const width = size * 2.7;
43
+ const height = width * 1.5;
44
+ group.appendChild(
45
+ svgNode("image", {
46
+ href: photoHref,
47
+ x: (-width * 0.5).toFixed(2),
48
+ y: "-4",
49
+ width: width.toFixed(2),
50
+ height: height.toFixed(2),
51
+ preserveAspectRatio: "xMidYMin meet",
52
+ decoding: "sync",
53
+ style: `filter:hue-rotate(${(column % 5) * 7 - 12}deg) saturate(${0.88 + (column % 3) * 0.09}) drop-shadow(2px 5px 3px rgba(34,26,14,0.25))`
54
+ })
55
+ );
56
+ return group;
57
+ }
58
+ const outline = `0,${-size} ${size * 0.72},${-size * 0.25} ${size * 0.5},${size * 0.66} 0,${size} ${-size * 0.5},${size * 0.66} ${-size * 0.72},${-size * 0.25}`;
59
+
60
+ group.append(
61
+ svgNode("ellipse", {
62
+ cx: 2.5,
63
+ cy: size + 5,
64
+ rx: size * 0.56,
65
+ ry: size * 0.18,
66
+ fill: "rgba(30,27,22,0.2)",
67
+ filter: "url(#oa-crystal-shadow)"
68
+ }),
69
+ svgNode("ellipse", {
70
+ cx: 0,
71
+ cy: -size - 4.5,
72
+ rx: size * 0.22,
73
+ ry: size * 0.11,
74
+ fill: "none",
75
+ stroke: "url(#oa-brass)",
76
+ "stroke-width": "2.1"
77
+ }),
78
+ svgNode("rect", {
79
+ x: -size * 0.23,
80
+ y: -size - 2.8,
81
+ width: size * 0.46,
82
+ height: size * 0.22,
83
+ rx: size * 0.08,
84
+ fill: "url(#oa-brass)",
85
+ stroke: "#5a431d",
86
+ "stroke-width": "0.6"
87
+ }),
88
+ svgNode("polygon", {
89
+ points: outline,
90
+ fill: `url(#${gradientId})`,
91
+ stroke: "#292720",
92
+ "stroke-width": "1.35",
93
+ "stroke-linejoin": "round"
94
+ }),
95
+ svgNode("polygon", {
96
+ points: `0,${-size} ${size * 0.17},${-size * 0.22} 0,${size} ${-size * 0.5},${size * 0.66} ${-size * 0.72},${-size * 0.25}`,
97
+ fill: "rgba(255,255,255,0.38)"
98
+ }),
99
+ svgNode("polygon", {
100
+ points: `0,${-size} ${size * 0.72},${-size * 0.25} ${size * 0.5},${size * 0.66} 0,${size} ${size * 0.17},${-size * 0.22}`,
101
+ fill: "rgba(29,36,41,0.13)"
102
+ }),
103
+ svgNode("polygon", {
104
+ points: `${-size * 0.72},${-size * 0.25} 0,${-size * 0.1} ${size * 0.72},${-size * 0.25} ${size * 0.5},${size * 0.66} 0,${size * 0.42} ${-size * 0.5},${size * 0.66}`,
105
+ fill: color,
106
+ opacity: "0.2"
107
+ }),
108
+ svgNode("path", {
109
+ d: `M ${-size * 0.72} ${-size * 0.25} L 0 ${-size * 0.1} L ${size * 0.72} ${-size * 0.25} M 0 ${-size} L 0 ${size} M ${-size * 0.5} ${size * 0.66} L 0 ${size * 0.42} L ${size * 0.5} ${size * 0.66}`,
110
+ stroke: "rgba(38,35,29,0.34)",
111
+ "stroke-width": "0.72",
112
+ fill: "none"
113
+ }),
114
+ svgNode("path", {
115
+ d: `M ${-size * 0.38} ${-size * 0.18} L ${-size * 0.04} ${-size * 0.52} M ${size * 0.08} ${size * 0.38} L ${size * 0.32} ${size * 0.12}`,
116
+ stroke: "rgba(255,255,255,0.86)",
117
+ "stroke-width": "1.1",
118
+ "stroke-linecap": "round"
119
+ }),
120
+ svgNode("circle", {
121
+ cx: -size * 0.25,
122
+ cy: -size * 0.34,
123
+ r: Math.max(0.8, size * 0.055),
124
+ fill: "rgba(255,255,255,0.95)"
125
+ })
126
+ );
127
+ return group;
128
+ }
129
+
130
+ function createBead(color, radius) {
131
+ const group = svgNode("g", { class: "oa-glass-bead" });
132
+ group.append(
133
+ svgNode("circle", {
134
+ r: radius + 0.8,
135
+ fill: "rgba(42,40,34,0.2)"
136
+ }),
137
+ svgNode("circle", {
138
+ r: radius,
139
+ fill: color,
140
+ stroke: "#3b382f",
141
+ "stroke-width": "0.75"
142
+ }),
143
+ svgNode("circle", {
144
+ cx: -radius * 0.34,
145
+ cy: -radius * 0.38,
146
+ r: Math.max(0.7, radius * 0.3),
147
+ fill: "rgba(255,255,255,0.72)"
148
+ }),
149
+ svgNode("circle", {
150
+ r: Math.max(0.45, radius * 0.17),
151
+ fill: "#554d3d"
152
+ })
153
+ );
154
+ return group;
155
+ }
156
+
157
+ export function createCrystalRenderer(svg, simulation, options = {}) {
158
+ const { scene, base, frames } = simulation;
159
+ if (!frames) {
160
+ throw new TypeError("The crystal renderer requires simulation frames.");
161
+ }
162
+
163
+ const { columns, rows } = scene.field;
164
+ const palette =
165
+ options.palette ?? ["#74b9b5", "#efc8c0", "#d98567", "#929ee5", "#e9b73e"];
166
+ const root = svgNode("g", { class: "oa-crystal-scene" });
167
+ const defs = svgNode("defs");
168
+
169
+ addGradient(defs, "oa-brass", [
170
+ { offset: "0%", "stop-color": "#6d4d18" },
171
+ { offset: "28%", "stop-color": "#e4bd62" },
172
+ { offset: "54%", "stop-color": "#fff0a5" },
173
+ { offset: "76%", "stop-color": "#a97625" },
174
+ { offset: "100%", "stop-color": "#4e3512" }
175
+ ]);
176
+ for (let column = 0; column < columns; column += 1) {
177
+ addGradient(defs, `oa-glass-${column}`, [
178
+ { offset: "0%", "stop-color": "#ffffff", "stop-opacity": "0.78" },
179
+ { offset: "24%", "stop-color": palette[column % palette.length], "stop-opacity": "0.55" },
180
+ { offset: "70%", "stop-color": palette[(column + 2) % palette.length], "stop-opacity": "0.9" },
181
+ { offset: "100%", "stop-color": "#354450", "stop-opacity": "0.36" }
182
+ ]);
183
+ }
184
+ const shadow = svgNode("filter", {
185
+ id: "oa-crystal-shadow",
186
+ x: "-50%",
187
+ y: "-50%",
188
+ width: "220%",
189
+ height: "240%"
190
+ });
191
+ shadow.appendChild(svgNode("feGaussianBlur", { stdDeviation: "2.4" }));
192
+ defs.appendChild(shadow);
193
+ root.appendChild(defs);
194
+
195
+ const railPath = `M ${base.anchorX[0] - 28} ${base.anchorY[0] - 9} Q ${scene.canvas.width / 2} ${base.anchorY[0] - 23} ${base.anchorX[columns - 1] + 28} ${base.anchorY[columns - 1] - 9}`;
196
+ const railGroup = svgNode("g", { class: "oa-crystal-rail" });
197
+ railGroup.append(
198
+ svgNode("path", {
199
+ d: railPath,
200
+ fill: "none",
201
+ stroke: "rgba(38,30,17,0.22)",
202
+ "stroke-width": "12",
203
+ "stroke-linecap": "round",
204
+ transform: "translate(0 4)",
205
+ filter: "url(#oa-crystal-shadow)"
206
+ }),
207
+ svgNode("path", {
208
+ d: railPath,
209
+ fill: "none",
210
+ stroke: "url(#oa-brass)",
211
+ "stroke-width": "9",
212
+ "stroke-linecap": "round"
213
+ }),
214
+ svgNode("path", {
215
+ d: railPath,
216
+ fill: "none",
217
+ stroke: "rgba(255,246,191,0.72)",
218
+ "stroke-width": "1.6",
219
+ "stroke-linecap": "round",
220
+ transform: "translate(0 -1.6)"
221
+ })
222
+ );
223
+
224
+ const cords = svgNode("g", {
225
+ class: "oa-crystal-cords",
226
+ fill: "none",
227
+ "stroke-linecap": "round",
228
+ "stroke-linejoin": "round"
229
+ });
230
+ const payloads = svgNode("g", {
231
+ class: "oa-crystal-payloads"
232
+ });
233
+ const hardware = svgNode("g", { class: "oa-crystal-hardware" });
234
+ root.append(railGroup, cords, payloads, hardware);
235
+ svg.appendChild(root);
236
+
237
+ const cordNodes = [];
238
+ const crystalNodes = [];
239
+ const beadNodes = [];
240
+
241
+ for (let column = 0; column < columns; column += 1) {
242
+ const under = svgNode("path", {
243
+ stroke: "rgba(42,36,25,0.62)",
244
+ "stroke-width": "1.45"
245
+ });
246
+ const shine = svgNode("path", {
247
+ stroke: "rgba(255,250,225,0.52)",
248
+ "stroke-width": "0.48",
249
+ transform: "translate(-0.45 0)"
250
+ });
251
+ cords.append(under, shine);
252
+ cordNodes.push({ under, shine });
253
+
254
+ const anchorX = base.anchorX[column];
255
+ const anchorY = base.anchorY[column] - 1;
256
+ hardware.append(
257
+ svgNode("circle", {
258
+ cx: anchorX,
259
+ cy: anchorY,
260
+ r: 3.55,
261
+ fill: "url(#oa-brass)",
262
+ stroke: "#4b3616",
263
+ "stroke-width": "0.9"
264
+ }),
265
+ svgNode("circle", {
266
+ cx: anchorX,
267
+ cy: anchorY,
268
+ r: 1.45,
269
+ fill: scene.render.background,
270
+ stroke: "#473717",
271
+ "stroke-width": "0.55"
272
+ }),
273
+ svgNode("path", {
274
+ d: `M ${anchorX - 2.2} ${anchorY + 4.5} q 2.2 3.2 4.4 0`,
275
+ fill: "none",
276
+ stroke: "#6f5630",
277
+ "stroke-width": "0.85",
278
+ "stroke-linecap": "round"
279
+ })
280
+ );
281
+
282
+ const crystal = createCrystal(
283
+ column,
284
+ 15 + (column % 4) * 2.15,
285
+ palette[column % palette.length],
286
+ `oa-glass-${column}`,
287
+ options.pendantHref
288
+ );
289
+ payloads.appendChild(crystal);
290
+ crystalNodes.push(crystal);
291
+
292
+ const beads = [];
293
+ for (const row of [3, 7, 10]) {
294
+ if (row >= rows - 1) continue;
295
+ const bead = createBead(
296
+ palette[(column + row) % palette.length],
297
+ row === 7 ? 3.65 : 2.75
298
+ );
299
+ payloads.appendChild(bead);
300
+ beads.push({ row, node: bead });
301
+ }
302
+ beadNodes.push(beads);
303
+ }
304
+
305
+ function renderFrame(frameIndex) {
306
+ const frame = frames[Math.max(0, Math.min(frames.length - 1, frameIndex))];
307
+ for (let column = 0; column < columns; column += 1) {
308
+ const points = [
309
+ { x: base.anchorX[column], y: base.anchorY[column] }
310
+ ];
311
+ for (let row = 0; row < rows; row += 1) {
312
+ const index = row * columns + column;
313
+ points.push({
314
+ x: base.x[index] + frame.x[index],
315
+ y: base.y[index] + frame.y[index]
316
+ });
317
+ }
318
+ const cordPath = pathThrough(points);
319
+ cordNodes[column].under.setAttribute("d", cordPath);
320
+ cordNodes[column].shine.setAttribute("d", cordPath);
321
+
322
+ const terminalIndex = (rows - 1) * columns + column;
323
+ crystalNodes[column].setAttribute(
324
+ "transform",
325
+ `translate(${(base.x[terminalIndex] + frame.x[terminalIndex]).toFixed(2)} ${(base.y[terminalIndex] + frame.y[terminalIndex]).toFixed(2)}) rotate(${frame.rotation[terminalIndex].toFixed(2)})`
326
+ );
327
+ for (const bead of beadNodes[column]) {
328
+ const index = bead.row * columns + column;
329
+ bead.node.setAttribute(
330
+ "transform",
331
+ `translate(${(base.x[index] + frame.x[index]).toFixed(2)} ${(base.y[index] + frame.y[index]).toFixed(2)})`
332
+ );
333
+ }
334
+ }
335
+ }
336
+
337
+ return { renderFrame };
338
+ }