3d-spinner 0.9.1 → 0.9.3

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 (101) hide show
  1. package/README.md +96 -11
  2. package/dist/animation-label.d.ts +8 -0
  3. package/dist/animation-label.js +47 -0
  4. package/dist/animation.d.ts +2 -0
  5. package/dist/animations/charged-orb.d.ts +55 -0
  6. package/dist/animations/charged-orb.js +229 -0
  7. package/dist/animations/grid-assembly.d.ts +67 -0
  8. package/dist/animations/grid-assembly.js +268 -0
  9. package/dist/animations/object-motion.d.ts +19 -7
  10. package/dist/animations/object-motion.js +38 -32
  11. package/dist/animations/particles.d.ts +110 -0
  12. package/dist/animations/particles.js +209 -0
  13. package/dist/cjs/animations/charged-orb.cjs +1334 -0
  14. package/dist/cjs/animations/grid-assembly.cjs +1498 -0
  15. package/dist/cjs/animations/object-motion.cjs +1449 -0
  16. package/dist/cjs/animations/particles.cjs +1940 -0
  17. package/dist/cjs/animations/spin.cjs +1300 -0
  18. package/dist/cjs/composite-animation.cjs +58 -0
  19. package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +1433 -0
  20. package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +87 -0
  21. package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
  22. package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
  23. package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
  24. package/dist/cjs/engines/little-tween-engine/little-tween-engine.cjs +279 -0
  25. package/dist/cjs/index.cjs +120 -0
  26. package/dist/cjs/motion/motion.cjs +140 -0
  27. package/dist/cjs/motion/transitions.cjs +80 -0
  28. package/dist/cjs/prefabs/prefabs.cjs +3450 -0
  29. package/dist/composite-animation.d.ts +18 -0
  30. package/dist/composite-animation.js +39 -0
  31. package/dist/engines/little-3d-engine/little-3d-engine.d.ts +17 -12
  32. package/dist/engines/little-3d-engine/little-3d-engine.js +13 -8
  33. package/dist/engines/little-3d-engine/loaders/obj.d.ts +12 -4
  34. package/dist/engines/little-3d-engine/loaders/obj.js +44 -5
  35. package/dist/engines/little-3d-engine/renderer.d.ts +7 -1
  36. package/dist/engines/little-3d-engine/renderer.js +2 -0
  37. package/dist/engines/little-3d-engine/renderers/canvas2d-textured.d.ts +20 -0
  38. package/dist/engines/little-3d-engine/renderers/canvas2d-textured.js +135 -0
  39. package/dist/engines/little-3d-engine/renderers/textured-helpers.d.ts +9 -0
  40. package/dist/engines/little-3d-engine/renderers/textured-helpers.js +35 -0
  41. package/dist/engines/little-3d-engine/renderers/webgl-textured.d.ts +43 -0
  42. package/dist/engines/little-3d-engine/renderers/webgl-textured.js +211 -0
  43. package/dist/engines/little-3d-engine/renderers/webgpu-textured.d.ts +45 -0
  44. package/dist/engines/little-3d-engine/renderers/webgpu-textured.js +300 -0
  45. package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +11 -6
  46. package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +3 -0
  47. package/dist/engines/little-3d-engine/shapes/complex/plane.js +33 -0
  48. package/dist/engines/little-3d-engine/shapes/{cube.d.ts → primitives/cube.d.ts} +1 -1
  49. package/dist/engines/little-3d-engine/shapes/{octahedron.d.ts → primitives/octahedron.d.ts} +1 -1
  50. package/dist/engines/little-3d-engine/shapes/{pyramid.d.ts → primitives/pyramid.d.ts} +1 -1
  51. package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +9 -0
  52. package/dist/engines/little-3d-engine/shapes/primitives/quad.js +18 -0
  53. package/dist/engines/little-3d-engine/shapes/{cube-sphere.d.ts → primitives/spheres/cube-sphere.d.ts} +1 -1
  54. package/dist/engines/little-3d-engine/shapes/{icosphere.d.ts → primitives/spheres/icosphere.d.ts} +1 -1
  55. package/dist/engines/little-3d-engine/shapes/{icosphere.js → primitives/spheres/icosphere.js} +1 -1
  56. package/dist/engines/little-3d-engine/shapes/{octa-sphere.d.ts → primitives/spheres/octa-sphere.d.ts} +1 -1
  57. package/dist/engines/little-3d-engine/shapes/{octa-sphere.js → primitives/spheres/octa-sphere.js} +1 -1
  58. package/dist/engines/little-3d-engine/shapes/{uv-sphere.d.ts → primitives/spheres/uv-sphere.d.ts} +1 -1
  59. package/dist/engines/little-3d-engine/shapes/{tetrahedron.d.ts → primitives/tetrahedron.d.ts} +1 -1
  60. package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.d.ts +1 -0
  61. package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.js +8 -0
  62. package/dist/engines/little-3d-engine/textures/dynamic/shine.d.ts +2 -0
  63. package/dist/engines/little-3d-engine/textures/dynamic/shine.js +14 -0
  64. package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +2 -0
  65. package/dist/engines/little-3d-engine/textures/dynamic/star.js +16 -0
  66. package/dist/engines/little-3d-engine/textures/dynamic/streak.d.ts +5 -0
  67. package/dist/engines/little-3d-engine/textures/dynamic/streak.js +20 -0
  68. package/dist/index.d.ts +2 -1
  69. package/dist/prefabs/charged-orb.d.ts +17 -0
  70. package/dist/prefabs/charged-orb.js +37 -0
  71. package/dist/prefabs/crystal-comet.d.ts +4 -0
  72. package/dist/prefabs/crystal-comet.js +41 -0
  73. package/dist/prefabs/ghost-train.d.ts +9 -0
  74. package/dist/prefabs/ghost-train.js +44 -0
  75. package/dist/prefabs/grid-assembly.d.ts +13 -0
  76. package/dist/prefabs/grid-assembly.js +15 -0
  77. package/dist/prefabs/monochrome-streak.d.ts +4 -0
  78. package/dist/prefabs/monochrome-streak.js +25 -0
  79. package/dist/prefabs/plane-star-trail.d.ts +4 -0
  80. package/dist/prefabs/plane-star-trail.js +40 -0
  81. package/dist/prefabs/prefabs.d.ts +10 -0
  82. package/dist/prefabs/prefabs.js +9 -0
  83. package/dist/prefabs/pulsing-starfield.d.ts +4 -0
  84. package/dist/prefabs/pulsing-starfield.js +27 -0
  85. package/dist/prefabs/rocket-launch.d.ts +8 -0
  86. package/dist/prefabs/rocket-launch.js +59 -0
  87. package/dist/prefabs/spinner.d.ts +7 -0
  88. package/dist/prefabs/spinner.js +17 -0
  89. package/dist/prefabs/star-swarm.d.ts +4 -0
  90. package/dist/prefabs/star-swarm.js +27 -0
  91. package/dist/prefabs/types.d.ts +46 -0
  92. package/dist/prefabs/types.js +1 -0
  93. package/dist/umd/spinner.global.js +4212 -0
  94. package/dist/umd/spinner.global.min.js +113 -0
  95. package/package.json +67 -11
  96. /package/dist/engines/little-3d-engine/shapes/{cube.js → primitives/cube.js} +0 -0
  97. /package/dist/engines/little-3d-engine/shapes/{octahedron.js → primitives/octahedron.js} +0 -0
  98. /package/dist/engines/little-3d-engine/shapes/{pyramid.js → primitives/pyramid.js} +0 -0
  99. /package/dist/engines/little-3d-engine/shapes/{cube-sphere.js → primitives/spheres/cube-sphere.js} +0 -0
  100. /package/dist/engines/little-3d-engine/shapes/{uv-sphere.js → primitives/spheres/uv-sphere.js} +0 -0
  101. /package/dist/engines/little-3d-engine/shapes/{tetrahedron.js → primitives/tetrahedron.js} +0 -0
