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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/animation.d.ts +28 -0
  4. package/dist/animation.js +1 -0
  5. package/dist/animations/object-motion.d.ts +96 -0
  6. package/dist/animations/object-motion.js +339 -0
  7. package/dist/animations/spin.d.ts +44 -0
  8. package/dist/animations/spin.js +108 -0
  9. package/dist/engines/little-3d-engine/core/camera.d.ts +26 -0
  10. package/dist/engines/little-3d-engine/core/camera.js +32 -0
  11. package/dist/engines/little-3d-engine/core/geometry.d.ts +27 -0
  12. package/dist/engines/little-3d-engine/core/geometry.js +87 -0
  13. package/dist/engines/little-3d-engine/core/light.d.ts +31 -0
  14. package/dist/engines/little-3d-engine/core/light.js +35 -0
  15. package/dist/engines/little-3d-engine/core/math.d.ts +50 -0
  16. package/dist/engines/little-3d-engine/core/math.js +109 -0
  17. package/dist/engines/little-3d-engine/core/mesh.d.ts +22 -0
  18. package/dist/engines/little-3d-engine/core/mesh.js +8 -0
  19. package/dist/engines/little-3d-engine/little-3d-engine.d.ts +75 -0
  20. package/dist/engines/little-3d-engine/little-3d-engine.js +167 -0
  21. package/dist/engines/little-3d-engine/loaders/obj.d.ts +24 -0
  22. package/dist/engines/little-3d-engine/loaders/obj.js +48 -0
  23. package/dist/engines/little-3d-engine/renderer.d.ts +43 -0
  24. package/dist/engines/little-3d-engine/renderer.js +16 -0
  25. package/dist/engines/little-3d-engine/renderers/canvas2d.d.ts +12 -0
  26. package/dist/engines/little-3d-engine/renderers/canvas2d.js +73 -0
  27. package/dist/engines/little-3d-engine/renderers/webgl.d.ts +15 -0
  28. package/dist/engines/little-3d-engine/renderers/webgl.js +146 -0
  29. package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +24 -0
  30. package/dist/engines/little-3d-engine/renderers/webgpu.js +222 -0
  31. package/dist/engines/little-3d-engine/shapes/cube-sphere.d.ts +11 -0
  32. package/dist/engines/little-3d-engine/shapes/cube-sphere.js +50 -0
  33. package/dist/engines/little-3d-engine/shapes/cube.d.ts +9 -0
  34. package/dist/engines/little-3d-engine/shapes/cube.js +30 -0
  35. package/dist/engines/little-3d-engine/shapes/icosphere.d.ts +11 -0
  36. package/dist/engines/little-3d-engine/shapes/icosphere.js +51 -0
  37. package/dist/engines/little-3d-engine/shapes/octa-sphere.d.ts +10 -0
  38. package/dist/engines/little-3d-engine/shapes/octa-sphere.js +31 -0
  39. package/dist/engines/little-3d-engine/shapes/octahedron.d.ts +8 -0
  40. package/dist/engines/little-3d-engine/shapes/octahedron.js +38 -0
  41. package/dist/engines/little-3d-engine/shapes/pyramid.d.ts +9 -0
  42. package/dist/engines/little-3d-engine/shapes/pyramid.js +26 -0
  43. package/dist/engines/little-3d-engine/shapes/tetrahedron.d.ts +8 -0
  44. package/dist/engines/little-3d-engine/shapes/tetrahedron.js +23 -0
  45. package/dist/engines/little-3d-engine/shapes/uv-sphere.d.ts +10 -0
  46. package/dist/engines/little-3d-engine/shapes/uv-sphere.js +50 -0
  47. package/dist/engines/little-tween-engine/core/tweens.d.ts +39 -0
  48. package/dist/engines/little-tween-engine/core/tweens.js +231 -0
  49. package/dist/engines/little-tween-engine/little-tween-engine.d.ts +21 -0
  50. package/dist/engines/little-tween-engine/little-tween-engine.js +16 -0
  51. package/dist/index.d.ts +37 -0
  52. package/dist/index.js +110 -0
  53. package/dist/motion/circle.d.ts +17 -0
  54. package/dist/motion/circle.js +20 -0
  55. package/dist/motion/controller.d.ts +13 -0
  56. package/dist/motion/controller.js +1 -0
  57. package/dist/motion/figure-eight.d.ts +13 -0
  58. package/dist/motion/figure-eight.js +23 -0
  59. package/dist/motion/motion.d.ts +5 -0
  60. package/dist/motion/motion.js +4 -0
  61. package/dist/motion/square.d.ts +17 -0
  62. package/dist/motion/square.js +39 -0
  63. package/dist/motion/transitions.d.ts +44 -0
  64. package/dist/motion/transitions.js +60 -0
  65. package/dist/motion/wander.d.ts +22 -0
  66. package/dist/motion/wander.js +50 -0
  67. package/dist/progress-animation.d.ts +55 -0
  68. package/dist/progress-animation.js +128 -0
  69. 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
+ [![tests](https://img.shields.io/github/actions/workflow/status/runelaang/3d-spinner/ci.yml?label=tests&logo=github)](https://github.com/runelaang/3d-spinner/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/3d-spinner?logo=npm)](https://www.npmjs.com/package/3d-spinner)
5
+ [![license](https://img.shields.io/github/license/runelaang/3d-spinner)](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
+ }