jailedthreejs 0.9.2-beta.0
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/README.md +141 -0
- package/dist/NoScope.js +208 -0
- package/dist/Train.js +349 -0
- package/dist/artist.js +492 -0
- package/dist/cell.js +508 -0
- package/dist/index.js +12 -0
- package/dist/main.js +136 -0
- package/dist/utils.js +300 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# JailedThreeJS
|
|
2
|
+
|
|
3
|
+
CSS-first scaffolding for Three.js. Drop a `<cell>` on the page, describe your scene in HTML, tweak it with CSS custom properties, sprinkle behaviour with plain JS. The engine does the wiring (renderer, cameras, raycasters, resize handling) so you can stay inside web platform muscle memory.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## TL;DR
|
|
8
|
+
|
|
9
|
+
- **Structure**: Every tag under a `<cell>` becomes its matching `THREE.Object3D` (`<mesh>`, `<group>`, `<perspectivecamera>`, `<axeshelper>`, …).
|
|
10
|
+
- **Style**: Custom properties map to object state: `--position`, `--rotation`, `--scale`, `--geometry`, `--material-color`, `--transition`, etc.
|
|
11
|
+
- **Interact**: Native DOM events (`onclick`, `onmouseover`, `ondblclick`, `oncontextmenu`, …) and pseudo-classes (`:hover`, `:focus`, `:active`) work on meshes.
|
|
12
|
+
- **Animate**: Declare transitions or CSS `@keyframes` and the painter lerps the underlying Three.js values.
|
|
13
|
+
- **Assets**: Reference built-ins (`@cube`, `@plane`, `@sphere`, `@torus`) or define custom `@asset` rules that fetch GLTF/GLB/FBX/textures/audio.
|
|
14
|
+
- **API access**: You can always grab the underlying `Cell`/`THREE.Object3D` to run imperative code when needed.
|
|
15
|
+
|
|
16
|
+
> Philosophy: embrace HTML for structure, CSS for look, JS for intent. No DSLs, no scene JSON. When you want raw Three.js, it’s still there.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
The repo ships as ES modules. Point your bundler (or `<script type="module">`) to the `src/module` folder.
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<script type="module">
|
|
26
|
+
import { JThree } from './module/index.js';
|
|
27
|
+
// All <cell> elements are converted on import.
|
|
28
|
+
</script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If you add cells later (e.g. via a framework), call `JThree.init_convert()` to rescan the DOM.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<cell id="demo" style="display:block;width:560px;height:320px;background:#05070f">
|
|
39
|
+
<perspectivecamera render></perspectivecamera>
|
|
40
|
+
<mesh id="box" class="box" onclick="spin(this)"></mesh>
|
|
41
|
+
</cell>
|
|
42
|
+
|
|
43
|
+
<style>
|
|
44
|
+
.box {
|
|
45
|
+
--geometry: @cube;
|
|
46
|
+
--position: (0,0,-5);
|
|
47
|
+
--scale: (1,1,1);
|
|
48
|
+
--material-color: (0.8,0.2,0.2);
|
|
49
|
+
--transition: 400ms ease-in-out;
|
|
50
|
+
}
|
|
51
|
+
.box:hover { --material-color: (0.2,0.8,0.9); }
|
|
52
|
+
.box:active { --scale: (1.2,1.2,1.2); }
|
|
53
|
+
</style>
|
|
54
|
+
|
|
55
|
+
<script type="module">
|
|
56
|
+
import { Cell } from './module/index.js';
|
|
57
|
+
const cell = Cell.getCell(document.getElementById('demo'));
|
|
58
|
+
const box = cell.getConvictById('box');
|
|
59
|
+
|
|
60
|
+
cell.addUpdateFunction(function () { box.rotation.y += 0.01; });
|
|
61
|
+
window.spin = (mesh) => mesh.style.setProperty('--rotation-y', box.rotation.y + Math.PI);
|
|
62
|
+
</script>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That is the entire scene: no manual `WebGLRenderer`, no raycaster setup, no resize bookkeeping.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Styling cheat sheet
|
|
70
|
+
|
|
71
|
+
| Purpose | Custom property examples |
|
|
72
|
+
|--------------------|--------------------------------------------------------------------------------|
|
|
73
|
+
| Transform | `--position: (x,y,z)` · `--rotation: (radX,radY,radZ)` · `--scale: (sx,sy,sz)` |
|
|
74
|
+
| Geometry/material | `--geometry: @cube` · `--material-color: (r,g,b)` · `--material-roughness: 0.5` |
|
|
75
|
+
| Cameras | `--fov` · `--near` · `--far` · `--aspect` (auto-updated unless overridden) |
|
|
76
|
+
| Lights | `--intensity` · `--distance` · `--color` · `--decay` |
|
|
77
|
+
| Transitions | `--transition: 300ms ease` (or set `object.transition = { duration, timing }`) |
|
|
78
|
+
| Animations | Use CSS `@keyframes` where frames set the same custom props. |
|
|
79
|
+
| Assets | `--geometry: @MySpaceship` (load via `@MySpaceship { url: './ship.glb'; }`) |
|
|
80
|
+
|
|
81
|
+
> Any custom property prefixed with `--` is forwarded to the object through a best-effort parser (numbers/arrays/assets/refs). Unknown props are ignored with a console warning.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Runtime API
|
|
86
|
+
|
|
87
|
+
| API | Description |
|
|
88
|
+
|---------------------|-------------------------------------------------------------------------------------------------------------------|
|
|
89
|
+
| `JThree.init_convert()` | Rescan the DOM for `<cell>` tags and boot them. Automatically run once on import. |
|
|
90
|
+
| `Cell.getCell(element)` | Retrieve the `Cell` controller attached to a `<cell>` DOM node. |
|
|
91
|
+
| `cell.addUpdateFunction(fn)` | Register a per-frame callback (runs inside the render loop). |
|
|
92
|
+
| `cell.getConvictById(id)` / `getConvictByDom(dom)` | Fetch the underlying `THREE.Object3D` created for a DOM element. |
|
|
93
|
+
| `cell.getConvictsByClass(cls)` | Batch lookup by class name (mirrors `document.getElementsByClassName`). |
|
|
94
|
+
| `cell.removeConvict(object)` | Remove an object and clean up book-keeping. |
|
|
95
|
+
| `object.transition = { duration, timing }` | Enable JS-driven interpolation for subsequent style changes. |
|
|
96
|
+
| `object.animation = { name, duration, iteration }` | Apply a CSS `@keyframes` animation to an object’s custom props. |
|
|
97
|
+
|
|
98
|
+
You always have access to the raw `THREE.Object3D` instance (`convict`). Use it for low-level operations, loaders, shaders, etc.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Events & pseudo-classes
|
|
103
|
+
|
|
104
|
+
- Add DOM event attributes (`onclick`, `onmouseover`, `ondblclick`, `onmousedown`, `onmouseup`, `oncontextmenu`). They receive a synthetic event with `{ target3d, targetCell, pointerPosition, originalEvt }`.
|
|
105
|
+
- Use CSS pseudo-classes (`:hover`, `:focus`, `:active`). The runtime keeps class flags in sync with pointer/click states and repaints affected objects.
|
|
106
|
+
- `object.layers` is managed automatically: pickable meshes are moved to layer 3 only when they need interaction, saving raycast cost.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Assets & `@rules`
|
|
111
|
+
|
|
112
|
+
Declare assets in CSS alongside the rest of your styles:
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
@MySpaceship {
|
|
116
|
+
url: "./models/ship.glb";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hero {
|
|
120
|
+
--geometry: @MySpaceship;
|
|
121
|
+
--position: (0,0,-10);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
First time the painter sees `@MySpaceship`, it loads the GLB (or FBX, texture, audio, material JSON), caches it, and applies it wherever referenced. Built-ins `@cube`, `@sphere`, `@plane`, and `@torus` ship for quick sketches.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
## Tips & gotchas
|
|
131
|
+
|
|
132
|
+
- **Inline styles vs stylesheet rules**: inline `style=""` attributes on `<mesh>` nodes work the same as CSS rules. The painter merges them in the order: base class → id → pseudo-class → inline.
|
|
133
|
+
- **Transitions**: if you define `object.transition`, only numeric/array props are animated. Non-numeric values (like `@asset` references) swap instantly.
|
|
134
|
+
- **Async assets**: `--geometry: @MyGLTF` assigns a promise until the loader resolves. The painter applies final values when the promise settles; don’t mutate those props synchronously.
|
|
135
|
+
- **Cleanup**: If you inject scripts that schedule loops/intervals, register a cleanup handler via `registerSceneCleanup(fn)` so the editor or export can dispose them reliably.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT © 2025. Use it in games, demos, dashboards, art toys, education—wherever CSS-driven 3D makes sense. Contributions welcome!
|
package/dist/NoScope.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// NoScope.js
|
|
2
|
+
//
|
|
3
|
+
// Centralised event handling.
|
|
4
|
+
// Shared THREE.Raycaster + NDC pointer for all cells; all pickable
|
|
5
|
+
// objects live on layer 3.
|
|
6
|
+
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
import { paintExtraCell, paintSpecificMuse } from './artist.js';
|
|
9
|
+
import { fastRemove_arry } from './utils.js';
|
|
10
|
+
|
|
11
|
+
const raycaster = new THREE.Raycaster();
|
|
12
|
+
const ndcPointer = new THREE.Vector2();
|
|
13
|
+
|
|
14
|
+
// Only objects on layer 3 are considered pickable.
|
|
15
|
+
raycaster.layers.set(3);
|
|
16
|
+
|
|
17
|
+
/* Flag helpers */
|
|
18
|
+
|
|
19
|
+
function addFlag(arr, flag) {
|
|
20
|
+
if (!arr.includes(flag)) arr.push(flag);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function delFlag(arr, flag) {
|
|
24
|
+
fastRemove_arry(arr, flag);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Public handlers */
|
|
28
|
+
|
|
29
|
+
export function default_onCellClick_method(domEvt, cell) {
|
|
30
|
+
const hit = cell._last_cast_caught;
|
|
31
|
+
if (!hit) return;
|
|
32
|
+
|
|
33
|
+
addFlag(hit.userData.extraParams, ':focus');
|
|
34
|
+
|
|
35
|
+
const synth = {
|
|
36
|
+
type: 'cellclick',
|
|
37
|
+
originalEvt: domEvt,
|
|
38
|
+
target3d: hit,
|
|
39
|
+
targetCell: cell,
|
|
40
|
+
targetElement: hit.userData.domEl,
|
|
41
|
+
pointerPosition: cell._lastHitPosition
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
hit.userData.domEl.onclick?.call(hit.userData.domEl, synth);
|
|
45
|
+
paintExtraCell(cell);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function default_onCellPointerMove_method(domEvt, cell) {
|
|
49
|
+
if (!cell.focusedCamera) return;
|
|
50
|
+
|
|
51
|
+
_raycast(domEvt, cell.focusedCamera, cell.cellElm);
|
|
52
|
+
|
|
53
|
+
const hitResult = raycaster.intersectObjects(cell.loadedScene.children, true)[0];
|
|
54
|
+
const lastHit = cell._last_cast_caught;
|
|
55
|
+
|
|
56
|
+
if (hitResult) {
|
|
57
|
+
const hitObject = hitResult.object;
|
|
58
|
+
|
|
59
|
+
if (hitObject !== lastHit) {
|
|
60
|
+
if (lastHit) {
|
|
61
|
+
delFlag(lastHit.userData.extraParams, ':hover');
|
|
62
|
+
lastHit.userData.domEl.onmouseleave?.call(lastHit.userData.domEl, {
|
|
63
|
+
type: 'cellmouseleave',
|
|
64
|
+
originalEvt: domEvt,
|
|
65
|
+
target3d: lastHit,
|
|
66
|
+
targetCell: cell,
|
|
67
|
+
targetElement: lastHit.userData.domEl,
|
|
68
|
+
pointerPosition: cell._lastHitPosition
|
|
69
|
+
});
|
|
70
|
+
paintSpecificMuse(lastHit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cell._last_cast_caught = hitObject;
|
|
74
|
+
|
|
75
|
+
hitObject.userData.domEl.onmouseenter?.call(hitObject.userData.domEl, {
|
|
76
|
+
type: 'cellmouseenter',
|
|
77
|
+
originalEvt: domEvt,
|
|
78
|
+
target3d: hitObject,
|
|
79
|
+
targetCell: cell,
|
|
80
|
+
targetElement: hitObject.userData.domEl,
|
|
81
|
+
pointerPosition: hitResult.point
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
addFlag(hitObject.userData.extraParams, ':hover');
|
|
86
|
+
cell._lastHitPosition = hitResult.point;
|
|
87
|
+
|
|
88
|
+
hitObject.userData.domEl.onmouseover?.call(hitObject.userData.domEl, {
|
|
89
|
+
type: 'cellhover',
|
|
90
|
+
originalEvt: domEvt,
|
|
91
|
+
target3d: hitObject,
|
|
92
|
+
targetCell: cell,
|
|
93
|
+
targetElement: hitObject.userData.domEl,
|
|
94
|
+
pointerPosition: hitResult.point
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
paintExtraCell(cell);
|
|
98
|
+
} else if (lastHit) {
|
|
99
|
+
delFlag(lastHit.userData.extraParams, ':hover');
|
|
100
|
+
lastHit.userData.domEl.onmouseleave?.call(lastHit.userData.domEl, {
|
|
101
|
+
type: 'cellmouseleave',
|
|
102
|
+
originalEvt: domEvt,
|
|
103
|
+
target3d: lastHit,
|
|
104
|
+
targetCell: cell,
|
|
105
|
+
targetElement: lastHit.userData.domEl,
|
|
106
|
+
pointerPosition: cell._lastHitPosition
|
|
107
|
+
});
|
|
108
|
+
paintSpecificMuse(lastHit);
|
|
109
|
+
cell._last_cast_caught = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function default_onCellMouseDown_method(domEvt, cell) {
|
|
114
|
+
const hit = cell._last_cast_caught;
|
|
115
|
+
if (!hit) return;
|
|
116
|
+
|
|
117
|
+
addFlag(hit.userData.extraParams, ':active');
|
|
118
|
+
|
|
119
|
+
const synth = {
|
|
120
|
+
type: 'celldown',
|
|
121
|
+
originalEvt: domEvt,
|
|
122
|
+
target3d: hit,
|
|
123
|
+
targetCell: cell,
|
|
124
|
+
targetElement: hit.userData.domEl,
|
|
125
|
+
pointerPosition: cell._lastHitPosition
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
hit.userData.domEl.onmousedown?.call(hit.userData.domEl, synth);
|
|
129
|
+
paintExtraCell(cell);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function default_onCellMouseUp_method(domEvt, cell) {
|
|
133
|
+
const hit = cell._last_cast_caught;
|
|
134
|
+
if (!hit) return;
|
|
135
|
+
|
|
136
|
+
delFlag(hit.userData.extraParams, ':active');
|
|
137
|
+
|
|
138
|
+
const synth = {
|
|
139
|
+
type: 'cellup',
|
|
140
|
+
originalEvt: domEvt,
|
|
141
|
+
target3d: hit,
|
|
142
|
+
targetCell: cell,
|
|
143
|
+
targetElement: hit.userData.domEl,
|
|
144
|
+
pointerPosition: cell._lastHitPosition
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
hit.userData.domEl.onmouseup?.call(hit.userData.domEl, synth);
|
|
148
|
+
paintExtraCell(cell);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function default_onCellDoubleClick_method(domEvt, cell) {
|
|
152
|
+
const hit = cell._last_cast_caught;
|
|
153
|
+
if (!hit) return;
|
|
154
|
+
|
|
155
|
+
addFlag(hit.userData.extraParams, ':focus');
|
|
156
|
+
|
|
157
|
+
const synth = {
|
|
158
|
+
type: 'celldblclick',
|
|
159
|
+
originalEvt: domEvt,
|
|
160
|
+
target3d: hit,
|
|
161
|
+
targetCell: cell,
|
|
162
|
+
targetElement: hit.userData.domEl,
|
|
163
|
+
pointerPosition: cell._lastHitPosition
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
hit.userData.domEl.ondblclick?.call(hit.userData.domEl, synth);
|
|
167
|
+
paintExtraCell(cell);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function default_onCellContextMenu_method(domEvt, cell) {
|
|
171
|
+
const hit = cell._last_cast_caught;
|
|
172
|
+
if (!hit) return;
|
|
173
|
+
|
|
174
|
+
const synth = {
|
|
175
|
+
type: 'cellcontextmenu',
|
|
176
|
+
originalEvt: domEvt,
|
|
177
|
+
target3d: hit,
|
|
178
|
+
targetCell: cell,
|
|
179
|
+
targetElement: hit.userData.domEl,
|
|
180
|
+
pointerPosition: cell._lastHitPosition
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
hit.userData.domEl.oncontextmenu?.call(hit.userData.domEl, synth);
|
|
184
|
+
paintExtraCell(cell);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Internal raycast helper */
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {MouseEvent} domEvt
|
|
191
|
+
* @param {THREE.Camera} camera
|
|
192
|
+
* @param {HTMLElement} referenceEl
|
|
193
|
+
*/
|
|
194
|
+
function _raycast(domEvt, camera, referenceEl) {
|
|
195
|
+
if (!camera) return;
|
|
196
|
+
|
|
197
|
+
const targetEl = referenceEl || domEvt.currentTarget || domEvt.target;
|
|
198
|
+
const rect = targetEl.getBoundingClientRect();
|
|
199
|
+
|
|
200
|
+
camera.updateMatrixWorld();
|
|
201
|
+
|
|
202
|
+
ndcPointer.set(
|
|
203
|
+
((domEvt.clientX - rect.left) / rect.width) * 2 - 1,
|
|
204
|
+
(-(domEvt.clientY - rect.top) / rect.height) * 2 + 1
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
raycaster.setFromCamera(ndcPointer, camera);
|
|
208
|
+
}
|
package/dist/Train.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Train.js
|
|
2
|
+
//
|
|
3
|
+
// Interpolation / animation helpers used by JailedThreeJS.
|
|
4
|
+
// - Numeric lerping
|
|
5
|
+
// - Cubic-bezier easing
|
|
6
|
+
// - Generic value interpolation (numbers + arrays)
|
|
7
|
+
// - Time-based transitions over JS values
|
|
8
|
+
// - CSS keyframe-driven animation for custom props
|
|
9
|
+
|
|
10
|
+
import { exchange_rule, deep_searchParms, CSSValueTo3JSValue } from './artist.js';
|
|
11
|
+
import { getAnimationMap } from './utils.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Linearly interpolate between two numbers.
|
|
15
|
+
* @param {number} a
|
|
16
|
+
* @param {number} b
|
|
17
|
+
* @param {number} t in [0, 1]
|
|
18
|
+
*/
|
|
19
|
+
export function lerpNumber(a, b, t) {
|
|
20
|
+
return a + (b - a) * t;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Interpolate arrays with tolerant length handling.
|
|
25
|
+
*
|
|
26
|
+
* Length rule:
|
|
27
|
+
* - For indices where both arrays have a value → lerp them.
|
|
28
|
+
* - For indices that exist only in `from` → keep `from[i]`.
|
|
29
|
+
* - For indices that exist only in `to` → keep `to[i]`.
|
|
30
|
+
*
|
|
31
|
+
* Example:
|
|
32
|
+
* [x1,y1,z1,q1] → [x2,y2,z2]
|
|
33
|
+
* ==>
|
|
34
|
+
* [lerp(x1,x2), lerp(y1,y2), lerp(z1,z2), q1]
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<number>} from
|
|
37
|
+
* @param {Array<number>} to
|
|
38
|
+
* @param {number} t
|
|
39
|
+
* @param {(a:number,b:number,t:number)=>number} lerpMethod
|
|
40
|
+
*/
|
|
41
|
+
function lerpArray(from, to, t, lerpMethod) {
|
|
42
|
+
const maxLen = Math.max(from.length, to.length);
|
|
43
|
+
const out = new Array(maxLen);
|
|
44
|
+
for (let i = 0; i < maxLen; i++) {
|
|
45
|
+
const hasFrom = i < from.length;
|
|
46
|
+
const hasTo = i < to.length;
|
|
47
|
+
if (hasFrom && hasTo) {
|
|
48
|
+
out[i] = lerpMethod(from[i], to[i], t);
|
|
49
|
+
} else if (hasFrom) {
|
|
50
|
+
out[i] = from[i];
|
|
51
|
+
} else {
|
|
52
|
+
out[i] = to[i];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Interpolate numbers or arrays using the provided lerp method.
|
|
60
|
+
*
|
|
61
|
+
* @param {number|Array<number>} from
|
|
62
|
+
* @param {number|Array<number>} to
|
|
63
|
+
* @param {number} t
|
|
64
|
+
* @param {(a:number,b:number,t:number)=>number} lerpMethod
|
|
65
|
+
*/
|
|
66
|
+
export function lerpValue(from, to, t, lerpMethod = lerpNumber) {
|
|
67
|
+
const isNum = v => typeof v === 'number';
|
|
68
|
+
const isArr = Array.isArray;
|
|
69
|
+
|
|
70
|
+
if (isNum(from) && isNum(to)) {
|
|
71
|
+
return lerpMethod(from, to, t);
|
|
72
|
+
}
|
|
73
|
+
if (isArr(from) && isArr(to)) {
|
|
74
|
+
return lerpArray(from, to, t, lerpMethod);
|
|
75
|
+
}
|
|
76
|
+
// Mixed or unsupported types: just return `to` instantly.
|
|
77
|
+
return to;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a cubic-bezier easing function.
|
|
82
|
+
* Implementation adapted from https://github.com/gre/bezier-easing
|
|
83
|
+
*/
|
|
84
|
+
function cubicBezier(p0, p1, p2, p3) {
|
|
85
|
+
const cx = 3 * p0;
|
|
86
|
+
const bx = 3 * (p2 - p0) - cx;
|
|
87
|
+
const ax = 1 - cx - bx;
|
|
88
|
+
const cy = 3 * p1;
|
|
89
|
+
const by = 3 * (p3 - p1) - cy;
|
|
90
|
+
const ay = 1 - cy - by;
|
|
91
|
+
|
|
92
|
+
function sampleCurveX(t) {
|
|
93
|
+
return ((ax * t + bx) * t + cx) * t;
|
|
94
|
+
}
|
|
95
|
+
function sampleCurveY(t) {
|
|
96
|
+
return ((ay * t + by) * t + cy) * t;
|
|
97
|
+
}
|
|
98
|
+
function sampleCurveDerivativeX(t) {
|
|
99
|
+
return (3 * ax * t + 2 * bx) * t + cx;
|
|
100
|
+
}
|
|
101
|
+
function solveTforX(x) {
|
|
102
|
+
let t = x;
|
|
103
|
+
for (let i = 0; i < 4; i++) {
|
|
104
|
+
const dx = sampleCurveX(t) - x;
|
|
105
|
+
if (Math.abs(dx) < 1e-6) return t;
|
|
106
|
+
t -= dx / sampleCurveDerivativeX(t);
|
|
107
|
+
}
|
|
108
|
+
return t;
|
|
109
|
+
}
|
|
110
|
+
return x => sampleCurveY(solveTforX(x));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve an easing function from a CSS timing function string.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} timingFunction
|
|
117
|
+
* @returns {(t:number)=>number}
|
|
118
|
+
*/
|
|
119
|
+
function _get_Equation(timingFunction) {
|
|
120
|
+
switch (timingFunction) {
|
|
121
|
+
case 'linear':
|
|
122
|
+
return t => t;
|
|
123
|
+
case 'ease':
|
|
124
|
+
return cubicBezier(0.25, 0.1, 0.25, 1.0);
|
|
125
|
+
case 'ease-in':
|
|
126
|
+
return cubicBezier(0.42, 0, 1.0, 1.0);
|
|
127
|
+
case 'ease-out':
|
|
128
|
+
return cubicBezier(0, 0, 0.58, 1.0);
|
|
129
|
+
case 'ease-in-out':
|
|
130
|
+
return cubicBezier(0.42, 0, 0.58, 1.0);
|
|
131
|
+
default:
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof timingFunction === 'string' && timingFunction.startsWith('cubic-bezier')) {
|
|
136
|
+
const match = timingFunction.match(/cubic-bezier\(([^)]+)\)/);
|
|
137
|
+
if (match) {
|
|
138
|
+
const nums = match[1]
|
|
139
|
+
.split(/[, ]+/)
|
|
140
|
+
.map(Number)
|
|
141
|
+
.filter(n => !Number.isNaN(n));
|
|
142
|
+
if (nums.length === 4) {
|
|
143
|
+
return cubicBezier(nums[0], nums[1], nums[2], nums[3]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Fallback to linear on garbage.
|
|
149
|
+
return t => t;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Animate between two values over time.
|
|
154
|
+
*
|
|
155
|
+
* - Supports numbers and arrays (arrays follow the tolerant rule above).
|
|
156
|
+
* - Non-animatable values resolve instantly (onUpdate + onComplete with `to`).
|
|
157
|
+
*
|
|
158
|
+
* @param {number|Array<number>} from
|
|
159
|
+
* @param {number|Array<number>} to
|
|
160
|
+
* @param {number} durationMs
|
|
161
|
+
* @param {(value:any, easedT:number) => void} onUpdate
|
|
162
|
+
* @param {(finalValue:any) => void} [onComplete]
|
|
163
|
+
* @param {string} [timingFunction='linear']
|
|
164
|
+
*/
|
|
165
|
+
export function animateLerp(
|
|
166
|
+
from,
|
|
167
|
+
to,
|
|
168
|
+
durationMs,
|
|
169
|
+
onUpdate,
|
|
170
|
+
onComplete,
|
|
171
|
+
timingFunction = 'linear'
|
|
172
|
+
) {
|
|
173
|
+
const isAnimatable = v =>
|
|
174
|
+
typeof v === 'number' || Array.isArray(v);
|
|
175
|
+
|
|
176
|
+
const finishInstant = () => {
|
|
177
|
+
if (onUpdate) onUpdate(to, 1);
|
|
178
|
+
if (onComplete) onComplete(to);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (!isAnimatable(from) || !isAnimatable(to)) {
|
|
182
|
+
finishInstant();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
187
|
+
finishInstant();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const start = performance.now();
|
|
192
|
+
const ease = _get_Equation(timingFunction);
|
|
193
|
+
|
|
194
|
+
function step(now) {
|
|
195
|
+
let t = (now - start) / durationMs;
|
|
196
|
+
if (t >= 1) t = 1;
|
|
197
|
+
const easedT = ease(t);
|
|
198
|
+
const value = lerpValue(from, to, easedT, lerpNumber);
|
|
199
|
+
if (onUpdate) onUpdate(value, easedT);
|
|
200
|
+
if (t < 1) {
|
|
201
|
+
requestAnimationFrame(step);
|
|
202
|
+
} else if (onComplete) {
|
|
203
|
+
onComplete(value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
requestAnimationFrame(step);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse a keyframe time string into milliseconds.
|
|
212
|
+
*
|
|
213
|
+
* Supports:
|
|
214
|
+
* - 'from' / 'to'
|
|
215
|
+
* - percentages ('0%', '50%', '100%')
|
|
216
|
+
* - '123ms'
|
|
217
|
+
* - bare numbers treated as ms
|
|
218
|
+
*
|
|
219
|
+
* @param {string} keyText
|
|
220
|
+
* @param {number} totalDuration
|
|
221
|
+
*/
|
|
222
|
+
function parseKeyframeTime(keyText, totalDuration) {
|
|
223
|
+
if (!keyText) return 0;
|
|
224
|
+
const text = String(keyText).trim().toLowerCase();
|
|
225
|
+
|
|
226
|
+
if (text === 'from') return 0;
|
|
227
|
+
if (text === 'to') return totalDuration;
|
|
228
|
+
|
|
229
|
+
if (text.endsWith('%')) {
|
|
230
|
+
const v = parseFloat(text);
|
|
231
|
+
return Number.isFinite(v) ? (v / 100) * totalDuration : 0;
|
|
232
|
+
}
|
|
233
|
+
if (text.endsWith('ms')) {
|
|
234
|
+
const v = parseFloat(text);
|
|
235
|
+
return Number.isFinite(v) ? v : 0;
|
|
236
|
+
}
|
|
237
|
+
const n = Number(text);
|
|
238
|
+
return Number.isFinite(n) ? n : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Apply a CSS @keyframes animation to a Three.js object.
|
|
243
|
+
*
|
|
244
|
+
* @param {THREE.Object3D} object
|
|
245
|
+
* @param {{
|
|
246
|
+
* name: string,
|
|
247
|
+
* duration: number,
|
|
248
|
+
* timing?: { fun?: string },
|
|
249
|
+
* iteration?: { count?: number | string }
|
|
250
|
+
* }} animationObj
|
|
251
|
+
*/
|
|
252
|
+
export async function KeyFrameAnimationLerp(object, animationObj) {
|
|
253
|
+
if (!object || !animationObj?.name || !animationObj?.duration) return;
|
|
254
|
+
|
|
255
|
+
const keyFramesRule = getAnimationMap(animationObj.name);
|
|
256
|
+
if (!keyFramesRule || !keyFramesRule.cssRules) {
|
|
257
|
+
console.error(`Animation "${animationObj.name}" not found or has no rules.`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const duration = animationObj.duration;
|
|
262
|
+
|
|
263
|
+
const rules = Array.from(keyFramesRule.cssRules).slice();
|
|
264
|
+
rules.sort(
|
|
265
|
+
(a, b) =>
|
|
266
|
+
parseKeyframeTime(a.keyText, duration) -
|
|
267
|
+
parseKeyframeTime(b.keyText, duration)
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const runOnce = async () => {
|
|
271
|
+
for (let i = 0; i < rules.length - 1; i++) {
|
|
272
|
+
const fromRule = rules[i];
|
|
273
|
+
const toRule = rules[i + 1];
|
|
274
|
+
const t0 = parseKeyframeTime(fromRule.keyText, duration);
|
|
275
|
+
const t1 = parseKeyframeTime(toRule.keyText, duration);
|
|
276
|
+
const segmentMs = t1 - t0;
|
|
277
|
+
if (segmentMs <= 0) continue;
|
|
278
|
+
|
|
279
|
+
const fromProps = {};
|
|
280
|
+
const toProps = {};
|
|
281
|
+
|
|
282
|
+
// Collect animatable custom props from "from" frame
|
|
283
|
+
for (const propName of fromRule.style) {
|
|
284
|
+
const raw = fromRule.style.getPropertyValue(propName);
|
|
285
|
+
// custom props are expected to be `--foo-bar`
|
|
286
|
+
if (!propName.startsWith('--')) continue;
|
|
287
|
+
fromProps[propName.slice(2)] = CSSValueTo3JSValue(raw, object);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Collect matching props from "to" frame
|
|
291
|
+
for (const propName of toRule.style) {
|
|
292
|
+
const raw = toRule.style.getPropertyValue(propName);
|
|
293
|
+
if (!propName.startsWith('--')) continue;
|
|
294
|
+
toProps[propName.slice(2)] = CSSValueTo3JSValue(raw, object);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const keys = Object.keys(fromProps).filter(k => k in toProps);
|
|
298
|
+
if (!keys.length) continue;
|
|
299
|
+
|
|
300
|
+
await Promise.all(
|
|
301
|
+
keys.map(key => {
|
|
302
|
+
const fromVal = fromProps[key];
|
|
303
|
+
const toVal = toProps[key];
|
|
304
|
+
|
|
305
|
+
// Resolve async assets before animating.
|
|
306
|
+
const resolveValue = v =>
|
|
307
|
+
(v && typeof v.then === 'function')
|
|
308
|
+
? v
|
|
309
|
+
: Promise.resolve(v);
|
|
310
|
+
|
|
311
|
+
return Promise.all([resolveValue(fromVal), resolveValue(toVal)])
|
|
312
|
+
.then(([resolvedFrom, resolvedTo]) => new Promise(resolve => {
|
|
313
|
+
animateLerp(
|
|
314
|
+
resolvedFrom,
|
|
315
|
+
resolvedTo,
|
|
316
|
+
segmentMs,
|
|
317
|
+
v => {
|
|
318
|
+
const { parent, key: finalKey } = deep_searchParms(object, key.split('-'));
|
|
319
|
+
exchange_rule(parent, finalKey, v);
|
|
320
|
+
},
|
|
321
|
+
resolve,
|
|
322
|
+
animationObj.timing?.fun || 'linear'
|
|
323
|
+
);
|
|
324
|
+
}));
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
let iterationCount = animationObj.iteration?.count ?? 1;
|
|
331
|
+
if (iterationCount === 'infinity' || iterationCount === 'infinite') {
|
|
332
|
+
iterationCount = Infinity;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (iterationCount === Infinity) {
|
|
336
|
+
// infinite loop – deliberately never resolves
|
|
337
|
+
// eslint-disable-next-line no-constant-condition
|
|
338
|
+
while (true) {
|
|
339
|
+
// eslint-disable-next-line no-await-in-loop
|
|
340
|
+
await runOnce();
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
const total = Number(iterationCount) || 1;
|
|
344
|
+
for (let i = 0; i < total; i++) {
|
|
345
|
+
// eslint-disable-next-line no-await-in-loop
|
|
346
|
+
await runOnce();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|