@@ -0,0 +1,268 @@
1
+ import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
2
+ import { Little3dEngine, cube, tetrahedron, octahedron, pyramid, icosphere, } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ import { easeInCubic, easeInOutCubic, easeOutCubic, } from "../engines/little-tween-engine/core/tweens.js";
4
+ const GRID = 5;
5
+ const COUNT = GRID * GRID;
6
+ const CAMERA_Z = 4;
7
+ const FOV = (55 * Math.PI) / 180;
8
+ const TWO_PI = Math.PI * 2;
9
+ const INTRO_MS = 900;
10
+ const INTRO_STAGGER_MS = 60;
11
+ const GATE_DOCK = 0.35;
12
+ const GATE_UNDOCK = 0.65;
13
+ const EXIT_HURRY = 2.5;
14
+ const SPIN_EVERY_MS = 2000;
15
+ const SPIN_MS = 380;
16
+ const SPIN_STAGGER_MS = 80;
17
+ const HOLD_MS = 1000;
18
+ const COLLAPSE_MS = 700;
19
+ const POP_MS = 170;
20
+ const LABEL_FADE_MS = 600;
21
+ const DEFAULT_MESHES = [
22
+ () => cube(1, ["#60a5fa", "#3b82f6", "#2563eb", "#38bdf8", "#0ea5e9", "#1d4ed8"]),
23
+ () => tetrahedron(1, ["#f472b6", "#ec4899", "#db2777", "#f9a8d4"]),
24
+ () => octahedron(1, ["#34d399", "#10b981", "#059669", "#6ee7b7", "#a7f3d0", "#047857", "#4ade80", "#065f46"]),
25
+ () => pyramid(1, ["#fbbf24", "#f59e0b", "#d97706", "#fde68a", "#fcd34d"]),
26
+ () => icosphere(1, 1, ["#a78bfa", "#8b5cf6", "#7c3aed", "#c4b5fd"]),
27
+ ];
28
+ function clamp01(value) {
29
+ return Math.max(0, Math.min(1, value));
30
+ }
31
+ function smooth01(value) {
32
+ const x = clamp01(value);
33
+ return x * x * (3 - 2 * x);
34
+ }
35
+ function resolveMesh(mesh) {
36
+ return typeof mesh === "function" ? mesh() : mesh;
37
+ }
38
+ // Shortest signed representation of an angle, so easing a tumble back to zero
39
+ // never unwinds through full revolutions.
40
+ function wrapAngle(angle) {
41
+ const wrapped = angle - TWO_PI * Math.floor(angle / TWO_PI);
42
+ return wrapped > Math.PI ? wrapped - TWO_PI : wrapped;
43
+ }
44
+ function hash01(index, salt) {
45
+ let h = (Math.imul(index + 1, 0x9e3779b9) ^ Math.imul(salt + 1, 0x85ebca6b)) >>> 0;
46
+ h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
47
+ h ^= h >>> 16;
48
+ return (h >>> 0) / 4294967296;
49
+ }
50
+ /**
51
+ * A progress story in three acts: 25 shapes fly in and circle just inside the
52
+ * view edge; as progress climbs they leave the orbit one by one and dock into
53
+ * a 5x5 grid at the center (docked shapes idle with a staggered spin every two
54
+ * seconds); at completion the finished grid holds for a second, then every
55
+ * shape dives into the center while shrinking away and vanishes with a small
56
+ * pop. Docking is driven by time-based blends toward per-shape targets, so a
57
+ * progress jump in either direction stays smooth.
58
+ */
59
+ export class GridAssemblyAnimation {
60
+ constructor(options = {}) {
61
+ this.handles = [];
62
+ this.blends = new Array(COUNT).fill(0);
63
+ this.dockedAt = new Array(COUNT).fill(Infinity);
64
+ this.tumbleX = [];
65
+ this.tumbleY = [];
66
+ this.fades = [];
67
+ this.slots = [];
68
+ this.aspect = 16 / 9;
69
+ this.enterAt = Infinity;
70
+ this.exitAt = Infinity;
71
+ this.allDockedAt = Infinity;
72
+ this.collapseAt = Infinity;
73
+ this.lastNow = 0;
74
+ this.popFading = false;
75
+ this.finished = false;
76
+ const sources = options.meshes && options.meshes.length > 0 ? options.meshes : DEFAULT_MESHES;
77
+ this.meshes = sources.map(resolveMesh);
78
+ this.size = options.size ?? 0.34;
79
+ this.orbitPeriodMs = options.orbitPeriodMs ?? 9000;
80
+ this.dockMs = options.dockMs ?? 800;
81
+ this.backend = options.backend;
82
+ this.labelContent = options.label;
83
+ this.fadeLabel = options.fadeLabel ?? true;
84
+ const spacing = this.size + (options.gap ?? 0.12);
85
+ for (let i = 0; i < COUNT; i++) {
86
+ const row = Math.floor(i / GRID);
87
+ const col = i % GRID;
88
+ this.slots.push({ x: (col - 2) * spacing, y: (2 - row) * spacing, z: 0 });
89
+ this.tumbleX.push(TWO_PI * hash01(i, 2) - Math.PI);
90
+ this.tumbleY.push(TWO_PI * hash01(i, 4) - Math.PI);
91
+ }
92
+ }
93
+ mount(target) {
94
+ if (!target.style.position)
95
+ target.style.position = "relative";
96
+ const engine = new Little3dEngine({
97
+ backend: this.backend,
98
+ camera: { position: { x: 0, y: 0, z: CAMERA_Z }, fov: FOV },
99
+ });
100
+ for (let i = 0; i < COUNT; i++) {
101
+ this.fades.push({ mode: "one-sided", opacity: 1 });
102
+ this.handles.push(engine.add(this.meshes[i % this.meshes.length], { scale: 0 }));
103
+ }
104
+ this.engine = engine;
105
+ engine.mount(target).catch((error) => {
106
+ target.textContent = error instanceof Error ? error.message : String(error);
107
+ });
108
+ const measure = () => {
109
+ if (target.clientWidth > 0 && target.clientHeight > 0) {
110
+ this.aspect = target.clientWidth / target.clientHeight;
111
+ }
112
+ };
113
+ measure();
114
+ this.observer = new ResizeObserver(measure);
115
+ this.observer.observe(target);
116
+ this.label = mountAnimationLabel(target, this.labelContent);
117
+ if (this.fadeLabel)
118
+ this.label.setOpacity(0);
119
+ }
120
+ enter(now) {
121
+ if (this.enterAt === Infinity)
122
+ this.enterAt = now;
123
+ }
124
+ exit(now) {
125
+ if (this.exitAt === Infinity)
126
+ this.exitAt = now;
127
+ }
128
+ isFinished() {
129
+ return this.finished;
130
+ }
131
+ render(now, frame) {
132
+ if (!this.engine || !this.label)
133
+ return;
134
+ if (this.enterAt === Infinity) {
135
+ for (const handle of this.handles)
136
+ handle.transform.scale = 0;
137
+ this.engine.render();
138
+ return;
139
+ }
140
+ const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
141
+ this.lastNow = now;
142
+ this.updateBlends(dt, frame.progress, now);
143
+ if (this.exitAt !== Infinity && this.allDockedAt !== Infinity && this.collapseAt === Infinity) {
144
+ this.collapseAt = Math.max(this.exitAt, this.allDockedAt) + HOLD_MS;
145
+ }
146
+ if (now >= this.collapseAt)
147
+ this.renderCollapse(now);
148
+ else
149
+ this.renderStory(now, dt);
150
+ this.label.setText(frame.indeterminate
151
+ ? (typeof this.labelContent === "string" ? this.labelContent : "")
152
+ : `${Math.round(frame.progress * 100)}%`);
153
+ if (this.fadeLabel) {
154
+ this.label.setOpacity(animationLabelOpacity(now, this.enterAt, LABEL_FADE_MS, this.collapseAt, COLLAPSE_MS));
155
+ }
156
+ if (this.collapseAt !== Infinity && now >= this.collapseAt + COLLAPSE_MS + POP_MS) {
157
+ this.finished = true;
158
+ }
159
+ this.engine.render();
160
+ }
161
+ destroy() {
162
+ this.observer?.disconnect();
163
+ this.observer = undefined;
164
+ this.label?.container.remove();
165
+ this.label = undefined;
166
+ this.engine?.destroy();
167
+ this.engine = undefined;
168
+ this.handles.length = 0;
169
+ this.fades.length = 0;
170
+ }
171
+ updateBlends(dt, progress, now) {
172
+ const exiting = this.exitAt !== Infinity;
173
+ const want = exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
174
+ const rate = (dt / this.dockMs) * (exiting ? EXIT_HURRY : 1);
175
+ for (let i = 0; i < COUNT; i++) {
176
+ const target = i < want ? 1 : 0;
177
+ const blend = this.blends[i];
178
+ if (target > blend && (i === 0 || this.blends[i - 1] >= GATE_DOCK)) {
179
+ this.blends[i] = Math.min(1, blend + rate);
180
+ }
181
+ else if (target < blend && (i === COUNT - 1 || this.blends[i + 1] <= GATE_UNDOCK)) {
182
+ this.blends[i] = Math.max(0, blend - rate);
183
+ }
184
+ if (this.blends[i] >= 1) {
185
+ if (this.dockedAt[i] === Infinity)
186
+ this.dockedAt[i] = now;
187
+ }
188
+ else {
189
+ this.dockedAt[i] = Infinity;
190
+ }
191
+ }
192
+ const allDocked = want === COUNT && this.blends.every((blend) => blend >= 1);
193
+ if (allDocked) {
194
+ if (this.allDockedAt === Infinity)
195
+ this.allDockedAt = now;
196
+ }
197
+ else if (!exiting) {
198
+ this.allDockedAt = Infinity;
199
+ }
200
+ }
201
+ renderStory(now, dt) {
202
+ const t = now - this.enterAt;
203
+ const halfHeight = Math.tan(FOV / 2) * CAMERA_Z;
204
+ const halfWidth = halfHeight * this.aspect;
205
+ const radius = Math.max(0.6, Math.min(halfWidth, halfHeight) - this.size * 0.55);
206
+ const spawnFactor = (Math.max(halfWidth, halfHeight) * 1.25 + this.size * 2) / radius;
207
+ for (let i = 0; i < COUNT; i++) {
208
+ const transform = this.handles[i].transform;
209
+ const eased = smooth01(this.blends[i]);
210
+ const introT = clamp01((t - i * INTRO_STAGGER_MS) / INTRO_MS);
211
+ const reach = 1 + (spawnFactor - 1) * (1 - easeOutCubic(introT));
212
+ const angle = Math.PI / 2 - (i / COUNT) * TWO_PI - (t / this.orbitPeriodMs) * TWO_PI;
213
+ const orbitX = Math.cos(angle) * radius * reach;
214
+ const orbitY = Math.sin(angle) * radius * reach;
215
+ const slot = this.slots[i];
216
+ transform.position.x = orbitX + (slot.x - orbitX) * eased;
217
+ transform.position.y = orbitY + (slot.y - orbitY) * eased;
218
+ transform.position.z = 0;
219
+ transform.scale = this.size;
220
+ // The tumble angle only advances while a shape is fully free, so easing it to
221
+ // zero during docking never crosses the +-pi seam and cannot visibly snap.
222
+ if (this.blends[i] === 0) {
223
+ this.tumbleX[i] = wrapAngle(this.tumbleX[i] + (0.0004 + 0.0008 * hash01(i, 1)) * dt);
224
+ this.tumbleY[i] = wrapAngle(this.tumbleY[i] + (0.0006 + 0.001 * hash01(i, 3)) * dt);
225
+ }
226
+ transform.rotation.z = 0;
227
+ if (this.blends[i] >= 1) {
228
+ const phase = (t + i * SPIN_STAGGER_MS) % SPIN_EVERY_MS;
229
+ const spinning = phase < SPIN_MS && now - phase >= this.dockedAt[i];
230
+ transform.rotation.x = 0;
231
+ transform.rotation.y = spinning ? TWO_PI * easeInOutCubic(phase / SPIN_MS) : 0;
232
+ }
233
+ else {
234
+ const free = 1 - eased;
235
+ transform.rotation.x = this.tumbleX[i] * free;
236
+ transform.rotation.y = this.tumbleY[i] * free;
237
+ }
238
+ }
239
+ }
240
+ renderCollapse(now) {
241
+ if (!this.captured) {
242
+ this.captured = this.handles.map((handle) => ({ ...handle.transform.position }));
243
+ }
244
+ const u = clamp01((now - this.collapseAt) / COLLAPSE_MS);
245
+ const pull = easeInCubic(u);
246
+ for (let i = 0; i < COUNT; i++) {
247
+ const transform = this.handles[i].transform;
248
+ const from = this.captured[i];
249
+ transform.position.x = from.x * (1 - pull);
250
+ transform.position.y = from.y * (1 - pull);
251
+ transform.position.z = from.z * (1 - pull);
252
+ transform.scale = this.size * (1 - 0.99 * pull);
253
+ }
254
+ if (u >= 1) {
255
+ if (!this.popFading) {
256
+ this.popFading = true;
257
+ for (let i = 0; i < COUNT; i++)
258
+ this.handles[i].transparency = this.fades[i];
259
+ }
260
+ const v = clamp01((now - this.collapseAt - COLLAPSE_MS) / POP_MS);
261
+ for (let i = 0; i < COUNT; i++) {
262
+ this.fades[i].opacity = 1 - v;
263
+ this.handles[i].transform.scale =
264
+ v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
265
+ }
266
+ }
267
+ }
268
+ }
@@ -1,4 +1,4 @@
1
- import type { AnimationFrame, SpinnerAnimation } from "../animation.js";
1
+ import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
2
2
  import { type Backend, type Mesh, type Transparency } from "../engines/little-3d-engine/little-3d-engine.js";
