3d-spinner 0.9.3 → 0.9.5

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 (62) hide show
  1. package/dist/animations/charged-orb.js +4 -2
  2. package/dist/animations/ghost-train.d.ts +72 -0
  3. package/dist/animations/ghost-train.js +249 -0
  4. package/dist/animations/grid-assembly.d.ts +11 -8
  5. package/dist/animations/grid-assembly.js +44 -30
  6. package/dist/animations/rocket-launch.d.ts +58 -0
  7. package/dist/animations/rocket-launch.js +375 -0
  8. package/dist/animations/spin.d.ts +3 -1
  9. package/dist/animations/spin.js +6 -1
  10. package/dist/cjs/animations/charged-orb.cjs +170 -28
  11. package/dist/cjs/animations/grid-assembly.cjs +192 -172
  12. package/dist/cjs/animations/object-motion.cjs +156 -24
  13. package/dist/cjs/animations/particles.cjs +167 -26
  14. package/dist/cjs/animations/spin.cjs +169 -27
  15. package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +215 -55
  16. package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +51 -13
  17. package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +56 -6
  18. package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +55 -6
  19. package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +64 -11
  20. package/dist/cjs/prefabs/prefabs.cjs +817 -205
  21. package/dist/engines/little-3d-engine/core/geometry.d.ts +15 -2
  22. package/dist/engines/little-3d-engine/core/geometry.js +25 -3
  23. package/dist/engines/little-3d-engine/core/light.d.ts +28 -4
  24. package/dist/engines/little-3d-engine/core/light.js +48 -7
  25. package/dist/engines/little-3d-engine/core/mesh.d.ts +33 -0
  26. package/dist/engines/little-3d-engine/core/mesh.js +12 -0
  27. package/dist/engines/little-3d-engine/little-3d-engine.d.ts +2 -2
  28. package/dist/engines/little-3d-engine/little-3d-engine.js +1 -1
  29. package/dist/engines/little-3d-engine/loaders/obj.d.ts +8 -7
  30. package/dist/engines/little-3d-engine/loaders/obj.js +74 -20
  31. package/dist/engines/little-3d-engine/renderers/canvas2d.js +23 -1
  32. package/dist/engines/little-3d-engine/renderers/webgl.js +34 -5
  33. package/dist/engines/little-3d-engine/renderers/webgpu.js +43 -10
  34. package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +8 -3
  35. package/dist/engines/little-3d-engine/shapes/complex/plane.js +10 -4
  36. package/dist/engines/little-3d-engine/shapes/primitives/cube.d.ts +3 -2
  37. package/dist/engines/little-3d-engine/shapes/primitives/cube.js +4 -2
  38. package/dist/engines/little-3d-engine/shapes/primitives/octahedron.d.ts +3 -2
  39. package/dist/engines/little-3d-engine/shapes/primitives/octahedron.js +4 -2
  40. package/dist/engines/little-3d-engine/shapes/primitives/pyramid.d.ts +3 -2
  41. package/dist/engines/little-3d-engine/shapes/primitives/pyramid.js +4 -2
  42. package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +3 -2
  43. package/dist/engines/little-3d-engine/shapes/primitives/quad.js +4 -2
  44. package/dist/engines/little-3d-engine/shapes/primitives/spheres/cube-sphere.d.ts +3 -2
  45. package/dist/engines/little-3d-engine/shapes/primitives/spheres/cube-sphere.js +4 -2
  46. package/dist/engines/little-3d-engine/shapes/primitives/spheres/icosphere.d.ts +3 -2
  47. package/dist/engines/little-3d-engine/shapes/primitives/spheres/icosphere.js +4 -2
  48. package/dist/engines/little-3d-engine/shapes/primitives/spheres/octa-sphere.d.ts +3 -2
  49. package/dist/engines/little-3d-engine/shapes/primitives/spheres/octa-sphere.js +4 -2
  50. package/dist/engines/little-3d-engine/shapes/primitives/spheres/uv-sphere.d.ts +3 -2
  51. package/dist/engines/little-3d-engine/shapes/primitives/spheres/uv-sphere.js +4 -2
  52. package/dist/engines/little-3d-engine/shapes/primitives/tetrahedron.d.ts +3 -2
  53. package/dist/engines/little-3d-engine/shapes/primitives/tetrahedron.js +4 -2
  54. package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -2
  55. package/dist/engines/little-3d-engine/textures/dynamic/star.js +26 -11
  56. package/dist/prefabs/ghost-train.d.ts +5 -4
  57. package/dist/prefabs/ghost-train.js +17 -24
  58. package/dist/prefabs/rocket-launch.d.ts +7 -5
  59. package/dist/prefabs/rocket-launch.js +10 -53
  60. package/dist/umd/spinner.global.js +877 -167
  61. package/dist/umd/spinner.global.min.js +58 -17
  62. package/package.json +2 -2
