@opsydyn/effect-three 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +14 -1
  3. package/dist/{index-01kwfekm.js → index-h3vf50fn.js} +2418 -85
  4. package/dist/index-h3vf50fn.js.map +53 -0
  5. package/dist/index-ttwy0znv.js +145 -0
  6. package/dist/index-ttwy0znv.js.map +12 -0
  7. package/dist/index.js +543 -149
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/behaviours.d.ts.map +1 -1
  10. package/dist/kernel/boxSelect.d.ts +22 -0
  11. package/dist/kernel/boxSelect.d.ts.map +1 -0
  12. package/dist/kernel/build.d.ts +197 -6
  13. package/dist/kernel/build.d.ts.map +1 -1
  14. package/dist/kernel/cameraMotion.d.ts +3 -1
  15. package/dist/kernel/cameraMotion.d.ts.map +1 -1
  16. package/dist/kernel/cameraShake.d.ts +33 -0
  17. package/dist/kernel/cameraShake.d.ts.map +1 -0
  18. package/dist/kernel/describeScene.d.ts +50 -0
  19. package/dist/kernel/describeScene.d.ts.map +1 -0
  20. package/dist/kernel/description.d.ts +240 -2
  21. package/dist/kernel/description.d.ts.map +1 -1
  22. package/dist/kernel/diff.d.ts +18 -1
  23. package/dist/kernel/diff.d.ts.map +1 -1
  24. package/dist/kernel/equality.d.ts +13 -1
  25. package/dist/kernel/equality.d.ts.map +1 -1
  26. package/dist/kernel/gizmo.d.ts +69 -0
  27. package/dist/kernel/gizmo.d.ts.map +1 -0
  28. package/dist/kernel/history.d.ts +61 -0
  29. package/dist/kernel/history.d.ts.map +1 -0
  30. package/dist/kernel/index.d.ts +13 -2
  31. package/dist/kernel/index.d.ts.map +1 -1
  32. package/dist/kernel/index.js +116 -3
  33. package/dist/kernel/index.js.map +1 -1
  34. package/dist/kernel/inspect.d.ts +85 -0
  35. package/dist/kernel/inspect.d.ts.map +1 -0
  36. package/dist/kernel/lineGeometry.d.ts +25 -0
  37. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  38. package/dist/kernel/lockstep.d.ts +114 -0
  39. package/dist/kernel/lockstep.d.ts.map +1 -0
  40. package/dist/kernel/loop.d.ts.map +1 -1
  41. package/dist/kernel/materials.d.ts.map +1 -1
  42. package/dist/kernel/pivot.d.ts +54 -0
  43. package/dist/kernel/pivot.d.ts.map +1 -0
  44. package/dist/kernel/pointBuffers.d.ts +17 -0
  45. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  46. package/dist/kernel/pointer.d.ts +12 -0
  47. package/dist/kernel/pointer.d.ts.map +1 -1
  48. package/dist/kernel/renderKit.d.ts +21 -1
  49. package/dist/kernel/renderKit.d.ts.map +1 -1
  50. package/dist/kernel/renderer.d.ts +6 -0
  51. package/dist/kernel/renderer.d.ts.map +1 -1
  52. package/dist/kernel/resources.d.ts.map +1 -1
  53. package/dist/kernel/sceneChanges.d.ts +87 -0
  54. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  55. package/dist/kernel/sceneGraph.d.ts +6 -1
  56. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  57. package/dist/kernel/seeded.d.ts +9 -0
  58. package/dist/kernel/seeded.d.ts.map +1 -0
  59. package/dist/kernel/shots.d.ts +26 -0
  60. package/dist/kernel/shots.d.ts.map +1 -0
  61. package/dist/kernel/tape.d.ts +38 -0
  62. package/dist/kernel/tape.d.ts.map +1 -0
  63. package/dist/kernel/trail.d.ts +31 -0
  64. package/dist/kernel/trail.d.ts.map +1 -0
  65. package/dist/kernel/viewportGizmo.d.ts +25 -0
  66. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  67. package/dist/runtime/Inspector.d.ts +20 -0
  68. package/dist/runtime/Inspector.d.ts.map +1 -0
  69. package/dist/runtime/SceneFile.d.ts +448 -0
  70. package/dist/runtime/SceneFile.d.ts.map +1 -0
  71. package/dist/runtime/SceneSchema.d.ts +614 -5
  72. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  73. package/dist/runtime/errors.d.ts +9 -0
  74. package/dist/runtime/errors.d.ts.map +1 -1
  75. package/dist/runtime/index.d.ts +3 -1
  76. package/dist/runtime/index.d.ts.map +1 -1
  77. package/dist/webgl/index.d.ts.map +1 -1
  78. package/dist/webgl/index.js +228 -3
  79. package/dist/webgl/index.js.map +6 -4
  80. package/dist/webgl/line.d.ts +4 -0
  81. package/dist/webgl/line.d.ts.map +1 -0
  82. package/dist/webgl/points.d.ts +4 -0
  83. package/dist/webgl/points.d.ts.map +1 -0
  84. package/dist/webgpu/index.d.ts.map +1 -1
  85. package/dist/webgpu/index.js +206 -26
  86. package/dist/webgpu/index.js.map +6 -4
  87. package/dist/webgpu/line.d.ts +4 -0
  88. package/dist/webgpu/line.d.ts.map +1 -0
  89. package/dist/webgpu/points.d.ts +4 -0
  90. package/dist/webgpu/points.d.ts.map +1 -0
  91. package/package.json +2 -1
  92. package/src/kernel/behaviours.ts +5 -0
  93. package/src/kernel/bodySources.ts +1 -1
  94. package/src/kernel/boxSelect.ts +73 -0
  95. package/src/kernel/build.ts +430 -0
  96. package/src/kernel/cameraMotion.ts +3 -0
  97. package/src/kernel/cameraShake.ts +87 -0
  98. package/src/kernel/describeScene.ts +224 -0
  99. package/src/kernel/description.ts +266 -1
  100. package/src/kernel/diff.ts +46 -1
  101. package/src/kernel/equality.ts +148 -0
  102. package/src/kernel/gizmo.ts +478 -0
  103. package/src/kernel/history.ts +118 -0
  104. package/src/kernel/index.ts +102 -0
  105. package/src/kernel/inspect.ts +214 -0
  106. package/src/kernel/lineGeometry.ts +75 -0
  107. package/src/kernel/lockstep.ts +237 -0
  108. package/src/kernel/loop.ts +5 -0
  109. package/src/kernel/materials.ts +3 -0
  110. package/src/kernel/pivot.ts +418 -0
  111. package/src/kernel/pointBuffers.ts +115 -0
  112. package/src/kernel/pointer.ts +63 -12
  113. package/src/kernel/renderKit.ts +23 -1
  114. package/src/kernel/renderer.ts +6 -0
  115. package/src/kernel/resources.ts +69 -0
  116. package/src/kernel/sceneChanges.ts +265 -0
  117. package/src/kernel/sceneGraph.ts +461 -9
  118. package/src/kernel/seeded.ts +18 -0
  119. package/src/kernel/shots.ts +167 -0
  120. package/src/kernel/tape.ts +124 -0
  121. package/src/kernel/trail.ts +170 -0
  122. package/src/kernel/viewportGizmo.ts +194 -0
  123. package/src/runtime/Inspector.ts +43 -0
  124. package/src/runtime/SceneFile.ts +46 -0
  125. package/src/runtime/SceneSchema.ts +305 -4
  126. package/src/runtime/errors.ts +6 -0
  127. package/src/runtime/index.ts +3 -1
  128. package/src/webgl/index.ts +4 -0
  129. package/src/webgl/line.ts +66 -0
  130. package/src/webgl/points.ts +205 -0
  131. package/src/webgpu/index.ts +4 -0
  132. package/src/webgpu/line.ts +67 -0
  133. package/src/webgpu/points.ts +193 -0
  134. package/dist/index-01kwfekm.js.map +0 -40
