3d-spinner 0.9.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/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/animation.d.ts +28 -0
- package/dist/animation.js +1 -0
- package/dist/animations/object-motion.d.ts +96 -0
- package/dist/animations/object-motion.js +339 -0
- package/dist/animations/spin.d.ts +44 -0
- package/dist/animations/spin.js +108 -0
- package/dist/engines/little-3d-engine/core/camera.d.ts +26 -0
- package/dist/engines/little-3d-engine/core/camera.js +32 -0
- package/dist/engines/little-3d-engine/core/geometry.d.ts +27 -0
- package/dist/engines/little-3d-engine/core/geometry.js +87 -0
- package/dist/engines/little-3d-engine/core/light.d.ts +31 -0
- package/dist/engines/little-3d-engine/core/light.js +35 -0
- package/dist/engines/little-3d-engine/core/math.d.ts +50 -0
- package/dist/engines/little-3d-engine/core/math.js +109 -0
- package/dist/engines/little-3d-engine/core/mesh.d.ts +22 -0
- package/dist/engines/little-3d-engine/core/mesh.js +8 -0
- package/dist/engines/little-3d-engine/little-3d-engine.d.ts +75 -0
- package/dist/engines/little-3d-engine/little-3d-engine.js +167 -0
- package/dist/engines/little-3d-engine/loaders/obj.d.ts +24 -0
- package/dist/engines/little-3d-engine/loaders/obj.js +48 -0
- package/dist/engines/little-3d-engine/renderer.d.ts +43 -0
- package/dist/engines/little-3d-engine/renderer.js +16 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d.d.ts +12 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d.js +73 -0
- package/dist/engines/little-3d-engine/renderers/webgl.d.ts +15 -0
- package/dist/engines/little-3d-engine/renderers/webgl.js +146 -0
- package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +24 -0
- package/dist/engines/little-3d-engine/renderers/webgpu.js +222 -0
- package/dist/engines/little-3d-engine/shapes/cube-sphere.d.ts +11 -0
- package/dist/engines/little-3d-engine/shapes/cube-sphere.js +50 -0
- package/dist/engines/little-3d-engine/shapes/cube.d.ts +9 -0
- package/dist/engines/little-3d-engine/shapes/cube.js +30 -0
- package/dist/engines/little-3d-engine/shapes/icosphere.d.ts +11 -0
- package/dist/engines/little-3d-engine/shapes/icosphere.js +51 -0
- package/dist/engines/little-3d-engine/shapes/octa-sphere.d.ts +10 -0
- package/dist/engines/little-3d-engine/shapes/octa-sphere.js +31 -0
- package/dist/engines/little-3d-engine/shapes/octahedron.d.ts +8 -0
- package/dist/engines/little-3d-engine/shapes/octahedron.js +38 -0
- package/dist/engines/little-3d-engine/shapes/pyramid.d.ts +9 -0
- package/dist/engines/little-3d-engine/shapes/pyramid.js +26 -0
- package/dist/engines/little-3d-engine/shapes/tetrahedron.d.ts +8 -0
- package/dist/engines/little-3d-engine/shapes/tetrahedron.js +23 -0
- package/dist/engines/little-3d-engine/shapes/uv-sphere.d.ts +10 -0
- package/dist/engines/little-3d-engine/shapes/uv-sphere.js +50 -0
- package/dist/engines/little-tween-engine/core/tweens.d.ts +39 -0
- package/dist/engines/little-tween-engine/core/tweens.js +231 -0
- package/dist/engines/little-tween-engine/little-tween-engine.d.ts +21 -0
- package/dist/engines/little-tween-engine/little-tween-engine.js +16 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +110 -0
- package/dist/motion/circle.d.ts +17 -0
- package/dist/motion/circle.js +20 -0
- package/dist/motion/controller.d.ts +13 -0
- package/dist/motion/controller.js +1 -0
- package/dist/motion/figure-eight.d.ts +13 -0
- package/dist/motion/figure-eight.js +23 -0
- package/dist/motion/motion.d.ts +5 -0
- package/dist/motion/motion.js +4 -0
- package/dist/motion/square.d.ts +17 -0
- package/dist/motion/square.js +39 -0
- package/dist/motion/transitions.d.ts +44 -0
- package/dist/motion/transitions.js +60 -0
- package/dist/motion/wander.d.ts +22 -0
- package/dist/motion/wander.js +50 -0
- package/dist/progress-animation.d.ts +55 -0
- package/dist/progress-animation.js +128 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RuneL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# 3d-spinner
|
|
2
|
+
|
|
3
|
+
[](https://github.com/runelaang/3d-spinner/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/3d-spinner)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
A zero-dependency 3D spinner, loader, and progress indicator for the browser. It renders to a
|
|
8
|
+
canvas and ships as ES modules split across separate import paths, so a consumer loads only the
|
|
9
|
+
animation, motion path, and rendering backend they actually use - nothing else is pulled in.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install 3d-spinner
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
A spinner is a renderer (the `animation`) plus a mode. The simplest case is an indeterminate
|
|
20
|
+
spinner that runs until you stop it:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { createSpinner } from "3d-spinner";
|
|
24
|
+
import { SpinAnimation } from "3d-spinner/animations/spin";
|
|
25
|
+
|
|
26
|
+
const spinner = createSpinner(document.getElementById("app"), {
|
|
27
|
+
type: "indeterminate",
|
|
28
|
+
animation: new SpinAnimation(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// When the work is done:
|
|
32
|
+
spinner.stop(); // play the outro, then stop (leaves the element in place)
|
|
33
|
+
spinner.destroy(); // stop now and remove the element
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Reporting progress
|
|
37
|
+
|
|
38
|
+
Drop the `type` (it defaults to `"progress"`) and report progress yourself as work completes.
|
|
39
|
+
Give `SpinAnimation` a `progressAnimation` to make it pop in, scale with progress, and show a
|
|
40
|
+
label:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { createSpinner } from "3d-spinner";
|
|
44
|
+
import { SpinAnimation } from "3d-spinner/animations/spin";
|
|
45
|
+
|
|
46
|
+
const spinner = createSpinner(document.getElementById("app"), {
|
|
47
|
+
animation: new SpinAnimation({ progressAnimation: {} }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
spinner.setProgress(0.4); // smoothly advances toward 40%
|
|
51
|
+
spinner.setProgress(1); // reaching 1 plays the outro
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Choosing a shape
|
|
55
|
+
|
|
56
|
+
`SpinAnimation` spins a cube by default. Pass any built-in shape, or your own mesh:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { SpinAnimation } from "3d-spinner/animations/spin";
|
|
60
|
+
import { tetrahedron } from "3d-spinner/engines/little-3d-engine";
|
|
61
|
+
|
|
62
|
+
new SpinAnimation({ shape: tetrahedron(), color: "#3b82f6" });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Shapes exported from `3d-spinner/engines/little-3d-engine` include `cube`, `tetrahedron`,
|
|
66
|
+
`octahedron`, `pyramid`, and several spheres (`uvSphere`, `icosphere`, `octaSphere`,
|
|
67
|
+
`cubeSphere`).
|
|
68
|
+
|
|
69
|
+
## How it fits together
|
|
70
|
+
|
|
71
|
+
A spinner is assembled from a few independent pieces, so you can swap one without touching the
|
|
72
|
+
others:
|
|
73
|
+
|
|
74
|
+
- **`createSpinner`** mounts the spinner into an element and runs a single animation loop.
|
|
75
|
+
- **Spinner type** decides how progress is driven. `progress` is determinate - you report a value
|
|
76
|
+
from 0 to 1 with `setProgress`. `indeterminate` is self-driving - it loops a synthetic progress
|
|
77
|
+
on a timer until you `stop()`. Any animation works with either type.
|
|
78
|
+
- **Animation** is the visual that plays (a spinning shape, a moving object). Each one lives at its
|
|
79
|
+
own import path, so you load only the animation you use.
|
|
80
|
+
- **Intro and outro** are the entrance and exit. The loop plays the intro once when the spinner
|
|
81
|
+
starts and the outro when it finishes - progress reaching 1, or `stop()`. `destroy()` skips the
|
|
82
|
+
outro and removes the element immediately.
|
|
83
|
+
- **Motion controller** (for `ObjectMotionAnimation`) is a small, separate object that decides
|
|
84
|
+
*how* a thing moves - a circle, square, figure-8, or wander. Swap the controller to change the
|
|
85
|
+
path without changing the animation.
|
|
86
|
+
- **Transition** (for `ObjectMotionAnimation`) is the optional intro/outro effect - grow or shrink
|
|
87
|
+
in place, or fly in and out along the path.
|
|
88
|
+
- **The 3D engine** is the renderer underneath: a small, dependency-free engine that draws shapes
|
|
89
|
+
and meshes to a canvas, with swappable Canvas 2D, WebGL, and WebGPU backends.
|
|
90
|
+
|
|
91
|
+
## Animations
|
|
92
|
+
|
|
93
|
+
Each animation is imported from its own subpath, so you only pull in the one you use.
|
|
94
|
+
|
|
95
|
+
| Import | Class | Description |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `3d-spinner/animations/spin` | `SpinAnimation` | A spinning 3D shape, a cube by default. |
|
|
98
|
+
| `3d-spinner/animations/object-motion` | `ObjectMotionAnimation` | A mesh that follows a motion path, with an intro/outro you choose. |
|
|
99
|
+
|
|
100
|
+
`ObjectMotionAnimation` takes a motion controller from `3d-spinner/motion` (`circleMotion`,
|
|
101
|
+
`squareMotion`, `figureEightMotion`, `wanderMotion`) and optional entrance/exit transitions from
|
|
102
|
+
`3d-spinner/motion/transitions` (`grow`, `shrink`, `enterFromObjectDirection`,
|
|
103
|
+
`leaveInObjectDirection`) - for example a figure-8 path with a fly-in and fly-out.
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
### `createSpinner(target, options)`
|
|
108
|
+
|
|
109
|
+
Mounts a spinner inside `target` (an `HTMLElement`) and returns a `Spinner`. The options depend
|
|
110
|
+
on the mode.
|
|
111
|
+
|
|
112
|
+
**Progress** (the default, `type: "progress"`):
|
|
113
|
+
|
|
114
|
+
| Option | Type | Description |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `animation` | `SpinnerAnimation` | The renderer to play. Required. |
|
|
117
|
+
| `progress` | `number` | Initial progress `0..1`. A value above 0 plays the intro immediately. |
|
|
118
|
+
| `timeout` | `number` | Auto-complete after this many milliseconds. |
|
|
119
|
+
| `until` | `Date` | Auto-complete at this time. If both are set, the earlier wins. |
|
|
120
|
+
|
|
121
|
+
**Indeterminate** (`type: "indeterminate"`):
|
|
122
|
+
|
|
123
|
+
| Option | Type | Description |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `animation` | `SpinnerAnimation` | The renderer to play. Required. |
|
|
126
|
+
| `loop` | `"bounce" \| "restart"` | `"bounce"` ramps 0 to 1 and back; `"restart"` repeats 0 to 1. Default `"bounce"`. |
|
|
127
|
+
| `periodMs` | `number` | Milliseconds for one sweep. Must be finite and greater than zero. Default `2000`. |
|
|
128
|
+
|
|
129
|
+
### `Spinner`
|
|
130
|
+
|
|
131
|
+
| Method | Description |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `setProgress(target)` | Advance progress toward `target` (`0..1`). No-op for an indeterminate spinner. |
|
|
134
|
+
| `stop()` | Play the outro, then stop animating. Keeps the injected element. |
|
|
135
|
+
| `destroy()` | Stop immediately and remove the injected element. Safe to call more than once. |
|
|
136
|
+
|
|
137
|
+
## Rendering backend
|
|
138
|
+
|
|
139
|
+
By default the spinner uses a Canvas 2D software renderer, which has no dependencies and runs
|
|
140
|
+
anywhere a canvas does. The engine can also render through WebGL or WebGPU; pass `backend` to
|
|
141
|
+
any of the 3D animations to switch. Backends are loaded on demand, so the code for the ones you
|
|
142
|
+
do not use is never fetched.
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
new SpinAnimation({ backend: "webgl" }); // "canvas2d" (default), "webgl", or "webgpu"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## The engine
|
|
149
|
+
|
|
150
|
+
The renderer is a small, self-contained 3D engine, exported on its own in case you want it
|
|
151
|
+
directly:
|
|
152
|
+
|
|
153
|
+
- `3d-spinner/engines/little-3d-engine` - the engine (`Little3dEngine`), shapes, and math.
|
|
154
|
+
- `3d-spinner/engines/little-3d-engine/loaders/obj` - a minimal OBJ loader (`parseObj`).
|
|
155
|
+
- `3d-spinner/engines/little-tween-engine` - a standalone tween and easing engine
|
|
156
|
+
(`LittleTweenEngine`).
|
|
157
|
+
|
|
158
|
+
Each has no dependencies of its own.
|
|
159
|
+
|
|
160
|
+
## Module format
|
|
161
|
+
|
|
162
|
+
ES modules only, for the browser. Use a bundler or native `<script type="module">`; the engine
|
|
163
|
+
draws to a canvas, so there is no server-side rendering. ES modules do not load over `file://`,
|
|
164
|
+
so serve the page over HTTP rather than opening the file directly.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
npm install
|
|
170
|
+
npm run build # compile src/ to dist/ (ESM + type declarations)
|
|
171
|
+
npm test # build, then run the unit tests
|
|
172
|
+
npm run dev # serve this folder; open /examples/index.html
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT (c) RuneL
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Per-frame state handed to a {@link SpinnerAnimation}. */
|
|
2
|
+
export interface AnimationFrame {
|
|
3
|
+
/**
|
|
4
|
+
* Progress in the range 0..1. Lerped from the caller's target for a
|
|
5
|
+
* `progress` spinner; a synthetic looping value for an `indeterminate` one.
|
|
6
|
+
*/
|
|
7
|
+
readonly progress: number;
|
|
8
|
+
/** Lerp destination for `progress` (equals `progress` for an indeterminate spinner). */
|
|
9
|
+
readonly targetProgress: number;
|
|
10
|
+
/** True when the spinner is indeterminate (progress is a synthetic loop). */
|
|
11
|
+
readonly indeterminate: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A reusable visual: a cube spin, a flying plane, a progress bar. The animation
|
|
15
|
+
* owns its intro/loop/outro visuals but does not decide *when* to play them -
|
|
16
|
+
* the spinner runner triggers {@link enter} and {@link exit}.
|
|
17
|
+
*/
|
|
18
|
+
export interface SpinnerAnimation {
|
|
19
|
+
mount(target: HTMLElement): void;
|
|
20
|
+
/** Play the intro once. Calls after the first are ignored. */
|
|
21
|
+
enter(now: number): void;
|
|
22
|
+
/** Begin the outro. {@link isFinished} becomes true once it completes. */
|
|
23
|
+
exit(now: number): void;
|
|
24
|
+
render(now: number, frame: AnimationFrame): void;
|
|
25
|
+
/** True once the outro has finished and the spinner can be torn down. */
|
|
26
|
+
isFinished(): boolean;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AnimationFrame, SpinnerAnimation } from "../animation.js";
|
|
2
|
+
import { type Backend, type Mesh } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
import type { MotionController } from "../motion/controller.js";
|
|
4
|
+
import { type ObjectMotionTransitionConfig } from "../motion/transitions.js";
|
|
5
|
+
/** Which local axis the object's nose points down, used to correct a model that moves backwards or sideways. */
|
|
6
|
+
export type Facing = "+x" | "-x" | "+y" | "-y" | "+z" | "-z";
|
|
7
|
+
/** Trailing copies that chase the lead object in single file. */
|
|
8
|
+
export interface ObjectMotionTail {
|
|
9
|
+
/** Number of trailing copies. */
|
|
10
|
+
count: number;
|
|
11
|
+
/** Time each copy lags the one ahead of it, in milliseconds. */
|
|
12
|
+
gapMs: number;
|
|
13
|
+
}
|
|
14
|
+
/** Extra local-space orientation on top of path following. */
|
|
15
|
+
export interface ObjectMotionRotation {
|
|
16
|
+
/** Fixed local-space Euler offset, radians. */
|
|
17
|
+
x?: number;
|
|
18
|
+
y?: number;
|
|
19
|
+
z?: number;
|
|
20
|
+
/** Continuous spin around local X/Y/Z, radians per millisecond. */
|
|
21
|
+
spinX?: number;
|
|
22
|
+
spinY?: number;
|
|
23
|
+
spinZ?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ObjectMotionOptions {
|
|
26
|
+
/** Object mesh (an OBJ import or an engine primitive), or a factory returning one. */
|
|
27
|
+
mesh: Mesh | (() => Mesh);
|
|
28
|
+
/** How the object moves: a circle, square, figure-8, wander, or any custom controller. */
|
|
29
|
+
motion: MotionController;
|
|
30
|
+
/** Face color applied to every triangle. Default `"#cbd5e1"`. */
|
|
31
|
+
color?: string;
|
|
32
|
+
/** Rendering backend. Default `"canvas2d"`. */
|
|
33
|
+
backend?: Backend;
|
|
34
|
+
/** Uniform object size after centering. Default `1`. */
|
|
35
|
+
size?: number;
|
|
36
|
+
/** The object's nose axis. Set this to correct a model that moves backwards or sideways. Default `"+x"`. */
|
|
37
|
+
facing?: Facing;
|
|
38
|
+
/** Additional local-space rotation on top of path orientation. */
|
|
39
|
+
rotation?: ObjectMotionRotation;
|
|
40
|
+
/** Intro transition. Defaults to `grow()`. */
|
|
41
|
+
intro?: ObjectMotionTransitionConfig;
|
|
42
|
+
/** Outro transition. Defaults to `shrink()`. */
|
|
43
|
+
outro?: ObjectMotionTransitionConfig;
|
|
44
|
+
/** Trailing copies that chase the lead in single file. Omit for a single object. */
|
|
45
|
+
tail?: ObjectMotionTail;
|
|
46
|
+
/** Overlay label shown in indeterminate mode (no value to show). Hidden if omitted. */
|
|
47
|
+
label?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Centers a mesh at the origin and uniformly scales it to fit within `targetSize`. */
|
|
50
|
+
export declare function centerAndScaleMesh(mesh: Mesh, targetSize: number): Mesh;
|
|
51
|
+
/**
|
|
52
|
+
* An object that moves along a {@link MotionController}'s path (a circle, a
|
|
53
|
+
* square, a figure-8, a smooth wander, or any custom controller) with its nose
|
|
54
|
+
* following the path tangent. The runner triggers the lifecycle: {@link enter}
|
|
55
|
+
* pops it in, {@link exit} pops it out. Any mesh works (OBJ imports or engine
|
|
56
|
+
* primitives); `facing` corrects a model that points the wrong way, and
|
|
57
|
+
* `rotation` adds local spin or tilt on top of path following.
|
|
58
|
+
*/
|
|
59
|
+
export declare class ObjectMotionAnimation implements SpinnerAnimation {
|
|
60
|
+
private engine?;
|
|
61
|
+
private label?;
|
|
62
|
+
private readonly handles;
|
|
63
|
+
private readonly banks;
|
|
64
|
+
private readonly headings;
|
|
65
|
+
private readonly mesh;
|
|
66
|
+
private readonly motion;
|
|
67
|
+
private readonly backend?;
|
|
68
|
+
private readonly labelText?;
|
|
69
|
+
private readonly tailCount;
|
|
70
|
+
private readonly tailGap;
|
|
71
|
+
private readonly intro;
|
|
72
|
+
private readonly outro;
|
|
73
|
+
private readonly rotationOffset;
|
|
74
|
+
private readonly rotationSpin;
|
|
75
|
+
private readonly hasExtraRotation;
|
|
76
|
+
private started;
|
|
77
|
+
private finished;
|
|
78
|
+
private introStart;
|
|
79
|
+
private outroStart;
|
|
80
|
+
private outroPosition;
|
|
81
|
+
private outroVelocity;
|
|
82
|
+
private outroDirection;
|
|
83
|
+
constructor(options: ObjectMotionOptions);
|
|
84
|
+
mount(target: HTMLElement): void;
|
|
85
|
+
enter(now: number): void;
|
|
86
|
+
exit(now: number): void;
|
|
87
|
+
isFinished(): boolean;
|
|
88
|
+
render(now: number, frame: AnimationFrame): void;
|
|
89
|
+
destroy(): void;
|
|
90
|
+
private aheadAt;
|
|
91
|
+
private positionAt;
|
|
92
|
+
private sampleAt;
|
|
93
|
+
private transitionSample;
|
|
94
|
+
private transitionInput;
|
|
95
|
+
private applyTransitionOutput;
|
|
96
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { Little3dEngine, cross, normalize, scale, subtract, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
2
|
+
import { multiply, rotationX, rotationY, rotationZ, } from "../engines/little-3d-engine/core/math.js";
|
|
3
|
+
import { grow, shrink, } from "../motion/transitions.js";
|
|
4
|
+
const LABEL_STYLE = [
|
|
5
|
+
"position:absolute",
|
|
6
|
+
"inset:0",
|
|
7
|
+
"display:flex",
|
|
8
|
+
"align-items:center",
|
|
9
|
+
"justify-content:center",
|
|
10
|
+
"pointer-events:none",
|
|
11
|
+
"font:700 1.6rem/1 system-ui,sans-serif",
|
|
12
|
+
"letter-spacing:0.02em",
|
|
13
|
+
"color:rgba(255,255,255,0.9)",
|
|
14
|
+
"text-shadow:0 1px 10px rgba(0,0,0,0.6)",
|
|
15
|
+
"z-index:1",
|
|
16
|
+
].join(";");
|
|
17
|
+
const WORLD_UP = { x: 0, y: 1, z: 0 };
|
|
18
|
+
const DEFAULT_INTRO_MS = 2100;
|
|
19
|
+
const DEFAULT_OUTRO_MS = 2100;
|
|
20
|
+
const BANK_GAIN = 26;
|
|
21
|
+
const BANK_LIMIT = 0.7;
|
|
22
|
+
const BANK_SMOOTH = 0.12;
|
|
23
|
+
const SAMPLE_MS = 8;
|
|
24
|
+
// Rotation (proper, winding-preserving) that maps each `facing` axis onto +X.
|
|
25
|
+
const FACE_FORWARD = {
|
|
26
|
+
"+x": (v) => v,
|
|
27
|
+
"-x": (v) => ({ x: -v.x, y: v.y, z: -v.z }),
|
|
28
|
+
"+z": (v) => ({ x: v.z, y: v.y, z: -v.x }),
|
|
29
|
+
"-z": (v) => ({ x: -v.z, y: v.y, z: v.x }),
|
|
30
|
+
"+y": (v) => ({ x: v.y, y: -v.x, z: v.z }),
|
|
31
|
+
"-y": (v) => ({ x: -v.y, y: v.x, z: v.z }),
|
|
32
|
+
};
|
|
33
|
+
function add(a, b) {
|
|
34
|
+
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
|
|
35
|
+
}
|
|
36
|
+
function resolveMesh(mesh) {
|
|
37
|
+
return typeof mesh === "function" ? mesh() : mesh;
|
|
38
|
+
}
|
|
39
|
+
function applyColor(mesh, color) {
|
|
40
|
+
return { vertices: mesh.vertices, faces: mesh.faces.map((face) => ({ ...face, color })) };
|
|
41
|
+
}
|
|
42
|
+
function faceForward(mesh, facing) {
|
|
43
|
+
if (facing === "+x")
|
|
44
|
+
return mesh;
|
|
45
|
+
const turn = FACE_FORWARD[facing];
|
|
46
|
+
return { vertices: mesh.vertices.map(turn), faces: mesh.faces };
|
|
47
|
+
}
|
|
48
|
+
/** Centers a mesh at the origin and uniformly scales it to fit within `targetSize`. */
|
|
49
|
+
export function centerAndScaleMesh(mesh, targetSize) {
|
|
50
|
+
let minX = Infinity;
|
|
51
|
+
let minY = Infinity;
|
|
52
|
+
let minZ = Infinity;
|
|
53
|
+
let maxX = -Infinity;
|
|
54
|
+
let maxY = -Infinity;
|
|
55
|
+
let maxZ = -Infinity;
|
|
56
|
+
for (const vertex of mesh.vertices) {
|
|
57
|
+
minX = Math.min(minX, vertex.x);
|
|
58
|
+
minY = Math.min(minY, vertex.y);
|
|
59
|
+
minZ = Math.min(minZ, vertex.z);
|
|
60
|
+
maxX = Math.max(maxX, vertex.x);
|
|
61
|
+
maxY = Math.max(maxY, vertex.y);
|
|
62
|
+
maxZ = Math.max(maxZ, vertex.z);
|
|
63
|
+
}
|
|
64
|
+
const centerX = (minX + maxX) / 2;
|
|
65
|
+
const centerY = (minY + maxY) / 2;
|
|
66
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
67
|
+
const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ) || 1;
|
|
68
|
+
const factor = targetSize / extent;
|
|
69
|
+
return {
|
|
70
|
+
vertices: mesh.vertices.map((vertex) => ({
|
|
71
|
+
x: (vertex.x - centerX) * factor,
|
|
72
|
+
y: (vertex.y - centerY) * factor,
|
|
73
|
+
z: (vertex.z - centerZ) * factor,
|
|
74
|
+
})),
|
|
75
|
+
faces: mesh.faces,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Orientation (engine Euler, Rz*Ry*Rx) that points the nose (+X) along
|
|
80
|
+
* `forward` and keeps `up` upright, rolled by `bank` radians about the nose.
|
|
81
|
+
*/
|
|
82
|
+
function orientationFor(forward, bank) {
|
|
83
|
+
const fwd = normalize(forward);
|
|
84
|
+
let right = cross(fwd, WORLD_UP);
|
|
85
|
+
if (Math.hypot(right.x, right.y, right.z) < 1e-4)
|
|
86
|
+
right = { x: 0, y: 0, z: 1 };
|
|
87
|
+
right = normalize(right);
|
|
88
|
+
const levelUp = cross(right, fwd);
|
|
89
|
+
const up = add(scale(levelUp, Math.cos(bank)), scale(right, Math.sin(bank)));
|
|
90
|
+
const w = normalize(cross(fwd, up));
|
|
91
|
+
return {
|
|
92
|
+
x: Math.atan2(cross(w, fwd).z, w.z),
|
|
93
|
+
y: Math.asin(Math.max(-1, Math.min(1, -fwd.z))),
|
|
94
|
+
z: Math.atan2(fwd.y, fwd.x),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Engine rotation matrix from Euler angles (Rz * Ry * Rx). */
|
|
98
|
+
function rotationMatrix(x, y, z) {
|
|
99
|
+
return multiply(rotationZ(z), multiply(rotationY(y), rotationX(x)));
|
|
100
|
+
}
|
|
101
|
+
/** Inverse of {@link rotationMatrix} for the engine's Rz * Ry * Rx order. */
|
|
102
|
+
function eulerFromRotationMatrix(matrix) {
|
|
103
|
+
const sy = Math.hypot(matrix[0], matrix[1]);
|
|
104
|
+
if (sy > 1e-6) {
|
|
105
|
+
return {
|
|
106
|
+
x: Math.atan2(matrix[9], matrix[10]),
|
|
107
|
+
y: Math.asin(Math.max(-1, Math.min(1, -matrix[8]))),
|
|
108
|
+
z: Math.atan2(matrix[4], matrix[0]),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
x: Math.atan2(-matrix[6], matrix[5]),
|
|
113
|
+
y: Math.asin(Math.max(-1, Math.min(1, -matrix[8]))),
|
|
114
|
+
z: 0,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Compose path orientation with a local-space offset/spin rotation. */
|
|
118
|
+
function combineLocalRotation(path, extra) {
|
|
119
|
+
return eulerFromRotationMatrix(multiply(rotationMatrix(path.x, path.y, path.z), rotationMatrix(extra.x, extra.y, extra.z)));
|
|
120
|
+
}
|
|
121
|
+
function clamp01(value) {
|
|
122
|
+
return Math.max(0, Math.min(1, value));
|
|
123
|
+
}
|
|
124
|
+
function motionVectorAt(motion, t) {
|
|
125
|
+
return scale(subtract(motion.positionAt(t + 1), motion.positionAt(t - 1)), 0.5);
|
|
126
|
+
}
|
|
127
|
+
function resolveDirection(velocity, fallback) {
|
|
128
|
+
return Math.hypot(velocity.x, velocity.y, velocity.z) > 1e-6 ? normalize(velocity) : fallback;
|
|
129
|
+
}
|
|
130
|
+
function resolveTransition(config, fallback, durationMs) {
|
|
131
|
+
if (!config)
|
|
132
|
+
return { transition: fallback, durationMs };
|
|
133
|
+
if (typeof config === "function")
|
|
134
|
+
return { transition: config, durationMs };
|
|
135
|
+
return { transition: config.transition, durationMs: Math.max(0, config.durationMs ?? durationMs) };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* An object that moves along a {@link MotionController}'s path (a circle, a
|
|
139
|
+
* square, a figure-8, a smooth wander, or any custom controller) with its nose
|
|
140
|
+
* following the path tangent. The runner triggers the lifecycle: {@link enter}
|
|
141
|
+
* pops it in, {@link exit} pops it out. Any mesh works (OBJ imports or engine
|
|
142
|
+
* primitives); `facing` corrects a model that points the wrong way, and
|
|
143
|
+
* `rotation` adds local spin or tilt on top of path following.
|
|
144
|
+
*/
|
|
145
|
+
export class ObjectMotionAnimation {
|
|
146
|
+
constructor(options) {
|
|
147
|
+
this.handles = [];
|
|
148
|
+
this.banks = [];
|
|
149
|
+
this.headings = [];
|
|
150
|
+
this.started = false;
|
|
151
|
+
this.finished = false;
|
|
152
|
+
this.introStart = 0;
|
|
153
|
+
this.outroStart = Infinity;
|
|
154
|
+
this.outroPosition = { x: 0, y: 0, z: 0 };
|
|
155
|
+
this.outroVelocity = { x: 0, y: 0, z: 0 };
|
|
156
|
+
this.outroDirection = { x: 1, y: 0, z: 0 };
|
|
157
|
+
const centered = centerAndScaleMesh(resolveMesh(options.mesh), options.size ?? 1);
|
|
158
|
+
const facing = faceForward(centered, options.facing ?? "+x");
|
|
159
|
+
this.mesh = applyColor(facing, options.color ?? "#cbd5e1");
|
|
160
|
+
this.motion = options.motion;
|
|
161
|
+
this.backend = options.backend;
|
|
162
|
+
this.labelText = options.label;
|
|
163
|
+
this.tailCount = Math.max(0, Math.floor(options.tail?.count ?? 0));
|
|
164
|
+
this.tailGap = Math.max(0, options.tail?.gapMs ?? 0);
|
|
165
|
+
this.intro = resolveTransition(options.intro, grow(), DEFAULT_INTRO_MS);
|
|
166
|
+
this.outro = resolveTransition(options.outro, shrink(), DEFAULT_OUTRO_MS);
|
|
167
|
+
const rotation = options.rotation;
|
|
168
|
+
this.rotationOffset = { x: rotation?.x ?? 0, y: rotation?.y ?? 0, z: rotation?.z ?? 0 };
|
|
169
|
+
this.rotationSpin = {
|
|
170
|
+
x: rotation?.spinX ?? 0,
|
|
171
|
+
y: rotation?.spinY ?? 0,
|
|
172
|
+
z: rotation?.spinZ ?? 0,
|
|
173
|
+
};
|
|
174
|
+
this.hasExtraRotation =
|
|
175
|
+
this.rotationOffset.x !== 0 ||
|
|
176
|
+
this.rotationOffset.y !== 0 ||
|
|
177
|
+
this.rotationOffset.z !== 0 ||
|
|
178
|
+
this.rotationSpin.x !== 0 ||
|
|
179
|
+
this.rotationSpin.y !== 0 ||
|
|
180
|
+
this.rotationSpin.z !== 0;
|
|
181
|
+
}
|
|
182
|
+
mount(target) {
|
|
183
|
+
target.style.position = "relative";
|
|
184
|
+
const engine = new Little3dEngine({
|
|
185
|
+
backend: this.backend,
|
|
186
|
+
camera: { position: { x: 0, y: 0, z: 3 } },
|
|
187
|
+
});
|
|
188
|
+
for (let i = 0; i <= this.tailCount; i++) {
|
|
189
|
+
this.handles.push(engine.add(this.mesh));
|
|
190
|
+
this.banks.push(0);
|
|
191
|
+
this.headings.push({ x: 1, y: 0, z: 0 });
|
|
192
|
+
}
|
|
193
|
+
this.engine = engine;
|
|
194
|
+
engine.mount(target).catch((error) => {
|
|
195
|
+
target.textContent = error instanceof Error ? error.message : String(error);
|
|
196
|
+
});
|
|
197
|
+
const label = document.createElement("div");
|
|
198
|
+
label.style.cssText = LABEL_STYLE;
|
|
199
|
+
label.setAttribute("role", "status");
|
|
200
|
+
target.appendChild(label);
|
|
201
|
+
this.label = label;
|
|
202
|
+
}
|
|
203
|
+
enter(now) {
|
|
204
|
+
if (this.started)
|
|
205
|
+
return;
|
|
206
|
+
this.started = true;
|
|
207
|
+
this.introStart = now;
|
|
208
|
+
}
|
|
209
|
+
exit(now) {
|
|
210
|
+
if (!this.started || this.outroStart !== Infinity)
|
|
211
|
+
return;
|
|
212
|
+
this.outroPosition = this.motion.positionAt(now);
|
|
213
|
+
this.outroVelocity = motionVectorAt(this.motion, now);
|
|
214
|
+
this.outroDirection = resolveDirection(this.outroVelocity, this.headings[0]);
|
|
215
|
+
this.outroStart = now;
|
|
216
|
+
}
|
|
217
|
+
isFinished() {
|
|
218
|
+
return this.finished;
|
|
219
|
+
}
|
|
220
|
+
render(now, frame) {
|
|
221
|
+
if (!this.engine || !this.label)
|
|
222
|
+
return;
|
|
223
|
+
if (this.outroStart !== Infinity && now >= this.outroStart + this.outro.durationMs + this.tailCount * this.tailGap) {
|
|
224
|
+
this.finished = true;
|
|
225
|
+
}
|
|
226
|
+
for (let k = 0; k < this.handles.length; k++) {
|
|
227
|
+
const transform = this.handles[k].transform;
|
|
228
|
+
const t = now - k * this.tailGap;
|
|
229
|
+
const sample = this.sampleAt(t);
|
|
230
|
+
if (!sample) {
|
|
231
|
+
transform.scale = 0;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
transform.scale = sample.size;
|
|
235
|
+
let euler = sample.orientation;
|
|
236
|
+
if (!euler) {
|
|
237
|
+
const heading = subtract(this.positionAt(t + SAMPLE_MS) ?? sample.position, sample.position);
|
|
238
|
+
if (Math.hypot(heading.x, heading.y, heading.z) > 1e-5) {
|
|
239
|
+
this.headings[k] = normalize(heading);
|
|
240
|
+
}
|
|
241
|
+
const ahead = this.aheadAt(t) ?? this.headings[k];
|
|
242
|
+
const targetBank = Math.max(-BANK_LIMIT, Math.min(BANK_LIMIT, cross(this.headings[k], ahead).y * BANK_GAIN));
|
|
243
|
+
this.banks[k] += (targetBank - this.banks[k]) * BANK_SMOOTH;
|
|
244
|
+
euler = orientationFor(this.headings[k], this.banks[k]);
|
|
245
|
+
}
|
|
246
|
+
if (this.hasExtraRotation) {
|
|
247
|
+
euler = combineLocalRotation(euler, {
|
|
248
|
+
x: this.rotationOffset.x + this.rotationSpin.x * t,
|
|
249
|
+
y: this.rotationOffset.y + this.rotationSpin.y * t,
|
|
250
|
+
z: this.rotationOffset.z + this.rotationSpin.z * t,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
transform.position.x = sample.position.x;
|
|
254
|
+
transform.position.y = sample.position.y;
|
|
255
|
+
transform.position.z = sample.position.z;
|
|
256
|
+
transform.rotation.x = euler.x;
|
|
257
|
+
transform.rotation.y = euler.y;
|
|
258
|
+
transform.rotation.z = euler.z;
|
|
259
|
+
}
|
|
260
|
+
this.label.textContent = frame.indeterminate
|
|
261
|
+
? (this.labelText ?? "")
|
|
262
|
+
: `${Math.round(frame.progress * 100)}%`;
|
|
263
|
+
this.engine.render();
|
|
264
|
+
}
|
|
265
|
+
destroy() {
|
|
266
|
+
this.label?.remove();
|
|
267
|
+
this.label = undefined;
|
|
268
|
+
this.engine?.destroy();
|
|
269
|
+
this.engine = undefined;
|
|
270
|
+
this.handles.length = 0;
|
|
271
|
+
}
|
|
272
|
+
aheadAt(t) {
|
|
273
|
+
const next = this.positionAt(t + SAMPLE_MS);
|
|
274
|
+
const afterNext = this.positionAt(t + 2 * SAMPLE_MS);
|
|
275
|
+
if (!next || !afterNext)
|
|
276
|
+
return undefined;
|
|
277
|
+
const ahead = subtract(afterNext, next);
|
|
278
|
+
if (Math.hypot(ahead.x, ahead.y, ahead.z) <= 1e-5)
|
|
279
|
+
return undefined;
|
|
280
|
+
return normalize(ahead);
|
|
281
|
+
}
|
|
282
|
+
positionAt(t) {
|
|
283
|
+
return this.sampleAt(t)?.position;
|
|
284
|
+
}
|
|
285
|
+
sampleAt(t) {
|
|
286
|
+
if (!this.started || t < this.introStart)
|
|
287
|
+
return undefined;
|
|
288
|
+
if (t < this.introStart + this.intro.durationMs) {
|
|
289
|
+
return this.transitionSample("intro", t, this.intro, this.introStart);
|
|
290
|
+
}
|
|
291
|
+
if (this.outroStart !== Infinity) {
|
|
292
|
+
if (t > this.outroStart + this.outro.durationMs)
|
|
293
|
+
return undefined;
|
|
294
|
+
if (t >= this.outroStart)
|
|
295
|
+
return this.transitionSample("outro", t, this.outro, this.outroStart);
|
|
296
|
+
}
|
|
297
|
+
return { position: this.motion.positionAt(t), size: 1 };
|
|
298
|
+
}
|
|
299
|
+
transitionSample(phase, t, transition, start) {
|
|
300
|
+
const elapsedMs = Math.max(0, t - start);
|
|
301
|
+
const delta = transition.durationMs === 0 ? 1 : clamp01(elapsedMs / transition.durationMs);
|
|
302
|
+
const input = this.transitionInput(phase, delta, elapsedMs, transition.durationMs, start);
|
|
303
|
+
const output = transition.transition(input);
|
|
304
|
+
return this.applyTransitionOutput(input, output);
|
|
305
|
+
}
|
|
306
|
+
transitionInput(phase, delta, elapsedMs, durationMs, start) {
|
|
307
|
+
if (phase === "intro") {
|
|
308
|
+
const handoff = start + durationMs;
|
|
309
|
+
const velocity = motionVectorAt(this.motion, handoff);
|
|
310
|
+
return {
|
|
311
|
+
delta,
|
|
312
|
+
position: this.motion.positionAt(handoff),
|
|
313
|
+
direction: resolveDirection(velocity, { x: 1, y: 0, z: 0 }),
|
|
314
|
+
velocity,
|
|
315
|
+
size: 1,
|
|
316
|
+
durationMs,
|
|
317
|
+
elapsedMs,
|
|
318
|
+
phase,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
delta,
|
|
323
|
+
position: this.outroPosition,
|
|
324
|
+
direction: this.outroDirection,
|
|
325
|
+
velocity: this.outroVelocity,
|
|
326
|
+
size: 1,
|
|
327
|
+
durationMs,
|
|
328
|
+
elapsedMs,
|
|
329
|
+
phase,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
applyTransitionOutput(input, output) {
|
|
333
|
+
return {
|
|
334
|
+
position: output.position ?? input.position,
|
|
335
|
+
size: output.size ?? input.size ?? 1,
|
|
336
|
+
orientation: output.orientation,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|