@@ -139,7 +139,10 @@ var Spinner3D = (() => {
139
139
  const positions = new Float32Array(triangles * 9);
140
140
  const normals = new Float32Array(triangles * 9);
141
141
  const colors = new Float32Array(triangles * 9);
142
+ const emissives = new Float32Array(triangles * 9);
143
+ const speculars = new Float32Array(triangles * 12);
142
144
  let o = 0;
145
+ let so = 0;
143
146
  for (const face of mesh.faces) {
144
147
  const v0 = mesh.vertices[face.indices[0]];
145
148
  const v1 = mesh.vertices[face.indices[1]];
@@ -149,6 +152,15 @@ var Spinner3D = (() => {
149
152
  const cr = r / 255;
150
153
  const cg = g / 255;
151
154
  const cb = b / 255;
155
+ const emissive = face.material?.emissive;
156
+ const er = emissive ? emissive[0] : 0;
157
+ const eg = emissive ? emissive[1] : 0;
158
+ const eb = emissive ? emissive[2] : 0;
159
+ const specular = face.material?.specular;
160
+ const sr = specular ? specular[0] : 0;
161
+ const sg = specular ? specular[1] : 0;
162
+ const sb = specular ? specular[2] : 0;
163
+ const sn = specular ? face.material?.shininess ?? 32 : 1;
152
164
  for (let k = 1; k < face.indices.length - 1; k++) {
153
165
  const tri = [face.indices[0], face.indices[k], face.indices[k + 1]];
154
166
  for (const index of tri) {
@@ -162,11 +174,19 @@ var Spinner3D = (() => {
162
174
  colors[o] = cr;
163
175
  colors[o + 1] = cg;
164
176
  colors[o + 2] = cb;
177
+ emissives[o] = er;
178
+ emissives[o + 1] = eg;
179
+ emissives[o + 2] = eb;
180
+ speculars[so] = sr;
181
+ speculars[so + 1] = sg;
182
+ speculars[so + 2] = sb;
183
+ speculars[so + 3] = sn;
165
184
  o += 3;
185
+ so += 4;
166
186
  }
167
187
  }
168
188
  }
169
- return { positions, normals, colors, count: positions.length / 3 };
189
+ return { positions, normals, colors, emissives, speculars, count: positions.length / 3 };
170
190
  }
171
191
  function midpoint(a, b) {
172
192
  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2 };
@@ -204,13 +224,42 @@ var Spinner3D = (() => {
204
224
  function clamp012(value) {
205
225
  return Math.min(1, Math.max(0, value));
206
226
  }
207
- function shadeColor(normal, color, light) {
227
+ function clamp255(value) {
228
+ return Math.round(Math.min(255, Math.max(0, value)));
229
+ }
230
+ function shade(normal, color, light, surface) {
208
231
  const lambert = Math.max(0, dot(normal, light.toLight));
209
232
  const brightness = clamp012(light.ambient + light.intensity * lambert);
210
- const [r, g, b] = parseColor(color);
211
- return `rgb(${Math.round(r * brightness)}, ${Math.round(g * brightness)}, ${Math.round(
212
- b * brightness
213
- )})`;
233
+ const [baseR, baseG, baseB] = parseColor(color);
234
+ let r = baseR * brightness;
235
+ let g = baseG * brightness;
236
+ let b = baseB * brightness;
237
+ const material = surface?.material;
238
+ const specular = material?.specular;
239
+ const viewDir = surface?.viewDir;
240
+ if (specular && viewDir && lambert > 0) {
241
+ const half = normalize({
242
+ x: light.toLight.x + viewDir.x,
243
+ y: light.toLight.y + viewDir.y,
244
+ z: light.toLight.z + viewDir.z
245
+ });
246
+ const shininess = material?.shininess ?? 32;
247
+ const highlight = Math.pow(Math.max(0, dot(normal, half)), shininess) * light.intensity * 255;
248
+ r += highlight * specular[0];
249
+ g += highlight * specular[1];
250
+ b += highlight * specular[2];
251
+ }
252
+ const emissive = material?.emissive;
253
+ if (emissive) {
254
+ r += emissive[0] * 255;
255
+ g += emissive[1] * 255;
256
+ b += emissive[2] * 255;
257
+ }
258
+ return [clamp255(r), clamp255(g), clamp255(b)];
259
+ }
260
+ function shadeColor(normal, color, light, surface) {
261
+ const [r, g, b] = shade(normal, color, light, surface);
262
+ return `rgb(${r}, ${g}, ${b})`;
214
263
  }
215
264
  var DEFAULTS2, Light;
216
265
  var init_light = __esm({
@@ -233,8 +282,8 @@ var Spinner3D = (() => {
233
282
  };
234
283
  }
235
284
  /** Convenience wrapper around {@link shadeColor} using this light. */
236
- shade(normal, color) {
237
- return shadeColor(normal, color, this.params);
285
+ shade(normal, color, surface) {
286
+ return shadeColor(normal, color, this.params, surface);
238
287
  }
239
288
  };
240
289
  }
@@ -274,28 +323,51 @@ var Spinner3D = (() => {
274
323
  in vec3 aPos;
275
324
  in vec3 aNormal;
276
325
  in vec3 aColor;
326
+ in vec3 aEmissive;
327
+ in vec4 aSpecular;
277
328
  uniform mat4 uViewProj;
278
329
  uniform mat4 uModel;
279
330
  out vec3 vNormal;
280
331
  out vec3 vColor;
332
+ out vec3 vEmissive;
333
+ out vec4 vSpecular;
334
+ out vec3 vWorldPos;
281
335
  void main() {
282
336
  vNormal = mat3(uModel) * aNormal;
283
337
  vColor = aColor;
284
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
338
+ vEmissive = aEmissive;
339
+ vSpecular = aSpecular;
340
+ vec4 world = uModel * vec4(aPos, 1.0);
341
+ vWorldPos = world.xyz;
342
+ gl_Position = uViewProj * world;
285
343
  }`;
286
344
  FRAGMENT_SHADER = `#version 300 es
287
345
  precision mediump float;
288
346
  in vec3 vNormal;
289
347
  in vec3 vColor;
348
+ in vec3 vEmissive;
349
+ in vec4 vSpecular;
350
+ in vec3 vWorldPos;
290
351
  uniform vec3 uToLight;
352
+ uniform vec3 uEye;
291
353
  uniform float uIntensity;
292
354
  uniform float uAmbient;
293
355
  uniform float uOpacity;
294
356
  out vec4 fragColor;
295
357
  void main() {
296
- float lambert = max(dot(normalize(vNormal), normalize(uToLight)), 0.0);
358
+ vec3 normal = normalize(vNormal);
359
+ vec3 toLight = normalize(uToLight);
360
+ float lambert = max(dot(normal, toLight), 0.0);
297
361
  float brightness = clamp(uAmbient + uIntensity * lambert, 0.0, 1.0);
298
- fragColor = vec4(vColor * brightness, uOpacity);
362
+ vec3 lit = vColor * brightness;
363
+ if (lambert > 0.0) {
364
+ vec3 viewDir = normalize(uEye - vWorldPos);
365
+ vec3 halfVec = normalize(toLight + viewDir);
366
+ float highlight = pow(max(dot(normal, halfVec), 0.0), vSpecular.w) * uIntensity;
367
+ lit += highlight * vSpecular.xyz;
368
+ }
369
+ lit += vEmissive;
370
+ fragColor = vec4(lit, uOpacity);
299
371
  }`;
300
372
  WebGLRenderer = class {
301
373
  constructor(options = {}) {
@@ -316,9 +388,12 @@ void main() {
316
388
  aPos: gl.getAttribLocation(this.program, "aPos"),
317
389
  aNormal: gl.getAttribLocation(this.program, "aNormal"),
318
390
  aColor: gl.getAttribLocation(this.program, "aColor"),
391
+ aEmissive: gl.getAttribLocation(this.program, "aEmissive"),
392
+ aSpecular: gl.getAttribLocation(this.program, "aSpecular"),
319
393
  uViewProj: gl.getUniformLocation(this.program, "uViewProj"),
320
394
  uModel: gl.getUniformLocation(this.program, "uModel"),
321
395
  uToLight: gl.getUniformLocation(this.program, "uToLight"),
396
+ uEye: gl.getUniformLocation(this.program, "uEye"),
322
397
  uIntensity: gl.getUniformLocation(this.program, "uIntensity"),
323
398
  uAmbient: gl.getUniformLocation(this.program, "uAmbient"),
324
399
  uOpacity: gl.getUniformLocation(this.program, "uOpacity")
@@ -342,17 +417,19 @@ void main() {
342
417
  const data = expandToTriangles(mesh);
343
418
  const vao = gl.createVertexArray();
344
419
  gl.bindVertexArray(vao);
345
- const attribute = (location, array) => {
420
+ const attribute = (location, array, size = 3) => {
346
421
  if (location < 0) return;
347
422
  const buffer = gl.createBuffer();
348
423
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
349
424
  gl.bufferData(gl.ARRAY_BUFFER, array, gl.STATIC_DRAW);
350
425
  gl.enableVertexAttribArray(location);
351
- gl.vertexAttribPointer(location, 3, gl.FLOAT, false, 0, 0);
426
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0);
352
427
  };
353
428
  attribute(loc.aPos, data.positions);
354
429
  attribute(loc.aNormal, data.normals);
355
430
  attribute(loc.aColor, data.colors);
431
+ attribute(loc.aEmissive, data.emissives);
432
+ attribute(loc.aSpecular, data.speculars, 4);
356
433
  gl.bindVertexArray(null);
357
434
  const result = { vao, count: data.count };
358
435
  this.cache.set(mesh, result);
@@ -367,6 +444,7 @@ void main() {
367
444
  gl.useProgram(this.program);
368
445
  gl.uniformMatrix4fv(loc.uViewProj, false, new Float32Array(frame.viewProjection));
369
446
  gl.uniform3f(loc.uToLight, frame.light.toLight.x, frame.light.toLight.y, frame.light.toLight.z);
447
+ gl.uniform3f(loc.uEye, frame.eye.x, frame.eye.y, frame.eye.z);
370
448
  gl.uniform1f(loc.uIntensity, frame.light.intensity);
371
449
  gl.uniform1f(loc.uAmbient, frame.light.ambient);
372
450
  gl.disable(gl.BLEND);
@@ -441,6 +519,7 @@ struct Uniforms {
441
519
  model: mat4x4<f32>,
442
520
  toLight: vec4<f32>,
443
521
  params: vec4<f32>,
522
+ eye: vec4<f32>,
444
523
  };
445
524
  @group(0) @binding(0) var<uniform> u: Uniforms;
446
525
 
@@ -448,23 +527,40 @@ struct VSOut {
448
527
  @builtin(position) position: vec4<f32>,
449
528
  @location(0) normal: vec3<f32>,
450
529
  @location(1) color: vec3<f32>,
530
+ @location(2) emissive: vec3<f32>,
531
+ @location(3) specular: vec4<f32>,
532
+ @location(4) worldPos: vec3<f32>,
451
533
  };
452
534
 
453
535
  @vertex
454
- fn vs(@location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) color: vec3<f32>) -> VSOut {
536
+ fn vs(@location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) color: vec3<f32>, @location(3) emissive: vec3<f32>, @location(4) specular: vec4<f32>) -> VSOut {
455
537
  var out: VSOut;
456
538
  let m = mat3x3<f32>(u.model[0].xyz, u.model[1].xyz, u.model[2].xyz);
457
539
  out.normal = m * normal;
458
540
  out.color = color;
459
- out.position = u.viewProj * u.model * vec4<f32>(pos, 1.0);
541
+ out.emissive = emissive;
542
+ out.specular = specular;
543
+ let world = u.model * vec4<f32>(pos, 1.0);
544
+ out.worldPos = world.xyz;
545
+ out.position = u.viewProj * world;
460
546
  return out;
461
547
  }
462
548
 
463
549
  @fragment
464
550
  fn fs(in: VSOut) -> @location(0) vec4<f32> {
465
- let lambert = max(dot(normalize(in.normal), normalize(u.toLight.xyz)), 0.0);
551
+ let normal = normalize(in.normal);
552
+ let toLight = normalize(u.toLight.xyz);
553
+ let lambert = max(dot(normal, toLight), 0.0);
466
554
  let brightness = clamp(u.params.y + u.params.x * lambert, 0.0, 1.0);
467
- return vec4<f32>(in.color * brightness, u.params.z);
555
+ var lit = in.color * brightness;
556
+ if (lambert > 0.0) {
557
+ let viewDir = normalize(u.eye.xyz - in.worldPos);
558
+ let halfVec = normalize(toLight + viewDir);
559
+ let highlight = pow(max(dot(normal, halfVec), 0.0), in.specular.w) * u.params.x;
560
+ lit = lit + highlight * in.specular.xyz;
561
+ }
562
+ lit = lit + in.emissive;
563
+ return vec4<f32>(lit, u.params.z);
468
564
  }
469
565
  `;
470
566
  CLIP_Z_FIX = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 1];
@@ -505,13 +601,15 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
505
601
  {
506
602
  binding: 0,
507
603
  visibility: stage.VERTEX | stage.FRAGMENT,
508
- buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 160 }
604
+ buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 176 }
509
605
  }
510
606
  ]
511
607
  });
512
- const vertexBuffer = (location) => ({
513
- arrayStride: 12,
514
- attributes: [{ shaderLocation: location, offset: 0, format: "float32x3" }]
608
+ const vertexBuffer = (location, components = 3) => ({
609
+ arrayStride: components * 4,
610
+ attributes: [
611
+ { shaderLocation: location, offset: 0, format: `float32x${components}` }
612
+ ]
515
613
  });
516
614
  const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout] });
517
615
  const blend = {
@@ -527,7 +625,13 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
527
625
  vertex: {
528
626
  module,
529
627
  entryPoint: "vs",
530
- buffers: [vertexBuffer(0), vertexBuffer(1), vertexBuffer(2)]
628
+ buffers: [
629
+ vertexBuffer(0),
630
+ vertexBuffer(1),
631
+ vertexBuffer(2),
632
+ vertexBuffer(3),
633
+ vertexBuffer(4, 4)
634
+ ]
531
635
  },
532
636
  fragment: {
533
637
  module,
@@ -580,6 +684,8 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
580
684
  position: upload(data.positions),
581
685
  normal: upload(data.normals),
582
686
  color: upload(data.colors),
687
+ emissive: upload(data.emissives),
688
+ specular: upload(data.speculars),
583
689
  count: data.count
584
690
  };
585
691
  this.cache.set(mesh, result);
@@ -631,7 +737,7 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
631
737
  const bindGroup = this.device.createBindGroup({
632
738
  layout,
633
739
  entries: [
634
- { binding: 0, resource: { buffer: this.uniformBuffer, offset: 0, size: 160 } }
740
+ { binding: 0, resource: { buffer: this.uniformBuffer, offset: 0, size: 176 } }
635
741
  ]
636
742
  });
637
743
  draws.forEach((draw, i) => {
@@ -640,6 +746,7 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
640
746
  data.set(draw.item.model, 16);
641
747
  data.set([frame.light.toLight.x, frame.light.toLight.y, frame.light.toLight.z, 0], 32);
642
748
  data.set([frame.light.intensity, frame.light.ambient, draw.opacity, 0], 36);
749
+ data.set([frame.eye.x, frame.eye.y, frame.eye.z, 0], 40);
643
750
  this.device.queue.writeBuffer(this.uniformBuffer, i * UNIFORM_STRIDE, data);
644
751
  });
645
752
  const encoder = this.device.createCommandEncoder();
@@ -666,6 +773,8 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
666
773
  pass.setVertexBuffer(0, mesh.position);
667
774
  pass.setVertexBuffer(1, mesh.normal);
668
775
  pass.setVertexBuffer(2, mesh.color);
776
+ pass.setVertexBuffer(3, mesh.emissive);
777
+ pass.setVertexBuffer(4, mesh.specular);
669
778
  pass.draw(mesh.count);
670
779
  });
671
780
  pass.end();
@@ -677,6 +786,8 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
677
786
  mesh.position.destroy?.();
678
787
  mesh.normal.destroy?.();
679
788
  mesh.color.destroy?.();
789
+ mesh.emissive.destroy?.();
790
+ mesh.specular.destroy?.();
680
791
  }
681
792
  this.cache.clear();
682
793
  this.uniformBuffer?.destroy?.();
@@ -759,9 +870,30 @@ fn fs(in: VSOut) -> @location(0) vec4<f32> {
759
870
  depth += dot(d, d);
760
871
  }
761
872
  depth /= face.indices.length;
873
+ let surface;
874
+ const material = face.material;
875
+ if (material) {
876
+ if (material.specular) {
877
+ let cx = 0;
878
+ let cy = 0;
879
+ let cz = 0;
880
+ for (const i of face.indices) {
881
+ cx += world[i].x;
882
+ cy += world[i].y;
883
+ cz += world[i].z;
884
+ }
885
+ const inv = 1 / face.indices.length;
886
+ const viewDir = normalize(
887
+ subtract(frame.eye, { x: cx * inv, y: cy * inv, z: cz * inv })
888
+ );
889
+ surface = { material, viewDir };
890
+ } else {
891
+ surface = { material };
892
+ }
893
+ }
762
894
  polygons.push({
763
895
  points,
764
- color: shadeColor(normal, face.color, frame.light),
896
+ color: shadeColor(normal, face.color, frame.light, surface),
765
897
  depth,
766
898
  opacity: faceOpacity
767
899
  });
@@ -1512,6 +1644,7 @@ void main() {
1512
1644
  SpinAnimation: () => SpinAnimation,
1513
1645
  WebGLTexturedRenderer: () => WebGLTexturedRenderer,
1514
1646
  WebGPUTexturedRenderer: () => WebGPUTexturedRenderer,
1647
+ attachMaterial: () => attachMaterial,
1515
1648
  centerAndScaleMesh: () => centerAndScaleMesh,
1516
1649
  chargedOrb: () => chargedOrb,
1517
1650
  circleMotion: () => circleMotion,
@@ -1729,6 +1862,12 @@ void main() {
1729
1862
  init_math();
1730
1863
 
1731
1864
  // src/engines/little-3d-engine/core/mesh.ts
1865
+ function attachMaterial(mesh, material) {
1866
+ if (material) {
1867
+ for (const face of mesh.faces) face.material = material;
1868
+ }
1869
+ return mesh;
1870
+ }
1732
1871
  function transform(init) {
1733
1872
  return {
1734
1873
  position: init?.position ?? { x: 0, y: 0, z: 0 },
@@ -1743,7 +1882,7 @@ void main() {
1743
1882
 
1744
1883
  // src/engines/little-3d-engine/shapes/primitives/cube.ts
1745
1884
  var DEFAULT_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444"];
1746
- function cube(size = 1, colors = DEFAULT_COLORS) {
1885
+ function cube(size = 1, colors = DEFAULT_COLORS, material) {
1747
1886
  const h = size / 2;
1748
1887
  const vertices = [
1749
1888
  { x: -h, y: -h, z: h },
@@ -1763,12 +1902,12 @@ void main() {
1763
1902
  { indices: [1, 5, 6, 2], color: colors[4 % colors.length] },
1764
1903
  { indices: [4, 0, 3, 7], color: colors[5 % colors.length] }
1765
1904
  ];
1766
- return { vertices, faces };
1905
+ return attachMaterial({ vertices, faces }, material);
1767
1906
  }
1768
1907
 
1769
1908
  // src/engines/little-3d-engine/shapes/primitives/quad.ts
1770
1909
  var DEFAULT_COLORS2 = ["#3b82f6"];
1771
- function quad(size = 1, colors = DEFAULT_COLORS2) {
1910
+ function quad(size = 1, colors = DEFAULT_COLORS2, material) {
1772
1911
  const s = size / 2;
1773
1912
  const vertices = [
1774
1913
  { x: -s, y: -s, z: 0 },
@@ -1776,12 +1915,15 @@ void main() {
1776
1915
  { x: s, y: s, z: 0 },
1777
1916
  { x: -s, y: s, z: 0 }
1778
1917
  ];
1779
- return { vertices, faces: [{ indices: [0, 1, 2, 3], color: colors[0] }] };
1918
+ return attachMaterial(
1919
+ { vertices, faces: [{ indices: [0, 1, 2, 3], color: colors[0] }] },
1920
+ material
1921
+ );
1780
1922
  }
1781
1923
 
1782
1924
  // src/engines/little-3d-engine/shapes/primitives/tetrahedron.ts
1783
1925
  var DEFAULT_COLORS3 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b"];
1784
- function tetrahedron(size = 1, colors = DEFAULT_COLORS3) {
1926
+ function tetrahedron(size = 1, colors = DEFAULT_COLORS3, material) {
1785
1927
  const s = size / 2;
1786
1928
  const vertices = [
1787
1929
  { x: s, y: s, z: s },
@@ -1795,7 +1937,7 @@ void main() {
1795
1937
  { indices: [0, 2, 3], color: colors[2 % colors.length] },
1796
1938
  { indices: [1, 3, 2], color: colors[3 % colors.length] }
1797
1939
  ];
1798
- return { vertices, faces };
1940
+ return attachMaterial({ vertices, faces }, material);
1799
1941
  }
1800
1942
 
1801
1943
  // src/engines/little-3d-engine/shapes/primitives/octahedron.ts
@@ -1809,7 +1951,7 @@ void main() {
1809
1951
  "#06b6d4",
1810
1952
  "#eab308"
1811
1953
  ];
1812
- function octahedron(size = 1, colors = DEFAULT_COLORS4) {
1954
+ function octahedron(size = 1, colors = DEFAULT_COLORS4, material) {
1813
1955
  const r = size / 2;
1814
1956
  const vertices = [
1815
1957
  { x: r, y: 0, z: 0 },
@@ -1829,12 +1971,12 @@ void main() {
1829
1971
  { indices: [5, 3, 1], color: colors[6 % colors.length] },
1830
1972
  { indices: [5, 0, 3], color: colors[7 % colors.length] }
1831
1973
  ];
1832
- return { vertices, faces };
1974
+ return attachMaterial({ vertices, faces }, material);
1833
1975
  }
1834
1976
 
1835
1977
  // src/engines/little-3d-engine/shapes/primitives/pyramid.ts
1836
1978
  var DEFAULT_COLORS5 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981"];
1837
- function pyramid(size = 1, colors = DEFAULT_COLORS5) {
1979
+ function pyramid(size = 1, colors = DEFAULT_COLORS5, material) {
1838
1980
  const h = size / 2;
1839
1981
  const vertices = [
1840
1982
  { x: -h, y: -h, z: h },
@@ -1850,12 +1992,12 @@ void main() {
1850
1992
  { indices: [4, 2, 3], color: colors[3 % colors.length] },
1851
1993
  { indices: [4, 3, 0], color: colors[4 % colors.length] }
1852
1994
  ];
1853
- return { vertices, faces };
1995
+ return attachMaterial({ vertices, faces }, material);
1854
1996
  }
1855
1997
 
1856
1998
  // src/engines/little-3d-engine/shapes/primitives/spheres/uv-sphere.ts
1857
1999
  var DEFAULT_COLORS6 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444"];
1858
- function uvSphere(size = 1, detail = 1, colors = DEFAULT_COLORS6) {
2000
+ function uvSphere(size = 1, detail = 1, colors = DEFAULT_COLORS6, material) {
1859
2001
  const r = size / 2;
1860
2002
  const d = Math.max(1, Math.floor(detail));
1861
2003
  const slices = Math.max(4, d * 4);
@@ -1894,7 +2036,7 @@ void main() {
1894
2036
  color: color()
1895
2037
  });
1896
2038
  }
1897
- return { vertices, faces };
2039
+ return attachMaterial({ vertices, faces }, material);
1898
2040
  }
1899
2041
 
1900
2042
  // src/engines/little-3d-engine/shapes/primitives/spheres/icosphere.ts
@@ -1937,8 +2079,11 @@ void main() {
1937
2079
  [8, 6, 7],
1938
2080
  [9, 8, 1]
1939
2081
  ];
1940
- function icosphere(size = 1, detail = 1, colors = DEFAULT_COLORS7) {
1941
- return sphereFromTriangles(SEED_VERTICES, SEED_FACES, size, detail, colors);
2082
+ function icosphere(size = 1, detail = 1, colors = DEFAULT_COLORS7, material) {
2083
+ return attachMaterial(
2084
+ sphereFromTriangles(SEED_VERTICES, SEED_FACES, size, detail, colors),
2085
+ material
2086
+ );
1942
2087
  }
1943
2088
 
1944
2089
  // src/engines/little-3d-engine/shapes/primitives/spheres/octa-sphere.ts
@@ -1962,8 +2107,11 @@ void main() {
1962
2107
  [5, 3, 1],
1963
2108
  [5, 0, 3]
1964
2109
  ];
1965
- function octaSphere(size = 1, detail = 1, colors = DEFAULT_COLORS8) {
1966
- return sphereFromTriangles(SEED_VERTICES2, SEED_FACES2, size, detail, colors);
2110
+ function octaSphere(size = 1, detail = 1, colors = DEFAULT_COLORS8, material) {
2111
+ return attachMaterial(
2112
+ sphereFromTriangles(SEED_VERTICES2, SEED_FACES2, size, detail, colors),
2113
+ material
2114
+ );
1967
2115
  }
1968
2116
 
1969
2117
  // src/engines/little-3d-engine/shapes/primitives/spheres/cube-sphere.ts
@@ -1976,7 +2124,7 @@ void main() {
1976
2124
  { normal: [0, 1, 0], right: [1, 0, 0], up: [0, 0, -1] },
1977
2125
  { normal: [0, -1, 0], right: [1, 0, 0], up: [0, 0, 1] }
1978
2126
  ];
1979
- function cubeSphere(size = 1, detail = 1, colors = DEFAULT_COLORS9) {
2127
+ function cubeSphere(size = 1, detail = 1, colors = DEFAULT_COLORS9, material) {
1980
2128
  const r = size / 2;
1981
2129
  const n = Math.max(1, Math.floor(detail));
1982
2130
  const vertices = [];
@@ -2005,13 +2153,13 @@ void main() {
2005
2153
  }
2006
2154
  }
2007
2155
  }
2008
- return { vertices, faces };
2156
+ return attachMaterial({ vertices, faces }, material);
2009
2157
  }
2010
2158
 
2011
2159
  // src/engines/little-3d-engine/shapes/complex/plane.ts
2012
2160
  var DEFAULT_COLORS10 = ["#e0f2fe", "#7dd3fc", "#38bdf8", "#f8fafc"];
2013
- function planeMesh(colors = DEFAULT_COLORS10) {
2014
- return {
2161
+ function planeMesh(colors = DEFAULT_COLORS10, material) {
2162
+ return attachMaterial({
2015
2163
  vertices: [
2016
2164
  { x: 0.9, y: 0, z: 0 },
2017
2165
  { x: -0.2, y: 0, z: 0.82 },
@@ -2039,7 +2187,7 @@ void main() {
2039
2187
  { indices: [3, 6, 8], color: colors[0] ?? DEFAULT_COLORS10[0] },
2040
2188
  { indices: [3, 8, 6], color: colors[1] ?? DEFAULT_COLORS10[1] }
2041
2189
  ]
2042
- };
2190
+ }, material);
2043
2191
  }
2044
2192
 
2045
2193
  // src/engines/little-3d-engine/textures/dynamic/canvas-texture.ts
@@ -2052,18 +2200,30 @@ void main() {
2052
2200
  }
2053
2201
 
2054
2202
  // src/engines/little-3d-engine/textures/dynamic/star.ts
2055
- function starTexture() {
2203
+ function drawStar(ctx) {
2204
+ ctx.save();
2205
+ ctx.translate(48, 48);
2206
+ ctx.fillStyle = "#fff";
2207
+ ctx.beginPath();
2208
+ for (let index = 0; index < 10; index++) {
2209
+ const radius = index % 2 === 0 ? 43 : 16;
2210
+ const angle = index * Math.PI / 5 - Math.PI / 2;
2211
+ ctx.lineTo(radius * Math.cos(angle), radius * Math.sin(angle));
2212
+ }
2213
+ ctx.closePath();
2214
+ ctx.fill();
2215
+ ctx.restore();
2216
+ }
2217
+ function starTexture(options = {}) {
2218
+ const glow = Math.max(0, options.glow ?? 0);
2056
2219
  return canvasTexture((ctx) => {
2057
- ctx.translate(48, 48);
2058
- ctx.fillStyle = "#fff";
2059
- ctx.beginPath();
2060
- for (let index = 0; index < 10; index++) {
2061
- const radius = index % 2 === 0 ? 43 : 16;
2062
- const angle = index * Math.PI / 5 - Math.PI / 2;
2063
- ctx.lineTo(radius * Math.cos(angle), radius * Math.sin(angle));
2220
+ if (glow > 0) {
2221
+ ctx.save();
2222
+ ctx.filter = `blur(${glow}px)`;
2223
+ drawStar(ctx);
2224
+ ctx.restore();
2064
2225
  }
2065
- ctx.closePath();
2066
- ctx.fill();
2226
+ drawStar(ctx);
2067
2227
  });
2068
2228
  }
2069
2229
 
@@ -2584,10 +2744,14 @@ void main() {
2584
2744
  const pick = Array.isArray(color) ? (i) => color[i % color.length] : () => color;
2585
2745
  return { vertices: mesh.vertices, faces: mesh.faces.map((f, i) => ({ ...f, color: pick(i) })) };
2586
2746
  }
2747
+ function applyMaterial(mesh, material) {
2748
+ if (!material) return mesh;
2749
+ return { vertices: mesh.vertices, faces: mesh.faces.map((f) => ({ ...f, material })) };
2750
+ }
2587
2751
  var SpinAnimation = class {
2588
2752
  constructor(options = {}) {
2589
2753
  this.exited = false;
2590
- this.mesh = applyColor(resolveMesh(options.shape), options.color);
2754
+ this.mesh = applyMaterial(applyColor(resolveMesh(options.shape), options.color), options.material);
2591
2755
  this.spinX = options.spinX ?? 7e-4;
2592
2756
  this.spinY = options.spinY ?? 11e-4;
2593
2757
  this.backend = options.backend;
@@ -3280,6 +3444,7 @@ void main() {
3280
3444
  var CENTER_POP_OUT_AT = REENTER_MS + (MINIS - 1) * REENTER_STAGGER_MS + 150;
3281
3445
  var CENTER_POP_OUT_MS = 420;
3282
3446
  var PARKED = { x: 0, y: 0, z: 50 };
3447
+ var ORB_MATERIAL = { specular: [1, 1, 1], shininess: 28 };
3283
3448
  var CENTER_COLORS = ["#67e8f9", "#22d3ee", "#0ea5e9", "#38bdf8", "#7dd3fc"];
3284
3449
  var MINI_COLORS = [
3285
3450
  ["#e0f2fe", "#bae6fd", "#7dd3fc"],
@@ -3308,9 +3473,9 @@ void main() {
3308
3473
  backend: this.backend,
3309
3474
  camera: { position: { x: 0, y: 0, z: CAMERA_Z } }
3310
3475
  });
3311
- this.center = engine.add(icosphere(1, 2, CENTER_COLORS), { scale: 0 });
3476
+ this.center = engine.add(icosphere(1, 2, CENTER_COLORS, ORB_MATERIAL), { scale: 0 });
3312
3477
  for (let i = 0; i < MINIS; i++) {
3313
- const mesh = icosphere(1, 1, MINI_COLORS[i % MINI_COLORS.length]);
3478
+ const mesh = icosphere(1, 1, MINI_COLORS[i % MINI_COLORS.length], ORB_MATERIAL);
3314
3479
  this.minis.push(engine.add(mesh, { scale: 0, transparency: { ...MINI_TRANSPARENCY } }));
3315
3480
  }
3316
3481
  this.engine = engine;
@@ -3473,6 +3638,7 @@ void main() {
3473
3638
  var TWO_PI2 = Math.PI * 2;
3474
3639
  var INTRO_MS = 900;
3475
3640
  var INTRO_STAGGER_MS = 60;
3641
+ var INTRO_DONE_MS = (COUNT - 1) * INTRO_STAGGER_MS + INTRO_MS;
3476
3642
  var GATE_DOCK = 0.35;
3477
3643
  var GATE_UNDOCK = 0.65;
3478
3644
  var EXIT_HURRY2 = 2.5;
@@ -3481,15 +3647,11 @@ void main() {
3481
3647
  var SPIN_STAGGER_MS = 80;
3482
3648
  var HOLD_MS = 1e3;
3483
3649
  var COLLAPSE_MS = 700;
3650
+ var COLLAPSE_SPREAD_MS = 500;
3484
3651
  var POP_MS = 170;
3485
3652
  var LABEL_FADE_MS = 600;
3486
- var DEFAULT_MESHES = [
3487
- () => cube(1, ["#60a5fa", "#3b82f6", "#2563eb", "#38bdf8", "#0ea5e9", "#1d4ed8"]),
3488
- () => tetrahedron(1, ["#f472b6", "#ec4899", "#db2777", "#f9a8d4"]),
3489
- () => octahedron(1, ["#34d399", "#10b981", "#059669", "#6ee7b7", "#a7f3d0", "#047857", "#4ade80", "#065f46"]),
3490
- () => pyramid(1, ["#fbbf24", "#f59e0b", "#d97706", "#fde68a", "#fcd34d"]),
3491
- () => icosphere(1, 1, ["#a78bfa", "#8b5cf6", "#7c3aed", "#c4b5fd"])
3492
- ];
3653
+ var CUBE_COLORS = ["#8397c6", "#7186b8", "#6176a8", "#93a6cf", "#556a9c", "#7a8fc0"];
3654
+ var DEFAULT_MESHES = [() => cube(1, CUBE_COLORS)];
3493
3655
  function clamp015(value) {
3494
3656
  return Math.max(0, Math.min(1, value));
3495
3657
  }
@@ -3517,6 +3679,9 @@ void main() {
3517
3679
  this.dockedAt = new Array(COUNT).fill(Infinity);
3518
3680
  this.tumbleX = [];
3519
3681
  this.tumbleY = [];
3682
+ this.collapseDelay = [];
3683
+ this.popStarted = new Array(COUNT).fill(false);
3684
+ this.maxCollapseDelay = 0;
3520
3685
  this.fades = [];
3521
3686
  this.slots = [];
3522
3687
  this.aspect = 16 / 9;
@@ -3525,7 +3690,6 @@ void main() {
3525
3690
  this.allDockedAt = Infinity;
3526
3691
  this.collapseAt = Infinity;
3527
3692
  this.lastNow = 0;
3528
- this.popFading = false;
3529
3693
  this.finished = false;
3530
3694
  const sources = options.meshes && options.meshes.length > 0 ? options.meshes : DEFAULT_MESHES;
3531
3695
  this.meshes = sources.map(resolveMesh3);
@@ -3542,7 +3706,9 @@ void main() {
3542
3706
  this.slots.push({ x: (col - 2) * spacing, y: (2 - row) * spacing, z: 0 });
3543
3707
  this.tumbleX.push(TWO_PI2 * hash01(i, 2) - Math.PI);
3544
3708
  this.tumbleY.push(TWO_PI2 * hash01(i, 4) - Math.PI);
3709
+ this.collapseDelay.push(hash01(i, 7) * COLLAPSE_SPREAD_MS);
3545
3710
  }
3711
+ this.maxCollapseDelay = Math.max(...this.collapseDelay);
3546
3712
  }
3547
3713
  mount(target) {
3548
3714
  if (!target.style.position) target.style.position = "relative";
@@ -3603,7 +3769,7 @@ void main() {
3603
3769
  COLLAPSE_MS
3604
3770
  ));
3605
3771
  }
3606
- if (this.collapseAt !== Infinity && now >= this.collapseAt + COLLAPSE_MS + POP_MS) {
3772
+ if (this.collapseAt !== Infinity && now >= this.collapseAt + this.maxCollapseDelay + COLLAPSE_MS + POP_MS) {
3607
3773
  this.finished = true;
3608
3774
  }
3609
3775
  this.engine.render();
@@ -3620,7 +3786,8 @@ void main() {
3620
3786
  }
3621
3787
  updateBlends(dt, progress, now) {
3622
3788
  const exiting = this.exitAt !== Infinity;
3623
- const want = exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
3789
+ const ringComplete = now - this.enterAt >= INTRO_DONE_MS;
3790
+ const want = !ringComplete ? 0 : exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
3624
3791
  const rate = dt / this.dockMs * (exiting ? EXIT_HURRY2 : 1);
3625
3792
  for (let i = 0; i < COUNT; i++) {
3626
3793
  const target = i < want ? 1 : 0;
@@ -3683,25 +3850,30 @@ void main() {
3683
3850
  if (!this.captured) {
3684
3851
  this.captured = this.handles.map((handle) => ({ ...handle.transform.position }));
3685
3852
  }
3686
- const u = clamp015((now - this.collapseAt) / COLLAPSE_MS);
3687
- const pull = easeInCubic(u);
3688
3853
  for (let i = 0; i < COUNT; i++) {
3689
3854
  const transform2 = this.handles[i].transform;
3690
3855
  const from = this.captured[i];
3856
+ const local = now - this.collapseAt - this.collapseDelay[i];
3857
+ if (local <= 0) {
3858
+ transform2.position.x = from.x;
3859
+ transform2.position.y = from.y;
3860
+ transform2.position.z = from.z;
3861
+ transform2.scale = this.size;
3862
+ continue;
3863
+ }
3864
+ const pull = easeInCubic(clamp015(local / COLLAPSE_MS));
3691
3865
  transform2.position.x = from.x * (1 - pull);
3692
3866
  transform2.position.y = from.y * (1 - pull);
3693
3867
  transform2.position.z = from.z * (1 - pull);
3694
3868
  transform2.scale = this.size * (1 - 0.99 * pull);
3695
- }
3696
- if (u >= 1) {
3697
- if (!this.popFading) {
3698
- this.popFading = true;
3699
- for (let i = 0; i < COUNT; i++) this.handles[i].transparency = this.fades[i];
3700
- }
3701
- const v = clamp015((now - this.collapseAt - COLLAPSE_MS) / POP_MS);
3702
- for (let i = 0; i < COUNT; i++) {
3869
+ if (local >= COLLAPSE_MS) {
3870
+ if (!this.popStarted[i]) {
3871
+ this.popStarted[i] = true;
3872
+ this.handles[i].transparency = this.fades[i];
3873
+ }
3874
+ const v = clamp015((local - COLLAPSE_MS) / POP_MS);
3703
3875
  this.fades[i].opacity = 1 - v;
3704
- this.handles[i].transform.scale = v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
3876
+ transform2.scale = v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
3705
3877
  }
3706
3878
  }
3707
3879
  }
@@ -3874,36 +4046,239 @@ void main() {
3874
4046
  };
3875
4047
  }
3876
4048
 
4049
+ // src/animations/ghost-train.ts
4050
+ var MAX_CARS = 50;
4051
+ var CAMERA_Z3 = 3;
4052
+ var FOV2 = 55 * Math.PI / 180;
4053
+ var HALF_HEIGHT = Math.tan(FOV2 / 2) * CAMERA_Z3;
4054
+ var RUN_GAP_MS = 130;
4055
+ var POP_MS2 = 320;
4056
+ var SAMPLE_MS2 = 8;
4057
+ var TURN_RATE = 0.4 * Math.PI / 180;
4058
+ var MAX_OUTRO_MS = 4e3;
4059
+ var WARP_ACCEL = 1e3;
4060
+ var TRAIL_OUTRO_MS = 1200;
4061
+ var TRANSPARENCY = { mode: "two-sided", opacity: 0.275 };
4062
+ var CAR_COLORS = ["#bae6fd", "#7dd3fc", "#38bdf8", "#0ea5e9", "#a5f3fc", "#e0f2fe"];
4063
+ var CAR_MATERIAL = { emissive: [0.1, 0.2, 0.3] };
4064
+ var WORLD_UP2 = { x: 0, y: 1, z: 0 };
4065
+ function clamp016(value) {
4066
+ return Math.max(0, Math.min(1, value));
4067
+ }
4068
+ function orientationFor2(forward) {
4069
+ const fwd = normalize(forward);
4070
+ let right = cross(fwd, WORLD_UP2);
4071
+ if (Math.hypot(right.x, right.y, right.z) < 1e-4) right = { x: 0, y: 0, z: 1 };
4072
+ right = normalize(right);
4073
+ const up = cross(right, fwd);
4074
+ const w = normalize(cross(fwd, up));
4075
+ return {
4076
+ x: Math.atan2(cross(w, fwd).z, w.z),
4077
+ y: Math.asin(Math.max(-1, Math.min(1, -fwd.z))),
4078
+ z: Math.atan2(fwd.y, fwd.x)
4079
+ };
4080
+ }
4081
+ function rotateToward(from, to, maxRad) {
4082
+ const a = normalize(from);
4083
+ const b = normalize(to);
4084
+ const d = Math.max(-1, Math.min(1, dot(a, b)));
4085
+ const angle = Math.acos(d);
4086
+ if (angle <= maxRad || angle < 1e-4) return b;
4087
+ const sin = Math.sin(angle);
4088
+ if (sin < 1e-4) return b;
4089
+ const t = maxRad / angle;
4090
+ const w1 = Math.sin((1 - t) * angle) / sin;
4091
+ const w2 = Math.sin(t * angle) / sin;
4092
+ return normalize({ x: a.x * w1 + b.x * w2, y: a.y * w1 + b.y * w2, z: a.z * w1 + b.z * w2 });
4093
+ }
4094
+ var GhostTrainAnimation = class {
4095
+ constructor(options = {}) {
4096
+ this.cars = [];
4097
+ this.appear = new Array(MAX_CARS).fill(0);
4098
+ this.headings = new Array(MAX_CARS).fill(void 0);
4099
+ this.aspect = 16 / 9;
4100
+ this.enterAt = Infinity;
4101
+ this.outroAt = Infinity;
4102
+ this.carsAtOutro = 0;
4103
+ this.exitPathTime = 0;
4104
+ // lead car's path-time at blast-off (the escape switch point)
4105
+ this.exitPoint = { x: 0, y: 0, z: 0 };
4106
+ this.exitDir = { x: 1, y: 0, z: 0 };
4107
+ // shared escape direction, outward from the track
4108
+ this.exitSpeed = 1e-3;
4109
+ // path-units per path-millisecond at the switch (keeps speed continuous)
4110
+ this.lastNow = 0;
4111
+ this.finished = false;
4112
+ this.motion = options.motion ?? squareMotion({ size: 1.7, periodMs: 6800, tilt: 0.5 });
4113
+ this.size = options.size ?? 0.15;
4114
+ this.backend = options.backend;
4115
+ this.labelContent = options.label;
4116
+ this.fadeLabel = options.fadeLabel ?? true;
4117
+ }
4118
+ mount(target) {
4119
+ if (!target.style.position) target.style.position = "relative";
4120
+ const engine = new Little3dEngine({
4121
+ backend: this.backend,
4122
+ camera: { position: { x: 0, y: 0, z: CAMERA_Z3 }, fov: FOV2 }
4123
+ });
4124
+ const mesh = cube(1, CAR_COLORS, CAR_MATERIAL);
4125
+ for (let i = 0; i < MAX_CARS; i++) {
4126
+ this.cars.push(engine.add(mesh, { scale: 0, transparency: { ...TRANSPARENCY } }));
4127
+ }
4128
+ this.engine = engine;
4129
+ engine.mount(target).catch((error) => {
4130
+ target.textContent = error instanceof Error ? error.message : String(error);
4131
+ });
4132
+ const measure = () => {
4133
+ if (target.clientWidth > 0 && target.clientHeight > 0) {
4134
+ this.aspect = target.clientWidth / target.clientHeight;
4135
+ }
4136
+ };
4137
+ measure();
4138
+ this.observer = new ResizeObserver(measure);
4139
+ this.observer.observe(target);
4140
+ this.label = mountAnimationLabel(target, this.labelContent);
4141
+ if (this.fadeLabel) this.label.setOpacity(0);
4142
+ }
4143
+ enter(now) {
4144
+ if (this.enterAt === Infinity) this.enterAt = now;
4145
+ }
4146
+ exit(now) {
4147
+ if (this.outroAt !== Infinity || this.enterAt === Infinity) return;
4148
+ this.outroAt = now;
4149
+ this.carsAtOutro = this.appear.filter((a) => a > 0.5).length;
4150
+ this.exitPathTime = now - this.enterAt;
4151
+ const from = this.motion.positionAt(this.exitPathTime);
4152
+ const velocity = subtract(
4153
+ this.motion.positionAt(this.exitPathTime + 1),
4154
+ this.motion.positionAt(this.exitPathTime - 1)
4155
+ );
4156
+ const speed = Math.hypot(velocity.x, velocity.y, velocity.z);
4157
+ this.exitPoint = from;
4158
+ if (speed > 1e-6) this.exitSpeed = speed / 2;
4159
+ this.exitDir = speed > 1e-6 ? normalize(velocity) : { x: 1, y: 0, z: 0 };
4160
+ }
4161
+ isFinished() {
4162
+ return this.finished;
4163
+ }
4164
+ /** Milliseconds the lead car keeps moving into the outro; feed a trail layer's `outroMs`. */
4165
+ get outroDurationMs() {
4166
+ return TRAIL_OUTRO_MS;
4167
+ }
4168
+ /**
4169
+ * A {@link MotionController} following the lead car's actual position, through
4170
+ * laps and the accelerating escape. Feed it to a particle layer's `emitter`
4171
+ * so the star trail stays behind the train.
4172
+ */
4173
+ trailEmitter() {
4174
+ return {
4175
+ positionAt: (t) => this.enterAt === Infinity ? this.motion.positionAt(t) : this.pathPosition(t - this.enterAt + this.warp(t))
4176
+ };
4177
+ }
4178
+ render(now, frame) {
4179
+ if (!this.engine || !this.label) return;
4180
+ for (const car of this.cars) car.transform.scale = 0;
4181
+ if (this.enterAt === Infinity) {
4182
+ this.engine.render();
4183
+ return;
4184
+ }
4185
+ const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
4186
+ this.lastNow = now;
4187
+ const want = this.outroAt !== Infinity ? this.carsAtOutro : Math.min(MAX_CARS, Math.round(frame.progress * MAX_CARS));
4188
+ const halfWidth = HALF_HEIGHT * this.aspect;
4189
+ const warp = this.warp(now);
4190
+ let anyOnScreen = false;
4191
+ for (let k = 0; k < MAX_CARS; k++) {
4192
+ const target = k < want ? 1 : 0;
4193
+ this.appear[k] = clamp016(this.appear[k] + Math.sign(target - this.appear[k]) * (dt / POP_MS2));
4194
+ if (this.appear[k] <= 0) {
4195
+ this.headings[k] = void 0;
4196
+ continue;
4197
+ }
4198
+ const p = now - this.enterAt - k * RUN_GAP_MS + warp;
4199
+ const position = this.pathPosition(p);
4200
+ if (Math.abs(position.x) > halfWidth + this.size || Math.abs(position.y) > HALF_HEIGHT + this.size) {
4201
+ continue;
4202
+ }
4203
+ const ahead = subtract(this.pathPosition(p + SAMPLE_MS2), position);
4204
+ const targetDir = Math.hypot(ahead.x, ahead.y, ahead.z) > 1e-5 ? ahead : this.headings[k] ?? { x: 1, y: 0, z: 0 };
4205
+ this.headings[k] = this.headings[k] ? rotateToward(this.headings[k], targetDir, TURN_RATE * dt) : normalize(targetDir);
4206
+ const orientation = orientationFor2(this.headings[k]);
4207
+ const transform2 = this.cars[k].transform;
4208
+ transform2.position.x = position.x;
4209
+ transform2.position.y = position.y;
4210
+ transform2.position.z = position.z;
4211
+ transform2.rotation.x = orientation.x;
4212
+ transform2.rotation.y = orientation.y;
4213
+ transform2.rotation.z = orientation.z;
4214
+ transform2.scale = this.size * easeOutBack(this.appear[k]);
4215
+ anyOnScreen = true;
4216
+ }
4217
+ this.label.setText(frame.indeterminate ? typeof this.labelContent === "string" ? this.labelContent : "" : `${Math.round(frame.progress * 100)}%`);
4218
+ if (this.fadeLabel) {
4219
+ this.label.setOpacity(animationLabelOpacity(now, this.enterAt, POP_MS2, this.outroAt, TRAIL_OUTRO_MS));
4220
+ }
4221
+ if (this.outroAt !== Infinity && now > this.outroAt + 300 && (!anyOnScreen || now >= this.outroAt + MAX_OUTRO_MS)) {
4222
+ this.finished = true;
4223
+ }
4224
+ this.engine.render();
4225
+ }
4226
+ destroy() {
4227
+ this.observer?.disconnect();
4228
+ this.observer = void 0;
4229
+ this.label?.container.remove();
4230
+ this.label = void 0;
4231
+ this.engine?.destroy();
4232
+ this.engine = void 0;
4233
+ this.cars.length = 0;
4234
+ }
4235
+ /** Extra path-time every car has accelerated forward by, `now` ms into the outro. */
4236
+ warp(now) {
4237
+ if (this.outroAt === Infinity) return 0;
4238
+ const seconds = (now - this.outroAt) / 1e3;
4239
+ return 0.5 * WARP_ACCEL * seconds * seconds;
4240
+ }
4241
+ /**
4242
+ * The single trajectory every car rides, sampled at path-time `p`: the track
4243
+ * up to the exit switch point, then a straight escape outward. Because the
4244
+ * switch point and direction are shared, all cars follow the exact same path.
4245
+ */
4246
+ pathPosition(p) {
4247
+ if (this.outroAt === Infinity || p <= this.exitPathTime) {
4248
+ return this.motion.positionAt(p);
4249
+ }
4250
+ const distance = this.exitSpeed * (p - this.exitPathTime);
4251
+ return {
4252
+ x: this.exitPoint.x + this.exitDir.x * distance,
4253
+ y: this.exitPoint.y + this.exitDir.y * distance,
4254
+ z: this.exitPoint.z + this.exitDir.z * distance
4255
+ };
4256
+ }
4257
+ };
4258
+
3877
4259
  // src/prefabs/ghost-train.ts
3878
4260
  function ghostTrain(options = {}) {
3879
- const motion = options.object?.motion ?? squareMotion({ size: 1.7, periodMs: 6800, tilt: 0.5 });
3880
4261
  const particles = options.particles ?? {};
3881
- const object = new ObjectMotionAnimation({
3882
- mesh: () => cube(1, ["#bae6fd", "#7dd3fc", "#38bdf8", "#0ea5e9", "#a5f3fc", "#e0f2fe"]),
3883
- motion,
3884
- size: 0.3,
3885
- transparency: { mode: "two-sided", opacity: 0.55 },
3886
- tail: { count: 4, gapMs: 240 },
3887
- backend: options.backend,
3888
- ...options.object,
3889
- label: options.object?.label
4262
+ const train = new GhostTrainAnimation({
4263
+ motion: options.object?.motion,
4264
+ backend: options.backend
3890
4265
  });
3891
4266
  const trail = new ParticlesAnimation({
3892
4267
  rate: 30,
3893
4268
  lifeMs: 1700,
3894
- size: 0.13,
3895
- speed: 0.07,
4269
+ size: 0.15,
4270
+ speed: 0.11,
3896
4271
  colors: ["#e0f2fe", "#a5f3fc", "#c4b5fd"],
3897
- texture: particles.texture ?? starTexture(),
3898
- emitter: object.trailEmitter(),
3899
- outroMs: object.outroDurationMs,
4272
+ texture: particles.texture ?? starTexture({ glow: 5 }),
4273
+ emitter: train.trailEmitter(),
4274
+ outroMs: train.outroDurationMs,
3900
4275
  seed: 17,
3901
4276
  backend: options.backend,
3902
4277
  ...particles,
3903
4278
  label: options.label ?? particles.label,
3904
4279
  fadeLabel: options.fadeLabel ?? particles.fadeLabel
3905
4280
  });
3906
- return progressSpinner(new CompositeAnimation([trail, object]), options);
4281
+ return progressSpinner(new CompositeAnimation([trail, train]), options);
3907
4282
  }
3908
4283
 
3909
4284
  // src/prefabs/grid-assembly.ts
@@ -4000,68 +4375,347 @@ void main() {
4000
4375
  }), options);
4001
4376
  }
4002
4377
 
4003
- // src/motion/circle.ts
4004
- function circleMotion(options = {}) {
4005
- const radius = options.radius ?? 1.3;
4006
- const periodMs = options.periodMs ?? 3e3;
4007
- const tilt = options.tilt ?? 0.5;
4008
- const direction = options.direction ?? 1;
4009
- const cosTilt = Math.cos(tilt);
4010
- const sinTilt = Math.sin(tilt);
4011
- return {
4012
- positionAt(t) {
4013
- const angle = direction * t / periodMs * Math.PI * 2;
4014
- const x = radius * Math.cos(angle);
4015
- const flatY = radius * Math.sin(angle);
4016
- return { x, y: flatY * cosTilt, z: flatY * sinTilt };
4017
- }
4018
- };
4378
+ // src/animations/rocket-launch.ts
4379
+ var ROCKETS = 20;
4380
+ var CAMERA_Z4 = 3;
4381
+ var FOV3 = 55 * Math.PI / 180;
4382
+ var HALF_HEIGHT2 = Math.tan(FOV3 / 2) * CAMERA_Z4;
4383
+ var SIZE = 0.12;
4384
+ var ROW_Y = -0.5;
4385
+ var PARTICLE_Z = 0.3;
4386
+ var SLIDE_MS = 460;
4387
+ var SLIDE_GATE = 0.45;
4388
+ var EXIT_HURRY3 = 2.5;
4389
+ var LAUNCH_SPREAD_MS = 620;
4390
+ var ASCENT_G = 5.2;
4391
+ var FINISH_PAD_MS = 2e3;
4392
+ var TURNERS = 3;
4393
+ var TURN_MIN_Y = 0.2;
4394
+ var TURN_MAX_Y = 0.8;
4395
+ var TURN_MIN_DEG = 30;
4396
+ var TURN_MAX_DEG = 50;
4397
+ var DEG = Math.PI / 180;
4398
+ var SMOKE_LIFE_MS = 1400;
4399
+ var SMOKE_GAP_MS = 320;
4400
+ var SMOKE_RISE = 0.55;
4401
+ var SMOKE_SIZE = 0.17;
4402
+ var SMOKE_PEAK = 0.16;
4403
+ var SMOKE_POOL = 104;
4404
+ var FIRE_LIFE_MS = 420;
4405
+ var FIRE_GAP_MS = 55;
4406
+ var FIRE_ON_MS = 950;
4407
+ var FIRE_TRAIL = 0.25;
4408
+ var FIRE_SPREAD = 0.09;
4409
+ var FIRE_SIZE = 0.15;
4410
+ var FIRE_PEAK = 0.9;
4411
+ var FIRE_POOL = 140;
4412
+ var SMOKE_COLORS = ["#e2e8f0", "#cbd5e1"];
4413
+ var FIRE_COLORS = ["#fef3c7", "#fde047", "#fb923c", "#ef4444"];
4414
+ var ROCKET_COLORS = ["#e2e8f0", "#f8fafc", "#cbd5e1", "#94a3b8", "#e2e8f0"];
4415
+ function clamp017(value) {
4416
+ return Math.max(0, Math.min(1, value));
4019
4417
  }
4020
-
4021
- // src/prefabs/rocket-launch.ts
4022
- var launchUp = ({ delta, position, velocity, durationMs }) => {
4023
- const coast = durationMs * delta * (1 - 0.5 * delta);
4024
- const climb = 5.5 * easeInCubic(delta);
4025
- return {
4026
- position: {
4027
- x: position.x + (velocity?.x ?? 0) * coast,
4028
- y: position.y + (velocity?.y ?? 0) * coast + climb,
4029
- z: position.z + (velocity?.z ?? 0) * coast
4418
+ function smoothstep2(edge0, edge1, value) {
4419
+ const x = clamp017((value - edge0) / (edge1 - edge0));
4420
+ return x * x * (3 - 2 * x);
4421
+ }
4422
+ function hash012(index, salt) {
4423
+ let h = (Math.imul(index + 1, 2654435769) ^ Math.imul(salt + 1, 2246822507)) >>> 0;
4424
+ h = Math.imul(h ^ h >>> 16, 73244475);
4425
+ h ^= h >>> 16;
4426
+ return (h >>> 0) / 4294967296;
4427
+ }
4428
+ function puffTexture(coreAlpha, coreStop) {
4429
+ return canvasTexture((ctx) => {
4430
+ const g = ctx.createRadialGradient(48, 48, 1, 48, 48, 47);
4431
+ g.addColorStop(0, `rgba(255,255,255,${coreAlpha})`);
4432
+ g.addColorStop(coreStop, `rgba(255,255,255,${coreAlpha * 0.6})`);
4433
+ g.addColorStop(1, "rgba(255,255,255,0)");
4434
+ ctx.fillStyle = g;
4435
+ ctx.fillRect(0, 0, 96, 96);
4436
+ });
4437
+ }
4438
+ var RocketLaunchAnimation = class {
4439
+ constructor(options = {}) {
4440
+ this.rockets = [];
4441
+ this.smoke = [];
4442
+ this.fire = [];
4443
+ this.smokeFades = [];
4444
+ this.fireFades = [];
4445
+ this.blends = new Array(ROCKETS).fill(0);
4446
+ this.groundedAt = new Array(ROCKETS).fill(Infinity);
4447
+ // Per-rocket veer parameters (turnS = Infinity for a rocket that climbs straight).
4448
+ this.turnS = new Array(ROCKETS).fill(Infinity);
4449
+ this.turnDir = [];
4450
+ this.turnRoll = new Array(ROCKETS).fill(0);
4451
+ this.stagger = new Array(ROCKETS).fill(0);
4452
+ this.aspect = 16 / 9;
4453
+ this.enterAt = Infinity;
4454
+ this.exitAt = Infinity;
4455
+ this.launchedAt = Infinity;
4456
+ this.lastNow = 0;
4457
+ this.finished = false;
4458
+ this.backend = options.backend;
4459
+ this.labelContent = options.label;
4460
+ this.fadeLabel = options.fadeLabel ?? true;
4461
+ for (let i = 0; i < ROCKETS; i++) {
4462
+ this.turnDir.push({ x: 0, y: 1 });
4463
+ this.stagger[i] = hash012(i, 7) * LAUNCH_SPREAD_MS;
4030
4464
  }
4031
- };
4465
+ const order = Array.from({ length: ROCKETS }, (_, i) => i).sort(
4466
+ (a, b) => hash012(a, 11) - hash012(b, 11)
4467
+ );
4468
+ for (const i of order.slice(0, TURNERS)) {
4469
+ const height = TURN_MIN_Y + hash012(i, 12) * (TURN_MAX_Y - TURN_MIN_Y);
4470
+ const angle = (TURN_MIN_DEG + hash012(i, 13) * (TURN_MAX_DEG - TURN_MIN_DEG)) * DEG;
4471
+ const sign = hash012(i, 14) < 0.5 ? -1 : 1;
4472
+ this.turnS[i] = height - ROW_Y;
4473
+ this.turnDir[i] = { x: sign * Math.sin(angle), y: Math.cos(angle) };
4474
+ this.turnRoll[i] = -sign * angle;
4475
+ }
4476
+ }
4477
+ mount(target) {
4478
+ if (!target.style.position) target.style.position = "relative";
4479
+ const smokeMeshes = SMOKE_COLORS.map((color) => quad(1, [color]));
4480
+ const fireMeshes = FIRE_COLORS.map((color) => quad(1, [color]));
4481
+ const smokeTexture = puffTexture(0.85, 0.5);
4482
+ const fireTexture = puffTexture(1, 0.32);
4483
+ const backend = async (rendererOptions) => {
4484
+ const renderer = this.backend === "webgpu" ? new (await Promise.resolve().then(() => (init_webgpu_textured(), webgpu_textured_exports))).WebGPUTexturedRenderer(rendererOptions) : this.backend === "webgl" ? new (await Promise.resolve().then(() => (init_webgl_textured(), webgl_textured_exports))).WebGLTexturedRenderer(rendererOptions) : new (await Promise.resolve().then(() => (init_canvas2d_textured(), canvas2d_textured_exports))).Canvas2DTexturedRenderer(rendererOptions);
4485
+ for (const mesh of smokeMeshes) renderer.setTexture(mesh, smokeTexture);
4486
+ for (const mesh of fireMeshes) renderer.setTexture(mesh, fireTexture);
4487
+ return renderer;
4488
+ };
4489
+ const engine = new Little3dEngine({
4490
+ backend,
4491
+ camera: { position: { x: 0, y: 0, z: CAMERA_Z4 }, fov: FOV3 }
4492
+ });
4493
+ const rocketMesh = pyramid(1, ROCKET_COLORS);
4494
+ for (let i = 0; i < ROCKETS; i++) this.rockets.push(engine.add(rocketMesh, { scale: 0 }));
4495
+ for (let s = 0; s < SMOKE_POOL; s++) {
4496
+ const fade = { mode: "one-sided", opacity: 0 };
4497
+ this.smokeFades.push(fade);
4498
+ this.smoke.push(engine.add(smokeMeshes[s % smokeMeshes.length], { scale: 0, transparency: fade }));
4499
+ }
4500
+ for (let f = 0; f < FIRE_POOL; f++) {
4501
+ const fade = { mode: "one-sided", opacity: 0 };
4502
+ this.fireFades.push(fade);
4503
+ this.fire.push(engine.add(fireMeshes[f % fireMeshes.length], { scale: 0, transparency: fade }));
4504
+ }
4505
+ this.engine = engine;
4506
+ engine.mount(target).catch((error) => {
4507
+ target.textContent = error instanceof Error ? error.message : String(error);
4508
+ });
4509
+ const measure = () => {
4510
+ if (target.clientWidth > 0 && target.clientHeight > 0) {
4511
+ this.aspect = target.clientWidth / target.clientHeight;
4512
+ }
4513
+ };
4514
+ measure();
4515
+ this.observer = new ResizeObserver(measure);
4516
+ this.observer.observe(target);
4517
+ this.label = mountAnimationLabel(target, this.labelContent);
4518
+ if (this.fadeLabel) this.label.setOpacity(0);
4519
+ }
4520
+ enter(now) {
4521
+ if (this.enterAt === Infinity) this.enterAt = now;
4522
+ }
4523
+ exit(now) {
4524
+ if (this.exitAt === Infinity) this.exitAt = now;
4525
+ }
4526
+ isFinished() {
4527
+ return this.finished;
4528
+ }
4529
+ render(now, frame) {
4530
+ if (!this.engine || !this.label) return;
4531
+ for (const handle of this.rockets) handle.transform.scale = 0;
4532
+ for (const fade of this.smokeFades) fade.opacity = 0;
4533
+ for (const fade of this.fireFades) fade.opacity = 0;
4534
+ for (const handle of this.smoke) handle.transform.scale = 0;
4535
+ for (const handle of this.fire) handle.transform.scale = 0;
4536
+ if (this.enterAt === Infinity) {
4537
+ this.engine.render();
4538
+ return;
4539
+ }
4540
+ const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
4541
+ this.lastNow = now;
4542
+ const exiting = this.exitAt !== Infinity;
4543
+ this.updateBlends(dt, frame.progress, now, exiting);
4544
+ if (exiting && this.launchedAt === Infinity && this.blends.every((blend) => blend >= 1)) {
4545
+ this.launchedAt = now;
4546
+ }
4547
+ const launched = this.launchedAt !== Infinity;
4548
+ const halfWidth = HALF_HEIGHT2 * this.aspect;
4549
+ const rowHalf = Math.min(halfWidth * 0.8, 1.18);
4550
+ const spawnX = halfWidth + 0.6;
4551
+ let smokeCursor = 0;
4552
+ let fireCursor = 0;
4553
+ for (let i = 0; i < ROCKETS; i++) {
4554
+ const homeX = -rowHalf + 2 * rowHalf * i / (ROCKETS - 1);
4555
+ const launchAt = launched ? this.launchedAt + this.stagger[i] : Infinity;
4556
+ const la = now - launchAt;
4557
+ if (launched && la >= 0) {
4558
+ this.renderAscent(i, homeX, la, halfWidth);
4559
+ fireCursor = this.emitFire(i, homeX, la, fireCursor);
4560
+ continue;
4561
+ }
4562
+ const blend = this.blends[i];
4563
+ if (blend <= 0) continue;
4564
+ const transform2 = this.rockets[i].transform;
4565
+ transform2.position.x = spawnX + (homeX - spawnX) * easeOutBack(blend);
4566
+ transform2.position.y = ROW_Y;
4567
+ transform2.position.z = 0;
4568
+ transform2.rotation.x = 0;
4569
+ transform2.rotation.y = 0;
4570
+ transform2.rotation.z = 0;
4571
+ transform2.scale = SIZE * smoothstep2(0, 0.6, blend);
4572
+ if (this.groundedAt[i] !== Infinity) {
4573
+ smokeCursor = this.emitSmoke(i, homeX, now, launchAt, smokeCursor);
4574
+ }
4575
+ }
4576
+ this.label.setText(frame.indeterminate ? typeof this.labelContent === "string" ? this.labelContent : "" : `${Math.round(frame.progress * 100)}%`);
4577
+ if (this.fadeLabel) {
4578
+ this.label.setOpacity(animationLabelOpacity(
4579
+ now,
4580
+ this.enterAt,
4581
+ SLIDE_MS,
4582
+ this.launchedAt,
4583
+ LAUNCH_SPREAD_MS
4584
+ ));
4585
+ }
4586
+ if (launched && now >= this.launchedAt + LAUNCH_SPREAD_MS + FINISH_PAD_MS) {
4587
+ this.finished = true;
4588
+ }
4589
+ this.engine.render();
4590
+ }
4591
+ destroy() {
4592
+ this.observer?.disconnect();
4593
+ this.observer = void 0;
4594
+ this.label?.container.remove();
4595
+ this.label = void 0;
4596
+ this.engine?.destroy();
4597
+ this.engine = void 0;
4598
+ this.rockets.length = 0;
4599
+ this.smoke.length = 0;
4600
+ this.fire.length = 0;
4601
+ this.smokeFades.length = 0;
4602
+ this.fireFades.length = 0;
4603
+ }
4604
+ updateBlends(dt, progress, now, exiting) {
4605
+ const want = exiting ? ROCKETS : Math.min(ROCKETS, Math.round(progress * ROCKETS));
4606
+ const rate = dt / SLIDE_MS * (exiting ? EXIT_HURRY3 : 1);
4607
+ for (let i = 0; i < ROCKETS; i++) {
4608
+ const target = i < want ? 1 : 0;
4609
+ const blend = this.blends[i];
4610
+ if (target > blend && (i === 0 || this.blends[i - 1] >= SLIDE_GATE)) {
4611
+ this.blends[i] = Math.min(1, blend + rate);
4612
+ } else if (target < blend && (i === ROCKETS - 1 || this.blends[i + 1] <= 1 - SLIDE_GATE)) {
4613
+ this.blends[i] = Math.max(0, blend - rate);
4614
+ }
4615
+ if (this.blends[i] >= 1) {
4616
+ if (this.groundedAt[i] === Infinity) this.groundedAt[i] = now;
4617
+ } else {
4618
+ this.groundedAt[i] = Infinity;
4619
+ }
4620
+ }
4621
+ }
4622
+ /** Along-track distance climbed `la` ms after this rocket's own blast-off. */
4623
+ ascentDistance(la) {
4624
+ const seconds = la / 1e3;
4625
+ return 0.5 * ASCENT_G * seconds * seconds;
4626
+ }
4627
+ /** Rocket center, nose direction, and roll `la` ms into its climb. */
4628
+ ascentPose(i, homeX, la) {
4629
+ const s = this.ascentDistance(la);
4630
+ const turnS = this.turnS[i];
4631
+ if (s <= turnS) {
4632
+ return { pos: { x: homeX, y: ROW_Y + s }, dir: { x: 0, y: 1 }, roll: 0 };
4633
+ }
4634
+ const post = s - turnS;
4635
+ const dir = this.turnDir[i];
4636
+ return {
4637
+ pos: { x: homeX + dir.x * post, y: ROW_Y + turnS + dir.y * post },
4638
+ dir,
4639
+ roll: this.turnRoll[i]
4640
+ };
4641
+ }
4642
+ renderAscent(i, homeX, la, halfWidth) {
4643
+ const { pos, roll } = this.ascentPose(i, homeX, la);
4644
+ if (pos.y > HALF_HEIGHT2 + 0.4 || Math.abs(pos.x) > halfWidth + 0.4) return;
4645
+ const transform2 = this.rockets[i].transform;
4646
+ transform2.position.x = pos.x;
4647
+ transform2.position.y = pos.y;
4648
+ transform2.position.z = 0;
4649
+ transform2.rotation.x = 0;
4650
+ transform2.rotation.y = 0;
4651
+ transform2.rotation.z = roll;
4652
+ transform2.scale = SIZE;
4653
+ }
4654
+ emitFire(i, homeX, la, cursor) {
4655
+ const gap = FIRE_GAP_MS;
4656
+ const last = Math.min(Math.floor(la / gap), Math.floor(FIRE_ON_MS / gap));
4657
+ const first = Math.max(0, Math.ceil((la - FIRE_LIFE_MS) / gap));
4658
+ for (let n = first; n <= last; n++) {
4659
+ if (cursor >= this.fire.length) return cursor;
4660
+ const emitLa = n * gap;
4661
+ const age = la - emitLa;
4662
+ if (age < 0 || age >= FIRE_LIFE_MS) continue;
4663
+ const life = age / FIRE_LIFE_MS;
4664
+ const seconds = age / 1e3;
4665
+ const pose = this.ascentPose(i, homeX, emitLa);
4666
+ const back = { x: -pose.dir.x, y: -pose.dir.y };
4667
+ const perp = { x: -pose.dir.y, y: pose.dir.x };
4668
+ const lat = (hash012(i * 97 + n, 1) - 0.5) * FIRE_SPREAD;
4669
+ const baseX = pose.pos.x + back.x * SIZE * 0.5;
4670
+ const baseY = pose.pos.y + back.y * SIZE * 0.5;
4671
+ const transform2 = this.fire[cursor].transform;
4672
+ transform2.position.x = baseX + back.x * FIRE_TRAIL * seconds + perp.x * lat;
4673
+ transform2.position.y = baseY + back.y * FIRE_TRAIL * seconds + perp.y * lat - 0.12 * seconds * seconds;
4674
+ transform2.position.z = PARTICLE_Z;
4675
+ transform2.rotation.z = hash012(i * 97 + n, 2) * Math.PI * 2;
4676
+ transform2.scale = FIRE_SIZE * (0.7 + 0.5 * hash012(i * 97 + n, 3)) * (1 - 0.55 * life);
4677
+ this.fireFades[cursor].opacity = FIRE_PEAK * smoothstep2(0, 0.15, life) * (1 - smoothstep2(0.55, 1, life));
4678
+ cursor++;
4679
+ }
4680
+ return cursor;
4681
+ }
4682
+ emitSmoke(i, homeX, now, launchAt, cursor) {
4683
+ const start = this.groundedAt[i];
4684
+ const tr = now - start;
4685
+ const gap = SMOKE_GAP_MS;
4686
+ const emitUntil = Number.isFinite(launchAt) ? launchAt - start : tr;
4687
+ const last = Math.min(Math.floor(tr / gap), Math.floor(emitUntil / gap));
4688
+ const first = Math.max(0, Math.ceil((tr - SMOKE_LIFE_MS) / gap));
4689
+ const baseY = ROW_Y - SIZE * 0.4;
4690
+ for (let n = first; n <= last; n++) {
4691
+ if (cursor >= this.smoke.length) return cursor;
4692
+ const age = tr - n * gap;
4693
+ if (age < 0 || age >= SMOKE_LIFE_MS) continue;
4694
+ const life = age / SMOKE_LIFE_MS;
4695
+ const drift = (hash012(i * 131 + n, 1) - 0.5) * 0.14;
4696
+ const transform2 = this.smoke[cursor].transform;
4697
+ transform2.position.x = homeX + drift * life;
4698
+ transform2.position.y = baseY + SMOKE_RISE * life;
4699
+ transform2.position.z = PARTICLE_Z;
4700
+ transform2.rotation.z = hash012(i * 131 + n, 2) * Math.PI * 2;
4701
+ transform2.scale = SMOKE_SIZE * (0.5 + 0.8 * life) * (0.7 + 0.6 * hash012(i * 131 + n, 3));
4702
+ this.smokeFades[cursor].opacity = SMOKE_PEAK * smoothstep2(0, 0.25, life) * (1 - smoothstep2(0.5, 1, life));
4703
+ cursor++;
4704
+ }
4705
+ return cursor;
4706
+ }
4032
4707
  };
4708
+
4709
+ // src/prefabs/rocket-launch.ts
4033
4710
  function rocketLaunch(options = {}) {
4034
- const motion = options.object?.motion ?? circleMotion({ radius: 0.55, periodMs: 7e3, tilt: 0.15 });
4035
- const particles = options.particles ?? {};
4036
- const object = new ObjectMotionAnimation({
4037
- mesh: () => pyramid(1, ["#e2e8f0", "#f8fafc", "#cbd5e1", "#94a3b8", "#e2e8f0"]),
4038
- motion,
4039
- size: 0.44,
4040
- facing: "+y",
4041
- outro: { transition: launchUp, durationMs: 1300 },
4042
- backend: options.backend,
4043
- ...options.object,
4044
- label: options.object?.label
4045
- });
4046
- const exhaust = new ParticlesAnimation({
4047
- rate: 46,
4048
- lifeMs: 1100,
4049
- size: 0.18,
4050
- speed: 0.5,
4051
- direction: { x: 0, y: -1, z: 0 },
4052
- spread: 0.3,
4053
- gravity: { x: 0, y: -0.6, z: 0 },
4054
- colors: ["#fde047", "#fb923c", "#ef4444", "#fef3c7"],
4055
- texture: particles.texture ?? shineTexture(),
4056
- emitter: object.trailEmitter(),
4057
- outroMs: object.outroDurationMs,
4058
- seed: 9,
4059
- backend: options.backend,
4060
- ...particles,
4061
- label: options.label ?? particles.label,
4062
- fadeLabel: options.fadeLabel ?? particles.fadeLabel
4063
- });
4064
- return progressSpinner(new CompositeAnimation([exhaust, object]), options);
4711
+ return progressSpinner(
4712
+ new RocketLaunchAnimation({
4713
+ backend: options.backend,
4714
+ label: options.label,
4715
+ fadeLabel: options.fadeLabel
4716
+ }),
4717
+ options
4718
+ );
4065
4719
  }
4066
4720
 
4067
4721
  // src/motion/wander.ts
@@ -4129,34 +4783,88 @@ void main() {
4129
4783
  }), options);
4130
4784
  }
4131
4785
 
4786
+ // src/motion/circle.ts
4787
+ function circleMotion(options = {}) {
4788
+ const radius = options.radius ?? 1.3;
4789
+ const periodMs = options.periodMs ?? 3e3;
4790
+ const tilt = options.tilt ?? 0.5;
4791
+ const direction = options.direction ?? 1;
4792
+ const cosTilt = Math.cos(tilt);
4793
+ const sinTilt = Math.sin(tilt);
4794
+ return {
4795
+ positionAt(t) {
4796
+ const angle = direction * t / periodMs * Math.PI * 2;
4797
+ const x = radius * Math.cos(angle);
4798
+ const flatY = radius * Math.sin(angle);
4799
+ return { x, y: flatY * cosTilt, z: flatY * sinTilt };
4800
+ }
4801
+ };
4802
+ }
4803
+
4132
4804
  // <stdin>
4133
4805
  init_webgl_textured();
4134
4806
  init_webgpu_textured();
4135
4807
 
4136
4808
  // src/engines/little-3d-engine/loaders/obj.ts
4137
4809
  var DEFAULT_COLORS12 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444"];
4810
+ function clamp018(value) {
4811
+ return Math.min(1, Math.max(0, value));
4812
+ }
4138
4813
  function channelToHex(value) {
4139
4814
  const channel = Number.parseFloat(value);
4140
4815
  if (!Number.isFinite(channel)) return void 0;
4141
- return Math.round(Math.min(1, Math.max(0, channel)) * 255).toString(16).padStart(2, "0");
4142
- }
4143
- function parseMtlColors(text) {
4144
- const colors = /* @__PURE__ */ new Map();
4145
- let material;
4816
+ return Math.round(clamp018(channel) * 255).toString(16).padStart(2, "0");
4817
+ }
4818
+ function parseRgb(parts) {
4819
+ const channels = parts.slice(1, 4).map(Number.parseFloat);
4820
+ if (channels.length !== 3 || !channels.every(Number.isFinite)) return void 0;
4821
+ return [clamp018(channels[0]), clamp018(channels[1]), clamp018(channels[2])];
4822
+ }
4823
+ function toMaterial(surface) {
4824
+ const material = {};
4825
+ if (surface.specular) material.specular = surface.specular;
4826
+ if (surface.shininess !== void 0) material.shininess = surface.shininess;
4827
+ if (surface.emissive) material.emissive = surface.emissive;
4828
+ return Object.keys(material).length > 0 ? material : void 0;
4829
+ }
4830
+ function parseMtl(text) {
4831
+ const materials = /* @__PURE__ */ new Map();
4832
+ const surfaces = /* @__PURE__ */ new Map();
4833
+ let name;
4146
4834
  for (const line of text.split("\n")) {
4147
4835
  const trimmed = line.trim();
4148
4836
  if (trimmed === "" || trimmed.startsWith("#")) continue;
4149
4837
  const parts = trimmed.split(/\s+/);
4150
- if (parts[0] === "newmtl") {
4151
- material = parts.slice(1).join(" ");
4152
- } else if (parts[0] === "Kd" && material) {
4838
+ const keyword = parts[0];
4839
+ if (keyword === "newmtl") {
4840
+ name = parts.slice(1).join(" ");
4841
+ if (name && !materials.has(name)) {
4842
+ materials.set(name, {});
4843
+ surfaces.set(name, {});
4844
+ }
4845
+ continue;
4846
+ }
4847
+ if (!name) continue;
4848
+ const entry = materials.get(name);
4849
+ const surface = surfaces.get(name);
4850
+ if (keyword === "Kd") {
4153
4851
  const channels = parts.slice(1, 4).map(channelToHex);
4154
4852
  if (channels.length === 3 && channels.every((channel) => channel !== void 0)) {
4155
- colors.set(material, `#${channels.join("")}`);
4853
+ entry.color = `#${channels.join("")}`;
4156
4854
  }
4855
+ } else if (keyword === "Ks") {
4856
+ surface.specular = parseRgb(parts);
4857
+ } else if (keyword === "Ns") {
4858
+ const ns = Number.parseFloat(parts[1]);
4859
+ if (Number.isFinite(ns)) surface.shininess = Math.max(0, ns);
4860
+ } else if (keyword === "Ke") {
4861
+ surface.emissive = parseRgb(parts);
4157
4862
  }
4158
4863
  }
4159
- return colors;
4864
+ for (const [key, surface] of surfaces) {
4865
+ materials.get(key).material = toMaterial(surface);
4866
+ }
4867
+ return materials;
4160
4868
  }
4161
4869
  function resolveIndex(token, vertexCount) {
4162
4870
  const n = parseInt(token, 10);
@@ -4164,7 +4872,7 @@ void main() {
4164
4872
  }
4165
4873
  function parseObj(text, options = {}) {
4166
4874
  const colors = options.colors ?? DEFAULT_COLORS12;
4167
- const materialColors = options.useMtlColors && options.mtl ? parseMtlColors(options.mtl) : void 0;
4875
+ const materials = options.useMtlColors && options.mtl ? parseMtl(options.mtl) : void 0;
4168
4876
  const vertices = [];
4169
4877
  const faces = [];
4170
4878
  let material;
@@ -4188,9 +4896,11 @@ void main() {
4188
4896
  indices.push(resolveIndex(vertexToken, vertices.length));
4189
4897
  }
4190
4898
  if (indices.length >= 3) {
4191
- const mtlColor = material ? materialColors?.get(material) : void 0;
4192
- const color = mtlColor ?? (materialColors ? colors[0] ?? "#888888" : colors[faces.length % colors.length]);
4193
- faces.push({ indices, color });
4899
+ const entry = material ? materials?.get(material) : void 0;
4900
+ const color = entry?.color ?? (materials ? colors[0] ?? "#888888" : colors[faces.length % colors.length]);
4901
+ const face = { indices, color };
4902
+ if (entry?.material) face.material = entry.material;
4903
+ faces.push(face);
4194
4904
  }
4195
4905
  }
4196
4906
  }