@@ -3,9 +3,18 @@
3
3
  *
4
4
  * @module
5
5
  */
6
+ import { CatmullRomCurve3, Color, CubicBezierCurve3, QuadraticBezierCurve3, Vector3 } from 'three';
7
+
6
8
  import type {
7
9
  AmbientLightNode,
10
+ LineNode,
11
+ PointLook,
12
+ PointsNode,
13
+ Edges,
14
+ Trail,
8
15
  Animation,
16
+ CameraShake,
17
+ Detail,
9
18
  BasicMaterial,
10
19
  Body,
11
20
  Behave,
@@ -16,6 +25,8 @@ import type {
16
25
  ContactShadows,
17
26
  Environment,
18
27
  Gestures,
28
+ Hud,
29
+ HudAlignment,
19
30
  GridNode,
20
31
  Sky,
21
32
  Xr,
@@ -40,6 +51,8 @@ import type {
40
51
  ColliderDescription,
41
52
  ColliderShapeDescription,
42
53
  CapsuleGeometry,
54
+ RoundedBoxGeometry,
55
+ BackdropGeometry,
43
56
  CircleGeometry,
44
57
  ConeGeometry,
45
58
  CylinderGeometry,
@@ -106,6 +119,21 @@ export const spin = (options: {
106
119
  phase: options.phase ?? 0,
107
120
  });
108
121
 
122
+ /**
123
+ * Faces the camera every frame, as Drei's `Billboard` (`bf6f4ad`, `src/core/Billboard.tsx`):
124
+ * for labels, markers and sprites that should always read face on. Lock an axis to keep
125
+ * it still, `lockX` and `lockZ` together for something that stays upright and only turns
126
+ * round. To rotate it as well, put it in a rotated group.
127
+ */
128
+ export const billboard = (
129
+ options: { readonly lockX?: boolean; readonly lockY?: boolean; readonly lockZ?: boolean } = {},
130
+ ): Rotation => ({
131
+ _tag: 'Billboard',
132
+ lockX: options.lockX ?? false,
133
+ lockY: options.lockY ?? false,
134
+ lockZ: options.lockZ ?? false,
135
+ });
136
+
109
137
  /** Box geometry. Each dimension defaults to 1. */
110
138
  export const box = (
111
139
  size: { readonly width?: number; readonly height?: number; readonly depth?: number } = {},
@@ -197,6 +225,47 @@ export const capsule = (
197
225
  radialSegments: options.radialSegments ?? 8,
198
226
  });
199
227
 
228
+ /**
229
+ * A box with rounded edges and corners, as Drei's `RoundedBox` (`bf6f4ad`,
230
+ * `src/core/RoundedBox.tsx`), with its defaults: 1 × 1 × 1, radius 0.05, smoothness 4,
231
+ * 4 bevel segments, 1 step, crease angle 0.4.
232
+ */
233
+ export const roundedBox = (
234
+ options: {
235
+ readonly width?: number;
236
+ readonly height?: number;
237
+ readonly depth?: number;
238
+ readonly radius?: number;
239
+ readonly smoothness?: number;
240
+ readonly bevelSegments?: number;
241
+ readonly steps?: number;
242
+ readonly creaseAngle?: number;
243
+ } = {},
244
+ ): RoundedBoxGeometry => ({
245
+ _tag: 'RoundedBox',
246
+ width: options.width ?? 1,
247
+ height: options.height ?? 1,
248
+ depth: options.depth ?? 1,
249
+ radius: options.radius ?? 0.05,
250
+ smoothness: options.smoothness ?? 4,
251
+ bevelSegments: options.bevelSegments ?? 4,
252
+ steps: options.steps ?? 1,
253
+ creaseAngle: options.creaseAngle ?? 0.4,
254
+ });
255
+
256
+ /**
257
+ * A curved photo backdrop, as Drei's `Backdrop` (`bf6f4ad`, `src/core/Backdrop.tsx`), with
258
+ * its defaults: floor 0.25, 20 segments. One unit each way: scale the mesh to size, and
259
+ * give it `receiveShadow` to catch shadows as Drei's prop does.
260
+ */
261
+ export const backdrop = (
262
+ options: { readonly floor?: number; readonly segments?: number } = {},
263
+ ): BackdropGeometry => ({
264
+ _tag: 'Backdrop',
265
+ floor: options.floor ?? 0.25,
266
+ segments: options.segments ?? 20,
267
+ });
268
+
200
269
  /** Ring geometry facing +Z. Defaults (as three.js): radii 0.5 and 1, 32 segments. */
201
270
  export const ring = (
202
271
  options: {
@@ -284,6 +353,7 @@ export const basic = (options: Partial<Omit<BasicMaterial, '_tag'>> = {}): Basic
284
353
  transparent: options.transparent ?? false,
285
354
  side: options.side ?? 'front',
286
355
  wireframe: options.wireframe ?? false,
356
+ depthTest: options.depthTest ?? true,
287
357
  });
288
358
 
289
359
  /**
@@ -478,6 +548,8 @@ export const group = (
478
548
  readonly children?: ReadonlyArray<SceneNode>;
479
549
  readonly body?: Body | null;
480
550
  readonly follow?: XrSpace | null;
551
+ readonly detail?: Detail | null;
552
+ readonly trail?: Trail | null;
481
553
  },
482
554
  ): GroupNode => ({
483
555
  _tag: 'Group',
@@ -491,8 +563,287 @@ export const group = (
491
563
  children: options.children ?? [],
492
564
  body: options.body ?? null,
493
565
  follow: options.follow ?? null,
566
+ detail: options.detail ?? null,
567
+ trail: options.trail ?? null,
568
+ });
569
+
570
+ /**
571
+ * Levels of detail for a group, as Drei's `Detailed` (`bf6f4ad`, `src/core/Detailed.tsx`):
572
+ * `group({ detail: detailed({ distances: [0, 10, 20] }), children: [near, middle, far] })`
573
+ * shows `near` until the camera is 10 away, then `middle`, then `far` from 20. Hysteresis
574
+ * defaults to 0, as Drei's.
575
+ */
576
+ export const detailed = (options: {
577
+ readonly distances: ReadonlyArray<number>;
578
+ readonly hysteresis?: number;
579
+ }): Detail => ({ distances: options.distances, hysteresis: options.hysteresis ?? 0 });
580
+
581
+ /**
582
+ * A line through `points`, as Drei's `Line` (`bf6f4ad`, `src/core/Line.tsx`), with its
583
+ * defaults: white, 1 pixel wide, solid, opaque. three's `LineMaterial` defaults the dashes:
584
+ * size 1, gap 1, scale 1, offset 0. `segments: true` is Drei's `Segments`: pairs of points.
585
+ */
586
+ export const line = (
587
+ options: TransformOptions & {
588
+ readonly points: ReadonlyArray<Vec3>;
589
+ readonly segments?: boolean;
590
+ readonly color?: Rgb;
591
+ readonly vertexColors?: ReadonlyArray<Rgb> | null;
592
+ readonly width?: number;
593
+ readonly worldUnits?: boolean;
594
+ readonly dashed?: boolean;
595
+ readonly dashSize?: number;
596
+ readonly gapSize?: number;
597
+ readonly dashScale?: number;
598
+ readonly dashOffset?: number;
599
+ readonly opacity?: number;
600
+ readonly transparent?: boolean;
601
+ readonly interactive?: boolean;
602
+ },
603
+ ): LineNode => ({
604
+ _tag: 'Line',
605
+ key: options.key,
606
+ position: options.position ?? origin,
607
+ rotation: options.rotation ?? noRotation,
608
+ scale: options.scale ?? unitScale,
609
+ transition: options.transition ?? instantTransition,
610
+ behaviour: options.behaviour ?? null,
611
+ interactive: options.interactive ?? false,
612
+ points: options.points,
613
+ segments: options.segments ?? false,
614
+ color: options.color ?? 0xffffff,
615
+ vertexColors: options.vertexColors ?? null,
616
+ width: options.width ?? 1,
617
+ worldUnits: options.worldUnits ?? false,
618
+ dashed: options.dashed ?? false,
619
+ dashSize: options.dashSize ?? 1,
620
+ gapSize: options.gapSize ?? 1,
621
+ dashScale: options.dashScale ?? 1,
622
+ dashOffset: options.dashOffset ?? 0,
623
+ opacity: options.opacity ?? 1,
624
+ transparent: options.transparent ?? false,
494
625
  });
495
626
 
627
+ const asVector = (point: Vec3): Vector3 => new Vector3(point[0], point[1], point[2]);
628
+ const asVec3 = (point: Vector3): Vec3 => [point.x, point.y, point.z];
629
+
630
+ /** Where a points node sits, as the other placed nodes. */
631
+ type Placing = Pick<
632
+ TransformOptions,
633
+ 'key' | 'position' | 'rotation' | 'scale' | 'transition' | 'behaviour'
634
+ >;
635
+
636
+ const placed = (options: Placing, look: PointLook): PointsNode => ({
637
+ _tag: 'Points',
638
+ key: options.key,
639
+ position: options.position ?? origin,
640
+ rotation: options.rotation ?? noRotation,
641
+ scale: options.scale ?? unitScale,
642
+ transition: options.transition ?? instantTransition,
643
+ behaviour: options.behaviour ?? null,
644
+ look,
645
+ });
646
+
647
+ /**
648
+ * Dots at `positions`, as Drei's `Points` (`bf6f4ad`, `src/core/Points.tsx`) with its
649
+ * `PointMaterial`: round, white, 1 pixel (three's `PointsMaterial` size), smaller with
650
+ * distance. `colors` gives each its own colour, `sizes` a factor on `size`.
651
+ */
652
+ export const points = (
653
+ options: Placing & {
654
+ readonly positions: ReadonlyArray<Vec3>;
655
+ readonly colors?: ReadonlyArray<Rgb> | null;
656
+ readonly sizes?: ReadonlyArray<number> | null;
657
+ readonly color?: Rgb;
658
+ readonly size?: number;
659
+ readonly sizeAttenuation?: boolean;
660
+ readonly round?: boolean;
661
+ readonly opacity?: number;
662
+ readonly transparent?: boolean;
663
+ },
664
+ ): PointsNode =>
665
+ placed(options, {
666
+ _tag: 'Dots',
667
+ positions: options.positions,
668
+ colors: options.colors ?? null,
669
+ sizes: options.sizes ?? null,
670
+ color: options.color ?? 0xffffff,
671
+ size: options.size ?? 1,
672
+ sizeAttenuation: options.sizeAttenuation ?? true,
673
+ round: options.round ?? true,
674
+ opacity: options.opacity ?? 1,
675
+ transparent: options.transparent ?? false,
676
+ });
677
+
678
+ /**
679
+ * Glowing motes drifting in a box, as Drei's `Sparkles` (`src/core/Sparkles.tsx`), with
680
+ * its defaults: 100 of them, speed 1, opaque, a unit box (`spread`, Drei's `scale`),
681
+ * random sizes up to 1, white, noise 1. Drei scatters them with `Math.random`; `seed`
682
+ * scatters them the same way every time.
683
+ */
684
+ export const sparkles = (
685
+ options: Placing & {
686
+ readonly count?: number;
687
+ readonly speed?: number;
688
+ readonly opacity?: number;
689
+ readonly size?: number | null;
690
+ readonly color?: Rgb;
691
+ readonly spread?: number | Vec3;
692
+ readonly noise?: number | Vec3;
693
+ readonly seed?: number;
694
+ },
695
+ ): PointsNode => {
696
+ const spread = options.spread ?? 1;
697
+ const noise = options.noise ?? 1;
698
+ return placed(options, {
699
+ _tag: 'Sparkles',
700
+ count: options.count ?? 100,
701
+ speed: options.speed ?? 1,
702
+ opacity: options.opacity ?? 1,
703
+ scale: typeof spread === 'number' ? [spread, spread, spread] : spread,
704
+ size: options.size ?? null,
705
+ color: options.color ?? 0xffffff,
706
+ noise: typeof noise === 'number' ? [noise, noise, noise] : noise,
707
+ seed: options.seed ?? 1,
708
+ });
709
+ };
710
+
711
+ /**
712
+ * A starfield, as Drei's `Stars` (`src/core/Stars.tsx`), with its defaults: 5000 stars
713
+ * from radius 100 to 150, size factor 4, white (saturation 0), hard-edged, twinkling at
714
+ * speed 1. `seed` places them the same way every time.
715
+ */
716
+ export const stars = (
717
+ options: Placing & {
718
+ readonly radius?: number;
719
+ readonly depth?: number;
720
+ readonly count?: number;
721
+ readonly factor?: number;
722
+ readonly saturation?: number;
723
+ readonly fade?: boolean;
724
+ readonly speed?: number;
725
+ readonly seed?: number;
726
+ },
727
+ ): PointsNode =>
728
+ placed(options, {
729
+ _tag: 'Stars',
730
+ radius: options.radius ?? 100,
731
+ depth: options.depth ?? 50,
732
+ count: options.count ?? 5000,
733
+ factor: options.factor ?? 4,
734
+ saturation: options.saturation ?? 0,
735
+ fade: options.fade ?? false,
736
+ speed: options.speed ?? 1,
737
+ seed: options.seed ?? 1,
738
+ });
739
+
740
+ /**
741
+ * Points along a Catmull-Rom curve through `points`, as Drei's `CatmullRomLine`, with its
742
+ * defaults: open, centripetal, tension 0.5, 20 segments (so 21 points).
743
+ */
744
+ export const catmullRomPoints = (
745
+ points: ReadonlyArray<Vec3>,
746
+ options: {
747
+ readonly closed?: boolean;
748
+ readonly curveType?: 'centripetal' | 'chordal' | 'catmullrom';
749
+ readonly tension?: number;
750
+ readonly segments?: number;
751
+ } = {},
752
+ ): ReadonlyArray<Vec3> =>
753
+ new CatmullRomCurve3(
754
+ points.map(asVector),
755
+ options.closed ?? false,
756
+ options.curveType ?? 'centripetal',
757
+ options.tension ?? 0.5,
758
+ )
759
+ .getPoints(options.segments ?? 20)
760
+ .map(asVec3);
761
+
762
+ /**
763
+ * The point `t` of the way along a Catmull-Rom curve through `points` (0 its start, 1 its
764
+ * end; three's `getPoint`, by parameter rather than arc length), with
765
+ * `catmullRomPoints`'s defaults: for something that rides the curve, a behaviour say.
766
+ */
767
+ export const catmullRomPointAt = (
768
+ points: ReadonlyArray<Vec3>,
769
+ t: number,
770
+ options: {
771
+ readonly closed?: boolean;
772
+ readonly curveType?: 'centripetal' | 'chordal' | 'catmullrom';
773
+ readonly tension?: number;
774
+ } = {},
775
+ ): Vec3 =>
776
+ asVec3(
777
+ new CatmullRomCurve3(
778
+ points.map(asVector),
779
+ options.closed ?? false,
780
+ options.curveType ?? 'centripetal',
781
+ options.tension ?? 0.5,
782
+ ).getPoint(Math.min(1, Math.max(0, t))),
783
+ );
784
+
785
+ /**
786
+ * Colours for a Catmull-Rom line's points from a few along it, as Drei's
787
+ * `CatmullRomLine` spreads `vertexColors` over its segments (blended in linear colour).
788
+ */
789
+ export const catmullRomColors = (
790
+ colors: ReadonlyArray<Rgb>,
791
+ options: { readonly segments?: number; readonly closed?: boolean } = {},
792
+ ): ReadonlyArray<Rgb> => {
793
+ const segments = options.segments ?? 20;
794
+ if (colors.length < 2 || colors.length === segments + 1) return colors;
795
+ const stops = colors.map((color) => new Color(color));
796
+ const first = stops[0];
797
+ if (options.closed === true && first !== undefined) stops.push(first.clone());
798
+ const divisions = segments / (stops.length - 1);
799
+ // The colour at point `index`: between the two stops either side of it.
800
+ const at = (index: number): Rgb =>
801
+ (stops[Math.floor(index / divisions)] ?? new Color())
802
+ .clone()
803
+ .lerp(
804
+ stops[Math.floor(index / divisions) + 1] ?? new Color(),
805
+ (index % divisions) / divisions,
806
+ )
807
+ .getHex();
808
+ const blended = Array.from({ length: segments - 1 }, (_, step) => at(step + 1));
809
+ return [colors[0] ?? 0, ...blended, stops.at(-1)?.getHex() ?? 0];
810
+ };
811
+
812
+ /** Points along a cubic Bézier curve, as Drei's `CubicBezierLine` (20 segments). */
813
+ export const cubicBezierPoints = (options: {
814
+ readonly start: Vec3;
815
+ readonly midA: Vec3;
816
+ readonly midB: Vec3;
817
+ readonly end: Vec3;
818
+ readonly segments?: number;
819
+ }): ReadonlyArray<Vec3> =>
820
+ new CubicBezierCurve3(
821
+ asVector(options.start),
822
+ asVector(options.midA),
823
+ asVector(options.midB),
824
+ asVector(options.end),
825
+ )
826
+ .getPoints(options.segments ?? 20)
827
+ .map(asVec3);
828
+
829
+ /**
830
+ * Points along a quadratic Bézier curve, as Drei's `QuadraticBezierLine` (20 segments).
831
+ * Without `mid`, Drei's default: level with `start`, above or below `end`.
832
+ */
833
+ export const quadraticBezierPoints = (options: {
834
+ readonly start: Vec3;
835
+ readonly end: Vec3;
836
+ readonly mid?: Vec3;
837
+ readonly segments?: number;
838
+ }): ReadonlyArray<Vec3> => {
839
+ const [, startY] = options.start;
840
+ const [endX, , endZ] = options.end;
841
+ const mid = options.mid ?? [endX, startY, endZ];
842
+ return new QuadraticBezierCurve3(asVector(options.start), asVector(mid), asVector(options.end))
843
+ .getPoints(options.segments ?? 20)
844
+ .map(asVec3);
845
+ };
846
+
496
847
  /** The head (the viewer), for content that stays in view. */
497
848
  export const headSpace = (): XrSpace => ({ _tag: 'Head' });
498
849
 
@@ -547,6 +898,8 @@ export const mesh = (
547
898
  readonly castShadow?: boolean;
548
899
  readonly receiveShadow?: boolean;
549
900
  readonly body?: Body | null;
901
+ readonly edges?: Edges | null;
902
+ readonly trail?: Trail | null;
550
903
  },
551
904
  ): MeshNode => ({
552
905
  _tag: 'Mesh',
@@ -562,6 +915,42 @@ export const mesh = (
562
915
  castShadow: options.castShadow ?? false,
563
916
  receiveShadow: options.receiveShadow ?? false,
564
917
  body: options.body ?? null,
918
+ edges: options.edges ?? null,
919
+ trail: options.trail ?? null,
920
+ });
921
+
922
+ /**
923
+ * A trail behind a moving node, as Drei's `Trail` (`bf6f4ad`, `src/core/Trail.tsx`), with
924
+ * its defaults: width 0.2, length 1, decay 1, world positions, no stride, every step, hot
925
+ * pink; no taper or fade (Drei's `attenuation` and `meshline` options).
926
+ */
927
+ export const trail = (options: Partial<Trail> = {}): Trail => ({
928
+ width: options.width ?? 0.2,
929
+ length: options.length ?? 1,
930
+ decay: options.decay ?? 1,
931
+ local: options.local ?? false,
932
+ stride: options.stride ?? 0,
933
+ interval: options.interval ?? 1,
934
+ color: options.color ?? 0xff69b4,
935
+ taper: options.taper ?? 'none',
936
+ fade: options.fade ?? false,
937
+ });
938
+
939
+ /**
940
+ * Lines along a mesh's hard edges, as Drei's `Edges` (`bf6f4ad`, `src/core/Edges.tsx`),
941
+ * with its defaults: faces meeting at more than 15 degrees, a white line 1 pixel wide
942
+ * (Drei's `Line` defaults). `mesh({ edges: edges({ color: 0x000000 }) })`.
943
+ */
944
+ export const edges = (options: Partial<Edges> = {}): Edges => ({
945
+ threshold: options.threshold ?? 15,
946
+ color: options.color ?? 0xffffff,
947
+ width: options.width ?? 1,
948
+ worldUnits: options.worldUnits ?? false,
949
+ dashed: options.dashed ?? false,
950
+ dashSize: options.dashSize ?? 1,
951
+ gapSize: options.gapSize ?? 1,
952
+ opacity: options.opacity ?? 1,
953
+ transparent: options.transparent ?? false,
565
954
  });
566
955
 
567
956
  /**
@@ -859,6 +1248,8 @@ export const borrowed = (
859
1248
  readonly interactive?: boolean;
860
1249
  readonly animation?: Animation | null;
861
1250
  readonly body?: Body | null;
1251
+ readonly castShadow?: boolean;
1252
+ readonly receiveShadow?: boolean;
862
1253
  },
863
1254
  ): BorrowedNode => ({
864
1255
  _tag: 'Borrowed',
@@ -872,6 +1263,8 @@ export const borrowed = (
872
1263
  behaviour: options.behaviour ?? null,
873
1264
  interactive: options.interactive ?? false,
874
1265
  body: options.body ?? null,
1266
+ castShadow: options.castShadow ?? false,
1267
+ receiveShadow: options.receiveShadow ?? false,
875
1268
  });
876
1269
 
877
1270
  /**
@@ -1060,6 +1453,7 @@ export const perspectiveCamera = (
1060
1453
  lookAt: options.lookAt ?? origin,
1061
1454
  damping: options.damping ?? 0,
1062
1455
  autoRotate: options.autoRotate ?? 0,
1456
+ shake: options.shake ?? null,
1063
1457
  });
1064
1458
 
1065
1459
  /**
@@ -1077,6 +1471,26 @@ export const orthographicCamera = (
1077
1471
  lookAt: options.lookAt ?? origin,
1078
1472
  damping: options.damping ?? 0,
1079
1473
  autoRotate: options.autoRotate ?? 0,
1474
+ shake: options.shake ?? null,
1475
+ });
1476
+
1477
+ /**
1478
+ * Camera shake, as Drei's `CameraShake` (`bf6f4ad`, `src/core/CameraShake.tsx`), with its
1479
+ * defaults: intensity 1, no decay (rate 0.65 a second when on), and 0.1 radians at 0.1 for
1480
+ * each of yaw, pitch and roll. `perspectiveCamera({ shake: cameraShake({ decay: true }) })`
1481
+ * shakes once and settles; bump `trigger` to shake again.
1482
+ */
1483
+ export const cameraShake = (options: Partial<CameraShake> = {}): CameraShake => ({
1484
+ intensity: options.intensity ?? 1,
1485
+ decay: options.decay ?? false,
1486
+ decayRate: options.decayRate ?? 0.65,
1487
+ maxYaw: options.maxYaw ?? 0.1,
1488
+ maxPitch: options.maxPitch ?? 0.1,
1489
+ maxRoll: options.maxRoll ?? 0.1,
1490
+ yawFrequency: options.yawFrequency ?? 0.1,
1491
+ pitchFrequency: options.pitchFrequency ?? 0.1,
1492
+ rollFrequency: options.rollFrequency ?? 0.1,
1493
+ trigger: options.trigger ?? 0,
1080
1494
  });
1081
1495
 
1082
1496
  const noEffects: ReadonlyArray<PostEffect> = [];
@@ -1288,11 +1702,26 @@ export const overlay = (options: {
1288
1702
  view: options.view ?? null,
1289
1703
  });
1290
1704
 
1705
+ /**
1706
+ * Nodes drawn over the scene (Drei's `Hud`), with Drei's `GizmoHelper` defaults: the
1707
+ * origin 80 CSS pixels in from the bottom right.
1708
+ */
1709
+ export const hud = (options: {
1710
+ readonly alignment?: HudAlignment;
1711
+ readonly margin?: readonly [number, number];
1712
+ readonly children?: ReadonlyArray<SceneNode>;
1713
+ }): Hud => ({
1714
+ alignment: options.alignment ?? 'bottom-right',
1715
+ margin: options.margin ?? [80, 80],
1716
+ children: options.children ?? [],
1717
+ });
1718
+
1291
1719
  export const scene = (options: {
1292
1720
  readonly camera?: Camera;
1293
1721
  readonly gestures?: Gestures;
1294
1722
  readonly views?: ReadonlyArray<View>;
1295
1723
  readonly renderTextures?: ReadonlyArray<RenderTexture>;
1724
+ readonly hud?: Hud | null;
1296
1725
  readonly overlays?: ReadonlyArray<Overlay>;
1297
1726
  /** Optional staging: leave out or pass `null` for none, so a Model can switch it off. */
1298
1727
  readonly environment?: Environment | null;
@@ -1309,6 +1738,7 @@ export const scene = (options: {
1309
1738
  gestures: options.gestures ?? 'page',
1310
1739
  views: options.views ?? noViews,
1311
1740
  renderTextures: options.renderTextures ?? noRenderTextures,
1741
+ hud: options.hud ?? null,
1312
1742
  overlays: options.overlays ?? noOverlays,
1313
1743
  environment: options.environment ?? null,
1314
1744
  sky: options.sky ?? null,
@@ -94,6 +94,8 @@ export type CameraMotion = {
94
94
  readonly hold: (held: boolean) => void;
95
95
  /** Where turning has taken the camera, when it has turned since its description. */
96
96
  readonly turned: () => Vec3 | undefined;
97
+ /** The point the camera looks at now, on its way to the aim. Not to be changed. */
98
+ readonly target: () => Vector3;
97
99
  };
98
100
 
99
101
  /** Radians a turning camera turns per second per unit of `autoRotate` (three's `2π / 60`). */
@@ -198,5 +200,6 @@ export const createCameraMotion = (): CameraMotion => {
198
200
  position.setFromSpherical(goal.spherical).add(goal.target);
199
201
  return [position.x, position.y, position.z];
200
202
  },
203
+ target: () => current.target,
201
204
  };
202
205
  };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Camera shake, as Drei's `CameraShake` (`bf6f4ad`, `src/core/CameraShake.tsx`): yaw, pitch
3
+ * and roll added to the camera's rotation from simplex noise, scaled by the square of the
4
+ * intensity. Here it is a pure function of scene time: the noise is seeded (Drei seeds
5
+ * from `Math.random`), and decay counts down from when the shake began, so the same scene
6
+ * time always shakes the same way, at any frame rate. Reduced motion holds it still.
7
+ *
8
+ * The camera always looks at its target here, so each frame it is aimed there again and
9
+ * the shake added on top: whatever moved the camera in between, nothing accumulates.
10
+ *
11
+ * @module
12
+ */
13
+ import type { OrthographicCamera, PerspectiveCamera, Vector3 } from 'three';
14
+ import { SimplexNoise } from 'three/addons/math/SimplexNoise.js';
15
+
16
+ import type { CameraShake } from './description.ts';
17
+ import { cameraShakeEquals } from './equality.ts';
18
+ import { seededRandom } from './seeded.ts';
19
+
20
+ /** Each axis's noise, seeded so the shake is the same on every page load. */
21
+ const seeded = (seed: number) => ({ random: seededRandom(seed) });
22
+
23
+ const yawNoise = new SimplexNoise(seeded(1));
24
+ const pitchNoise = new SimplexNoise(seeded(2));
25
+ const rollNoise = new SimplexNoise(seeded(3));
26
+
27
+ /** The intensity at `seconds` after the shake began: Drei's, clamped to [0, 1], decaying. */
28
+ export const shakeIntensity = (shake: CameraShake, seconds: number): number => {
29
+ const decayed = shake.decay ? shake.intensity - shake.decayRate * seconds : shake.intensity;
30
+ return Math.min(1, Math.max(0, decayed));
31
+ };
32
+
33
+ export type CameraShaker = {
34
+ /**
35
+ * The camera's shake as described at scene time `now`: one that first appears, changes
36
+ * or is triggered again begins then.
37
+ */
38
+ readonly describe: (shake: CameraShake | null, now: number) => void;
39
+ /**
40
+ * Aims the camera at `target` and adds the shake at scene time `now`, or only aims it
41
+ * when the shake has ended or motion is reduced. Does nothing when nothing was added.
42
+ */
43
+ readonly apply: (
44
+ camera: PerspectiveCamera | OrthographicCamera,
45
+ target: Vector3,
46
+ now: number,
47
+ animating: boolean,
48
+ ) => void;
49
+ /** Whether the camera is shaking at scene time `now`, and so needs frames. */
50
+ readonly shaking: (now: number) => boolean;
51
+ };
52
+
53
+ /** One camera's shake. Allocation-free once created. */
54
+ export const createCameraShaker = (): CameraShaker => {
55
+ let current: CameraShake | null = null;
56
+ let since = 0;
57
+ // Whether the camera wears an offset that a still frame must take off again.
58
+ let offset = false;
59
+
60
+ return {
61
+ describe: (shake, now) => {
62
+ if (shake !== null && (current === null || !cameraShakeEquals(current, shake))) since = now;
63
+ current = shake;
64
+ },
65
+ apply: (camera, target, now, animating) => {
66
+ const shake = current;
67
+ const strength =
68
+ shake === null || !animating ? 0 : shakeIntensity(shake, Math.max(0, now - since)) ** 2;
69
+ if (shake === null || strength === 0) {
70
+ if (offset) camera.lookAt(target);
71
+ offset = false;
72
+ return;
73
+ }
74
+ camera.lookAt(target);
75
+ camera.rotation.set(
76
+ camera.rotation.x +
77
+ shake.maxPitch * strength * pitchNoise.noise(now * shake.pitchFrequency, 1),
78
+ camera.rotation.y + shake.maxYaw * strength * yawNoise.noise(now * shake.yawFrequency, 1),
79
+ camera.rotation.z +
80
+ shake.maxRoll * strength * rollNoise.noise(now * shake.rollFrequency, 1),
81
+ );
82
+ offset = true;
83
+ },
84
+ shaking: (now) =>
85
+ offset || (current !== null && shakeIntensity(current, Math.max(0, now - since)) > 0),
86
+ };
87
+ };