3d-spinner 0.9.2 → 0.9.4

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 +66 -4
  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/ghost-train.d.ts +72 -0
  8. package/dist/animations/ghost-train.js +247 -0
  9. package/dist/animations/grid-assembly.d.ts +70 -0
  10. package/dist/animations/grid-assembly.js +282 -0
  11. package/dist/animations/object-motion.d.ts +19 -7
  12. package/dist/animations/object-motion.js +38 -32
  13. package/dist/animations/particles.d.ts +110 -0
  14. package/dist/animations/particles.js +209 -0
  15. package/dist/animations/rocket-launch.d.ts +58 -0
  16. package/dist/animations/rocket-launch.js +375 -0
  17. package/dist/cjs/animations/charged-orb.cjs +1334 -0
  18. package/dist/cjs/animations/grid-assembly.cjs +1380 -0
  19. package/dist/cjs/animations/object-motion.cjs +124 -43
  20. package/dist/cjs/animations/particles.cjs +1940 -0
  21. package/dist/cjs/animations/spin.cjs +4 -3
  22. package/dist/cjs/composite-animation.cjs +58 -0
  23. package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +143 -22
  24. package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +30 -1
  25. package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
  26. package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
  27. package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
  28. package/dist/cjs/prefabs/prefabs.cjs +3916 -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 +14 -0
  65. package/dist/engines/little-3d-engine/textures/dynamic/star.js +31 -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 +10 -0
  74. package/dist/prefabs/ghost-train.js +37 -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 +10 -0
  86. package/dist/prefabs/rocket-launch.js +16 -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 +2407 -85
  94. package/dist/umd/spinner.global.min.js +60 -5
  95. package/package.json +43 -2
  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