3
3
  import type { MotionController } from "../motion/controller.js";
4
4
  import { type ObjectMotionTransitionConfig } from "../motion/transitions.js";
@@ -27,7 +27,7 @@ export interface ObjectMotionOptions {
27
27
  mesh: Mesh | (() => Mesh);
28
28
  /** How the object moves: a circle, square, figure-8, wander, or any custom controller. */
29
29
  motion: MotionController;
30
- /** Face color applied to every triangle. Default `"#cbd5e1"`. */
30
+ /** Face color applied to every triangle. Omit to retain the mesh's face colors. */
31
31
  color?: string;
32
32
  /** Rendering backend. Default `"canvas2d"`. */
33
33
  backend?: Backend;
@@ -39,14 +39,16 @@ export interface ObjectMotionOptions {
39
39
  facing?: Facing;
40
40
  /** Additional local-space rotation on top of path orientation. */
41
41
  rotation?: ObjectMotionRotation;
42
- /** Intro transition. Defaults to `grow()`. */
42
+ /** Intro transition. Defaults to `enterFromObjectDirection()` (fly in along the path). */
43
43
  intro?: ObjectMotionTransitionConfig;
44
- /** Outro transition. Defaults to `shrink()`. */
44
+ /** Outro transition. Defaults to `leaveInObjectDirection()` (fly out along the path). */
45
45
  outro?: ObjectMotionTransitionConfig;
46
46
  /** Trailing copies that chase the lead in single file. Omit for a single object. */
47
47
  tail?: ObjectMotionTail;
48
48
  /** Overlay label shown in indeterminate mode (no value to show). Hidden if omitted. */
49
- label?: string;
49
+ label?: AnimationLabel;
50
+ /** Fade the label with the intro and outro transitions. Default `true`. */
51
+ fadeLabel?: boolean;
50
52
  }
51
53
  /** Centers a mesh at the origin and uniformly scales it to fit within `targetSize`. */
52
54
  export declare function centerAndScaleMesh(mesh: Mesh, targetSize: number): Mesh;
@@ -54,7 +56,7 @@ export declare function centerAndScaleMesh(mesh: Mesh, targetSize: number): Mesh
54
56
  * An object that moves along a {@link MotionController}'s path (a circle, a
55
57
  * square, a figure-8, a smooth wander, or any custom controller) with its nose
