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.
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/SECURITY.md +29 -0
- package/assets/photoreal/PROVENANCE.md +19 -0
- package/assets/photoreal/baseball.png +0 -0
- package/assets/photoreal/basketball.png +0 -0
- package/assets/photoreal/crystal.png +0 -0
- package/assets/photoreal/kicking-cleat.png +0 -0
- package/assets/photoreal/letter-charm-square.png +0 -0
- package/assets/photoreal/letter-charm.png +0 -0
- package/assets/photoreal/sneaker.png +0 -0
- package/assets/photoreal/soccer-ball.png +0 -0
- package/bin/oddlyalive.js +283 -0
- package/docs/ARCHITECTURE.md +55 -0
- package/docs/DEMO-VIDEOS.md +122 -0
- package/docs/QUICKSTART.md +115 -0
- package/docs/ROADMAP.md +33 -0
- package/examples/ball-lab/PROVENANCE.md +4 -0
- package/examples/ball-lab/app.js +33 -0
- package/examples/ball-lab/index.html +56 -0
- package/examples/ball-lab/scene.json +68 -0
- package/examples/crystal-mobile/PROVENANCE.md +4 -0
- package/examples/crystal-mobile/app.js +26 -0
- package/examples/crystal-mobile/index.html +56 -0
- package/examples/crystal-mobile/scene.json +69 -0
- package/examples/football-kick/PROVENANCE.md +4 -0
- package/examples/football-kick/app.js +33 -0
- package/examples/football-kick/index.html +56 -0
- package/examples/football-kick/scene.json +44 -0
- package/examples/gallery.css +379 -0
- package/examples/index.html +109 -0
- package/examples/shared/example.css +320 -0
- package/examples/shared/player.js +85 -0
- package/examples/shoe-splash/PROVENANCE.md +4 -0
- package/examples/shoe-splash/app.js +26 -0
- package/examples/shoe-splash/index.html +56 -0
- package/examples/shoe-splash/scene.json +38 -0
- package/examples/string-touch/PROVENANCE.md +11 -0
- package/examples/string-touch/app.js +79 -0
- package/examples/string-touch/index.html +70 -0
- package/examples/string-touch/scene.json +73 -0
- package/examples/string-touch/styles.css +273 -0
- package/package.json +82 -0
- package/schemas/scene.schema.json +336 -0
- package/scripts/render-demo-videos.sh +77 -0
- package/scripts/serve.js +88 -0
- package/scripts/update-visual-fixtures.mjs +60 -0
- package/scripts/verify-demo-videos.mjs +138 -0
- package/skills/oddlyalive/SKILL.md +68 -0
- package/skills/oddlyalive/agents/openai.yaml +4 -0
- package/skills/oddlyalive/references/object-models.md +25 -0
- package/skills/oddlyalive/references/recipes.md +27 -0
- package/skills/oddlyalive/references/scene-schema.md +84 -0
- package/src/crystal-renderer.js +338 -0
- package/src/gesture.js +58 -0
- package/src/index.js +44 -0
- package/src/rigid-balls.js +304 -0
- package/src/rigid-renderer.js +760 -0
- package/src/rigid-scene.js +223 -0
- package/src/scene-registry.js +17 -0
- package/src/scene.js +206 -0
- package/src/state-hash.js +17 -0
- package/src/strands.js +773 -0
- package/src/surface-wave.js +209 -0
- package/src/svg-renderer.js +348 -0
- package/src/wave-renderer.js +463 -0
- package/src/wave-scene.js +163 -0
- package/tests/cli.test.js +101 -0
- package/tests/determinism.test.js +57 -0
- package/tests/fixtures/visual-baselines.json +27 -0
- package/tests/rigid-balls.test.js +77 -0
- package/tests/scene.test.js +78 -0
- package/tests/surface-wave.test.js +39 -0
- package/tests/visual-fixtures.test.js +43 -0
- package/videos/oddlyalive-demos/BRIEF.md +40 -0
- package/videos/oddlyalive-demos/DEMO-PLAN.md +13 -0
- package/videos/oddlyalive-demos/README.md +45 -0
- package/videos/oddlyalive-demos/app.js +212 -0
- package/videos/oddlyalive-demos/hyperframes.json +9 -0
- package/videos/oddlyalive-demos/index.html +246 -0
- package/videos/oddlyalive-demos/meta.json +5 -0
- package/videos/oddlyalive-demos/package-lock.json +506 -0
- package/videos/oddlyalive-demos/package.json +19 -0
- 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,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
|
+
}
|