@@ -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
+ }
@@ -0,0 +1,209 @@
1
+ import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
2
+ import { Little3dEngine, quad, cross, normalize, } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ const DEFAULT_COLORS = ["#fde047", "#fb923c", "#f472b6", "#60a5fa"];
4
+ const FADE_IN_END = 0.15;
5
+ const FADE_OUT_START = 0.6;
6
+ function rand01(seed, index, salt) {
7
+ let h = (seed ^ Math.imul(index + 1, 0x9e3779b9) ^ Math.imul(salt + 1, 0x85ebca6b)) >>> 0;
8
+ h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
9
+ h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
10
+ h ^= h >>> 16;
11
+ return (h >>> 0) / 4294967296;
12
+ }
13
+ function smoothstep(edge0, edge1, value) {
14
+ const x = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
15
+ return x * x * (3 - 2 * x);
16
+ }
17
+ function positiveFinite(value, name) {
18
+ if (!Number.isFinite(value) || value <= 0) {
19
+ throw new RangeError(`3d-spinner: ${name} must be a finite number greater than zero.`);
20
+ }
21
+ return value;
22
+ }
23
+ function emitBasis(direction) {
24
+ const d = normalize(direction);
25
+ const helper = Math.abs(d.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 };
26
+ const right = normalize(cross(helper, d));
27
+ return { d, right, up: cross(d, right) };
28
+ }
29
+ /** Create a {@link ParticleField} from the emission options. */
30
+ export function particleField(options = {}) {
31
+ const rate = positiveFinite(options.rate ?? 20, "rate");
32
+ const lifeMs = positiveFinite(options.lifeMs ?? 1800, "lifeMs");
33
+ const size = options.size ?? 0.16;
34
+ const speed = options.speed ?? 0.6;
35
+ const gravity = options.gravity;
36
+ const spread = options.spread ?? 0.5;
37
+ const peak = Math.max(0, Math.min(1, options.opacity ?? 0.9));
38
+ const spin = options.spin ?? 0.002;
39
+ const alignToMotion = options.alignToMotion ?? false;
40
+ const seed = options.seed ?? 1;
41
+ const basis = options.direction && emitBasis(options.direction);
42
+ const spawnGapMs = 1000 / rate;
43
+ const directionOf = (index) => {
44
+ const u = rand01(seed, index, 0);
45
+ const phi = 2 * Math.PI * rand01(seed, index, 1);
46
+ if (!basis) {
47
+ const z = 2 * u - 1;
48
+ const r = Math.sqrt(Math.max(0, 1 - z * z));
49
+ return { x: r * Math.cos(phi), y: r * Math.sin(phi), z };
50
+ }
51
+ const cos = 1 - u * (1 - Math.cos(spread));
52
+ const sin = Math.sqrt(Math.max(0, 1 - cos * cos));
53
+ const { d, right, up } = basis;
54
+ return {
55
+ x: d.x * cos + (right.x * Math.cos(phi) + up.x * Math.sin(phi)) * sin,
56
+ y: d.y * cos + (right.y * Math.cos(phi) + up.y * Math.sin(phi)) * sin,
57
+ z: d.z * cos + (right.z * Math.cos(phi) + up.z * Math.sin(phi)) * sin,
58
+ };
59
+ };
60
+ return {
61
+ maxLive: Math.ceil((lifeMs * rate) / 1000) + 1,
62
+ spawnGapMs,
63
+ lifeMs,
64
+ sample(index, t) {
65
+ if (index < 0)
66
+ return undefined;
67
+ const age = t - index * spawnGapMs;
68
+ if (age < 0 || age >= lifeMs)
69
+ return undefined;
70
+ const seconds = age / 1000;
71
+ const dir = directionOf(index);
72
+ const particleSpeed = speed * (0.6 + 0.8 * rand01(seed, index, 2));
73
+ const travel = particleSpeed * seconds;
74
+ const pull = gravity ? 0.5 * seconds * seconds : 0;
75
+ const life = age / lifeMs;
76
+ const roll = alignToMotion
77
+ ? Math.atan2(dir.y * particleSpeed + (gravity?.y ?? 0) * seconds, dir.x * particleSpeed + (gravity?.x ?? 0) * seconds)
78
+ : 2 * Math.PI * rand01(seed, index, 3)
79
+ + (2 * rand01(seed, index, 4) - 1) * spin * age;
80
+ return {
81
+ position: {
82
+ x: dir.x * travel + (gravity ? gravity.x * pull : 0),
83
+ y: dir.y * travel + (gravity ? gravity.y * pull : 0),
84
+ z: dir.z * travel + (gravity ? gravity.z * pull : 0),
85
+ },
86
+ roll,
87
+ size: size * (0.7 + 0.6 * rand01(seed, index, 5)),
88
+ opacity: peak * smoothstep(0, FADE_IN_END, life) * (1 - smoothstep(FADE_OUT_START, 1, life)),
89
+ };
90
+ },
91
+ };
92
+ }
93
+ /**
94
+ * A stream of camera-facing billboard particles: a burst, a fountain, drifting
95
+ * embers - shaped by the emission options. Particles fade in, drift under
96
+ * `gravity`, and fade out; the runner triggers the lifecycle: {@link enter}
97
+ * starts emission, {@link exit} stops it and lets the live particles die out
98
+ * as the outro.
99
+ */
100
+ export class ParticlesAnimation {
101
+ constructor(options = {}) {
102
+ this.handles = [];
103
+ this.fades = [];
104
+ this.enterAt = Infinity;
105
+ this.exitAt = Infinity;
106
+ this.finished = false;
107
+ this.field = particleField(options);
108
+ this.colors = options.colors ?? DEFAULT_COLORS;
109
+ this.backend = options.backend;
110
+ this.texture = options.texture;
111
+ this.labelContent = options.label;
112
+ this.fadeLabel = options.fadeLabel ?? true;
113
+ this.emitter = options.emitter;
114
+ this.outroMs = Math.max(0, options.outroMs ?? 0);
115
+ }
116
+ mount(target) {
117
+ if (!target.style.position)
118
+ target.style.position = "relative";
119
+ const meshes = this.colors.map((color) => quad(1, [color]));
120
+ const texture = this.texture;
121
+ const backend = texture
122
+ ? async (rendererOptions) => {
123
+ const renderer = this.backend === "webgpu"
124
+ ? new (await import("../engines/little-3d-engine/renderers/webgpu-textured.js")).WebGPUTexturedRenderer(rendererOptions)
125
+ : this.backend === "webgl"
126
+ ? new (await import("../engines/little-3d-engine/renderers/webgl-textured.js")).WebGLTexturedRenderer(rendererOptions)
127
+ : new (await import("../engines/little-3d-engine/renderers/canvas2d-textured.js")).Canvas2DTexturedRenderer(rendererOptions);
128
+ for (const mesh of meshes)
129
+ renderer.setTexture(mesh, texture);
130
+ return renderer;
131
+ }
132
+ : this.backend;
133
+ const engine = new Little3dEngine({
134
+ backend,
135
+ camera: { position: { x: 0, y: 0, z: 3 } },
136
+ light: { intensity: 0, ambient: 1 },
137
+ });
138
+ for (let slot = 0; slot < this.field.maxLive; slot++) {
139
+ const fade = { mode: "one-sided", opacity: 0 };
140
+ this.fades.push(fade);
141
+ this.handles.push(engine.add(meshes[slot % meshes.length], { scale: 0, transparency: fade }));
142
+ }
143
+ this.engine = engine;
144
+ engine.mount(target).catch((error) => {
145
+ target.textContent = error instanceof Error ? error.message : String(error);
146
+ });
147
+ this.label = mountAnimationLabel(target, this.labelContent);
148
+ if (this.fadeLabel)
149
+ this.label.setOpacity(0);
150
+ }
151
+ enter(now) {
152
+ if (this.enterAt === Infinity)
153
+ this.enterAt = now;
154
+ }
155
+ exit(now) {
156
+ if (this.exitAt === Infinity)
157
+ this.exitAt = now;
158
+ }
159
+ isFinished() {
160
+ return this.finished;
161
+ }
162
+ render(now, frame) {
163
+ if (!this.engine || !this.label)
164
+ return;
165
+ if (this.exitAt !== Infinity && now >= this.exitAt + this.outroMs + this.field.lifeMs)
166
+ this.finished = true;
167
+ for (const handle of this.handles)
168
+ handle.transform.scale = 0;
169
+ if (this.enterAt !== Infinity) {
170
+ const t = now - this.enterAt;
171
+ const gap = this.field.spawnGapMs;
172
+ let first = Math.max(0, Math.ceil((t - this.field.lifeMs) / gap));
173
+ let last = Math.floor(t / gap);
174
+ if (this.exitAt !== Infinity) {
175
+ last = Math.min(last, Math.floor((this.exitAt - this.enterAt + this.outroMs) / gap));
176
+ }
177
+ first = Math.max(first, last - this.field.maxLive + 1);
178
+ for (let index = first; index <= last; index++) {
179
+ const sample = this.field.sample(index, t);
180
+ if (!sample)
181
+ continue;
182
+ const slot = index % this.handles.length;
183
+ const transform = this.handles[slot].transform;
184
+ const origin = this.emitter?.positionAt(this.enterAt + index * gap);
185
+ transform.position.x = sample.position.x + (origin?.x ?? 0);
186
+ transform.position.y = sample.position.y + (origin?.y ?? 0);
187
+ transform.position.z = sample.position.z + (origin?.z ?? 0);
188
+ transform.rotation.z = sample.roll;
189
+ transform.scale = sample.size;
190
+ this.fades[slot].opacity = sample.opacity;
191
+ }
192
+ }
193
+ this.label.setText(frame.indeterminate
194
+ ? (typeof this.labelContent === "string" ? this.labelContent : "")
195
+ : `${Math.round(frame.progress * 100)}%`);
196
+ if (this.fadeLabel) {
197
+ this.label.setOpacity(animationLabelOpacity(now, this.enterAt, this.field.lifeMs * FADE_IN_END, this.exitAt, this.field.lifeMs));
198
+ }
199
+ this.engine.render();
200
+ }
201
+ destroy() {
202
+ this.label?.container.remove();
203
+ this.label = undefined;
204
+ this.engine?.destroy();
205
+ this.engine = undefined;
206
+ this.handles.length = 0;
207
+ this.fades.length = 0;
208
+ }
209
+ }
@@ -0,0 +1,58 @@
1
+ import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
2
+ import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ export interface RocketLaunchOptions {
4
+ /** Rendering backend. Default `"canvas2d"`. */
5
+ backend?: Backend;
6
+ /** Overlay label; progress mode shows a percentage. */
7
+ label?: AnimationLabel;
8
+ /** Fade the label with the story's beginning and launch. Default `true`. */
9
+ fadeLabel?: boolean;
10
+ }
11
+ /**
12
+ * A progress story told by a launch pad. Every 5% of progress a small rocket
13
+ * slides in cartoon-style from the right and lines up left-to-right under the
14
+ * progress text, idling with a thin wisp of smoke. At 100% the whole row blasts
15
+ * off in a loose stagger on columns of fire; partway up three of them suddenly
16
+ * veer 30-50 degrees and streak away. Rocket count follows the reported
17
+ * progress, so scrubbing in either direction stays smooth.
18
+ */
19
+ export declare class RocketLaunchAnimation implements SpinnerAnimation {
20
+ private engine?;
21
+ private label?;
22
+ private observer?;
23
+ private readonly backend?;
24
+ private readonly labelContent?;
25
+ private readonly fadeLabel;
26
+ private readonly rockets;
27
+ private readonly smoke;
28
+ private readonly fire;
29
+ private readonly smokeFades;
30
+ private readonly fireFades;
31
+ private readonly blends;
32
+ private readonly groundedAt;
33
+ private readonly turnS;
34
+ private readonly turnDir;
35
+ private readonly turnRoll;
36
+ private readonly stagger;
37
+ private aspect;
38
+ private enterAt;
39
+ private exitAt;
40
+ private launchedAt;
41
+ private lastNow;
42
+ private finished;
43
+ constructor(options?: RocketLaunchOptions);
44
+ mount(target: HTMLElement): void;
45
+ enter(now: number): void;
46
+ exit(now: number): void;
47
+ isFinished(): boolean;
48
+ render(now: number, frame: AnimationFrame): void;
49
+ destroy(): void;
50
+ private updateBlends;
51
+ /** Along-track distance climbed `la` ms after this rocket's own blast-off. */
52
+ private ascentDistance;
53
+ /** Rocket center, nose direction, and roll `la` ms into its climb. */
54
+ private ascentPose;
55
+ private renderAscent;
56
+ private emitFire;
57
+ private emitSmoke;
58
+ }