56
58
  * following the path tangent. The runner triggers the lifecycle: {@link enter}
57
- * pops it in, {@link exit} pops it out. Any mesh works (OBJ imports or engine
59
+ * flies it in along the path, {@link exit} flies it out. Any mesh works (OBJ imports or engine
58
60
  * primitives); `facing` corrects a model that points the wrong way, and
59
61
  * `rotation` adds local spin or tilt on top of path following.
60
62
  */
@@ -68,7 +70,8 @@ export declare class ObjectMotionAnimation implements SpinnerAnimation {
68
70
  private readonly motion;
69
71
  private readonly backend?;
70
72
  private readonly transparency?;
71
- private readonly labelText?;
73
+ private readonly labelContent?;
74
+ private readonly fadeLabel;
72
75
  private readonly tailCount;
73
76
  private readonly tailGap;
74
77
  private readonly intro;
@@ -88,6 +91,15 @@ export declare class ObjectMotionAnimation implements SpinnerAnimation {
88
91
  enter(now: number): void;
89
92
  exit(now: number): void;
90
93
  isFinished(): boolean;
94
+ /** Milliseconds the fly-out takes; used to align a following particle trail's outro. */
95
+ get outroDurationMs(): number;
96
+ /**
97
+ * A {@link MotionController} that follows the object's *actual* position, including
98
+ * the intro fly-in and outro fly-out (it falls back to the raw motion path before
99
+ * {@link enter} and once idle). Feed it to a particle layer's `emitter` so the
100
+ * particles trail the object through its transitions instead of the bare path.
101
+ */
102
+ trailEmitter(): MotionController;
91
103
  render(now: number, frame: AnimationFrame): void;
92
104
  destroy(): void;
93
105
  private aheadAt;
@@ -1,19 +1,7 @@
1
+ import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
1
2
  import { Little3dEngine, cross, normalize, scale, subtract, } from "../engines/little-3d-engine/little-3d-engine.js";
2
3
  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(";");
4
+ import { enterFromObjectDirection, leaveInObjectDirection, } from "../motion/transitions.js";
17
5
  const WORLD_UP = { x: 0, y: 1, z: 0 };
18
6
  const DEFAULT_INTRO_MS = 2100;
19
7
  const DEFAULT_OUTRO_MS = 2100;
@@ -37,6 +25,8 @@ function resolveMesh(mesh) {
37
25
  return typeof mesh === "function" ? mesh() : mesh;
38
26
  }
39
27
  function applyColor(mesh, color) {
28
+ if (color === undefined)
29
+ return mesh;
40
30
  return { vertices: mesh.vertices, faces: mesh.faces.map((face) => ({ ...face, color })) };
41
31
  }
42
32
  function faceForward(mesh, facing) {
@@ -138,7 +128,7 @@ function resolveTransition(config, fallback, durationMs) {
138
128
  * An object that moves along a {@link MotionController}'s path (a circle, a
139
129
  * square, a figure-8, a smooth wander, or any custom controller) with its nose
140
130
  * 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
131
+ * flies it in along the path, {@link exit} flies it out. Any mesh works (OBJ imports or engine
142
132
  * primitives); `facing` corrects a model that points the wrong way, and
143
133
  * `rotation` adds local spin or tilt on top of path following.
144
134
  */
@@ -156,15 +146,16 @@ export class ObjectMotionAnimation {
156
146
  this.outroDirection = { x: 1, y: 0, z: 0 };
157
147
  const centered = centerAndScaleMesh(resolveMesh(options.mesh), options.size ?? 1);
158
148
  const facing = faceForward(centered, options.facing ?? "+x");
159
- this.mesh = applyColor(facing, options.color ?? "#cbd5e1");
149
+ this.mesh = applyColor(facing, options.color);
160
150
  this.motion = options.motion;
161
151
  this.backend = options.backend;
162
152
  this.transparency = options.transparency;
163
- this.labelText = options.label;
153
+ this.labelContent = options.label;
154
+ this.fadeLabel = options.fadeLabel ?? true;
164
155
  this.tailCount = Math.max(0, Math.floor(options.tail?.count ?? 0));
165
156
  this.tailGap = Math.max(0, options.tail?.gapMs ?? 0);
166
- this.intro = resolveTransition(options.intro, grow(), DEFAULT_INTRO_MS);
167
- this.outro = resolveTransition(options.outro, shrink(), DEFAULT_OUTRO_MS);
157
+ this.intro = resolveTransition(options.intro, enterFromObjectDirection(), DEFAULT_INTRO_MS);
158
+ this.outro = resolveTransition(options.outro, leaveInObjectDirection(), DEFAULT_OUTRO_MS);
168
159
  const rotation = options.rotation;
169
160
  this.rotationOffset = { x: rotation?.x ?? 0, y: rotation?.y ?? 0, z: rotation?.z ?? 0 };
170
161
  this.rotationSpin = {
@@ -181,7 +172,8 @@ export class ObjectMotionAnimation {
181
172
  this.rotationSpin.z !== 0;
182
173
  }
183
174
  mount(target) {
184
- target.style.position = "relative";
175
+ if (!target.style.position)
176
+ target.style.position = "relative";
185
177
  const engine = new Little3dEngine({
186
178
  backend: this.backend,
187
179
  camera: { position: { x: 0, y: 0, z: 3 } },
@@ -195,11 +187,9 @@ export class ObjectMotionAnimation {
195
187
  engine.mount(target).catch((error) => {
196
188
  target.textContent = error instanceof Error ? error.message : String(error);
197
189
  });
198
- const label = document.createElement("div");
199
- label.style.cssText = LABEL_STYLE;
200
- label.setAttribute("role", "status");
201
- target.appendChild(label);
202
- this.label = label;
190
+ this.label = mountAnimationLabel(target, this.labelContent);
191
+ if (this.fadeLabel)
192
+ this.label.setOpacity(0);
203
193
  }
204
194
  enter(now) {
205
195
  if (this.started)
@@ -218,6 +208,19 @@ export class ObjectMotionAnimation {
218
208
  isFinished() {
219
209
  return this.finished;
220
210
  }
211
+ /** Milliseconds the fly-out takes; used to align a following particle trail's outro. */
212
+ get outroDurationMs() {
213
+ return this.outro.durationMs;
214
+ }
215
+ /**
216
+ * A {@link MotionController} that follows the object's *actual* position, including
217
+ * the intro fly-in and outro fly-out (it falls back to the raw motion path before
218
+ * {@link enter} and once idle). Feed it to a particle layer's `emitter` so the
219
+ * particles trail the object through its transitions instead of the bare path.
220
+ */
221
+ trailEmitter() {
222
+ return { positionAt: (t) => this.sampleAt(t)?.position ?? this.motion.positionAt(t) };
223
+ }
221
224
  render(now, frame) {
222
225
  if (!this.engine || !this.label)
223
226
  return;
@@ -258,13 +261,16 @@ export class ObjectMotionAnimation {
258
261
  transform.rotation.y = euler.y;
259
262
  transform.rotation.z = euler.z;
260
263
  }
261
- this.label.textContent = frame.indeterminate
262
- ? (this.labelText ?? "")
263
- : `${Math.round(frame.progress * 100)}%`;
264
+ this.label.setText(frame.indeterminate
265
+ ? (typeof this.labelContent === "string" ? this.labelContent : "")
266
+ : `${Math.round(frame.progress * 100)}%`);
267
+ if (this.fadeLabel) {
268
+ this.label.setOpacity(animationLabelOpacity(now, this.started ? this.introStart : Infinity, this.intro.durationMs, this.outroStart, this.outro.durationMs));
269
+ }
264
270
  this.engine.render();
265
271
  }
266
272
  destroy() {
267
- this.label?.remove();
273
+ this.label?.container.remove();
268
274
  this.label = undefined;
269
275
  this.engine?.destroy();
270
276
  this.engine = undefined;
@@ -302,7 +308,7 @@ export class ObjectMotionAnimation {
302
308
  const delta = transition.durationMs === 0 ? 1 : clamp01(elapsedMs / transition.durationMs);
303
309
  const input = this.transitionInput(phase, delta, elapsedMs, transition.durationMs, start);
304
310
  const output = transition.transition(input);
305
- return this.applyTransitionOutput(input, output);
311
+ return this.applyTransitionOutput(input, output, phase, t);
306
312
  }
307
313
  transitionInput(phase, delta, elapsedMs, durationMs, start) {
308
314
  if (phase === "intro") {
@@ -330,9 +336,9 @@ export class ObjectMotionAnimation {
330
336
  phase,
331
337
  };
332
338
  }
333
- applyTransitionOutput(input, output) {
339
+ applyTransitionOutput(input, output, phase, t) {
334
340
  return {
335
- position: output.position ?? input.position,
341
+ position: output.position ?? (phase === "intro" ? this.motion.positionAt(t) : input.position),
336
342
  size: output.size ?? input.size ?? 1,
337
343
  orientation: output.orientation,
338
344
  };
@@ -0,0 +1,110 @@
1
+ import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
2
+ import type { MotionController } from "../motion/controller.js";
3
+ import { type Backend, type Vec3 } from "../engines/little-3d-engine/little-3d-engine.js";
4
+ export interface ParticlesOptions {
5
+ /** Particles emitted per second. Default `20`. */
6
+ rate?: number;
7
+ /** Lifetime of one particle in milliseconds. Default `1800`. */
8
+ lifeMs?: number;
9
+ /** Particle colors, cycled across particles. Defaults to a built-in palette. */
10
+ colors?: string[];
11
+ /** Base particle size in world units, varied per particle. Default `0.16`. */
12
+ size?: number;
13
+ /** Base emission speed in world units per second, varied per particle. Default `0.6`. */
14
+ speed?: number;
15
+ /** Constant acceleration in world units per second squared. Default none. */
16
+ gravity?: Vec3;
17
+ /** Mean emission direction. Omit to emit uniformly in all directions. */
18
+ direction?: Vec3;
19
+ /** Cone half-angle around `direction` in radians. Default `0.5`. */
20
+ spread?: number;
21
+ /** Peak particle opacity `0..1`. Default `0.9`. */
22
+ opacity?: number;
23
+ /** Maximum spin around the view axis in radians per millisecond. Default `0.002`. */
24
+ spin?: number;
25
+ /** Rotate each billboard around the view axis to follow its current velocity. Default `false`. */
26
+ alignToMotion?: boolean;
27
+ /** Seed for the deterministic particle stream. Default `1`. */
28
+ seed?: number;
29
+ /** Rendering backend. Default `"canvas2d"`. */
30
+ backend?: Backend;
31
+ /** Optional moving emission origin. Each particle keeps the origin where it was emitted. */
32
+ emitter?: MotionController;
33
+ /**
34
+ * Milliseconds to keep emitting after {@link ParticlesAnimation.exit}. Default `0`
35
+ * (emission stops at exit). Give it a moving `emitter`'s outro duration so fresh
36
+ * particles keep trailing the emitter as it flies out, instead of freezing where
37
+ * the loop left off.
38
+ */
39
+ outroMs?: number;
40
+ /**
41
+ * Image applied to every particle (a URL or a drawable element), tinted by
42
+ * the particle color; the image's alpha shapes the particle. Renders
43
+ * through a textured renderer, fetched on demand: the WebGPU one when
44
+ * `backend` is `"webgpu"`, otherwise the WebGL one.
45
+ */
46
+ texture?: string | TexImageSource;
47
+ /** Overlay label shown in indeterminate mode (no value to show). Hidden if omitted. */
48
+ label?: AnimationLabel;
49
+ /** Fade the label as particles appear and drain away. Default `true`. */
50
+ fadeLabel?: boolean;
51
+ }
52
+ /** State of one live particle: where it is and how it looks. */
53
+ export interface ParticleSample {
54
+ position: Vec3;
55
+ /** Rotation around the view axis, radians. */
56
+ roll: number;
57
+ size: number;
58
+ opacity: number;
59
+ }
60
+ /**
61
+ * A deterministic particle stream. Particle `index` is emitted at
62
+ * `index * spawnGapMs`; its whole life is a pure function of time, so the
63
+ * field holds no per-frame state and the same seed replays the same stream.
64
+ */
65
+ export interface ParticleField {
66
+ /** Upper bound on simultaneously live particles; slots recycle beyond it. */
67
+ maxLive: number;
68
+ /** Milliseconds between consecutive emissions. */
69
+ spawnGapMs: number;
70
+ /** Lifetime of one particle in milliseconds. */
71
+ lifeMs: number;
72
+ /**
73
+ * State of particle `index` at `t` milliseconds after emission started, or
74
+ * `undefined` while unborn or after death.
75
+ */
76
+ sample(index: number, t: number): ParticleSample | undefined;
77
+ }
78
+ /** Create a {@link ParticleField} from the emission options. */
79
+ export declare function particleField(options?: ParticlesOptions): ParticleField;
80
+ /**
81
+ * A stream of camera-facing billboard particles: a burst, a fountain, drifting
82
+ * embers - shaped by the emission options. Particles fade in, drift under
83
+ * `gravity`, and fade out; the runner triggers the lifecycle: {@link enter}
84
+ * starts emission, {@link exit} stops it and lets the live particles die out
85
+ * as the outro.
86
+ */
87
+ export declare class ParticlesAnimation implements SpinnerAnimation {
88
+ private engine?;
89
+ private label?;
90
+ private readonly handles;
91
+ private readonly fades;
92
+ private readonly field;
93
+ private readonly colors;
94
+ private readonly backend?;
95
+ private readonly texture?;
96
+ private readonly labelContent?;
97
+ private readonly fadeLabel;
98
+ private readonly emitter?;
99
+ private readonly outroMs;
100
+ private enterAt;
101
+ private exitAt;
102
+ private finished;
103
+ constructor(options?: ParticlesOptions);
104
+ mount(target: HTMLElement): void;
105
+ enter(now: number): void;
106
+ exit(now: number): void;
107
+ isFinished(): boolean;
108
+ render(now: number, frame: AnimationFrame): void;
109
+ destroy(): void;
110
+ }