@opsydyn/effect-three 0.3.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 (125) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +12 -0
  3. package/dist/{index-8cf8t6by.js → index-h3vf50fn.js} +2113 -83
  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 +488 -147
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/boxSelect.d.ts +22 -0
  10. package/dist/kernel/boxSelect.d.ts.map +1 -0
  11. package/dist/kernel/build.d.ts +142 -6
  12. package/dist/kernel/build.d.ts.map +1 -1
  13. package/dist/kernel/cameraShake.d.ts.map +1 -1
  14. package/dist/kernel/describeScene.d.ts +50 -0
  15. package/dist/kernel/describeScene.d.ts.map +1 -0
  16. package/dist/kernel/description.d.ts +155 -1
  17. package/dist/kernel/description.d.ts.map +1 -1
  18. package/dist/kernel/diff.d.ts +15 -0
  19. package/dist/kernel/diff.d.ts.map +1 -1
  20. package/dist/kernel/equality.d.ts +9 -1
  21. package/dist/kernel/equality.d.ts.map +1 -1
  22. package/dist/kernel/gizmo.d.ts +69 -0
  23. package/dist/kernel/gizmo.d.ts.map +1 -0
  24. package/dist/kernel/history.d.ts +61 -0
  25. package/dist/kernel/history.d.ts.map +1 -0
  26. package/dist/kernel/index.d.ts +13 -2
  27. package/dist/kernel/index.d.ts.map +1 -1
  28. package/dist/kernel/index.js +106 -3
  29. package/dist/kernel/index.js.map +1 -1
  30. package/dist/kernel/inspect.d.ts +85 -0
  31. package/dist/kernel/inspect.d.ts.map +1 -0
  32. package/dist/kernel/lineGeometry.d.ts +25 -0
  33. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  34. package/dist/kernel/lockstep.d.ts +114 -0
  35. package/dist/kernel/lockstep.d.ts.map +1 -0
  36. package/dist/kernel/loop.d.ts.map +1 -1
  37. package/dist/kernel/materials.d.ts.map +1 -1
  38. package/dist/kernel/pivot.d.ts +54 -0
  39. package/dist/kernel/pivot.d.ts.map +1 -0
  40. package/dist/kernel/pointBuffers.d.ts +17 -0
  41. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  42. package/dist/kernel/pointer.d.ts +12 -0
  43. package/dist/kernel/pointer.d.ts.map +1 -1
  44. package/dist/kernel/renderKit.d.ts +21 -1
  45. package/dist/kernel/renderKit.d.ts.map +1 -1
  46. package/dist/kernel/renderer.d.ts +6 -0
  47. package/dist/kernel/renderer.d.ts.map +1 -1
  48. package/dist/kernel/sceneChanges.d.ts +87 -0
  49. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  50. package/dist/kernel/sceneGraph.d.ts +6 -0
  51. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  52. package/dist/kernel/seeded.d.ts +9 -0
  53. package/dist/kernel/seeded.d.ts.map +1 -0
  54. package/dist/kernel/shots.d.ts +26 -0
  55. package/dist/kernel/shots.d.ts.map +1 -0
  56. package/dist/kernel/tape.d.ts +38 -0
  57. package/dist/kernel/tape.d.ts.map +1 -0
  58. package/dist/kernel/trail.d.ts +31 -0
  59. package/dist/kernel/trail.d.ts.map +1 -0
  60. package/dist/kernel/viewportGizmo.d.ts +25 -0
  61. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  62. package/dist/runtime/Inspector.d.ts +20 -0
  63. package/dist/runtime/Inspector.d.ts.map +1 -0
  64. package/dist/runtime/SceneFile.d.ts +448 -0
  65. package/dist/runtime/SceneFile.d.ts.map +1 -0
  66. package/dist/runtime/SceneSchema.d.ts +318 -0
  67. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  68. package/dist/runtime/errors.d.ts +9 -0
  69. package/dist/runtime/errors.d.ts.map +1 -1
  70. package/dist/runtime/index.d.ts +3 -1
  71. package/dist/runtime/index.d.ts.map +1 -1
  72. package/dist/webgl/index.d.ts.map +1 -1
  73. package/dist/webgl/index.js +228 -3
  74. package/dist/webgl/index.js.map +6 -4
  75. package/dist/webgl/line.d.ts +4 -0
  76. package/dist/webgl/line.d.ts.map +1 -0
  77. package/dist/webgl/points.d.ts +4 -0
  78. package/dist/webgl/points.d.ts.map +1 -0
  79. package/dist/webgpu/index.d.ts.map +1 -1
  80. package/dist/webgpu/index.js +206 -26
  81. package/dist/webgpu/index.js.map +6 -4
  82. package/dist/webgpu/line.d.ts +4 -0
  83. package/dist/webgpu/line.d.ts.map +1 -0
  84. package/dist/webgpu/points.d.ts +4 -0
  85. package/dist/webgpu/points.d.ts.map +1 -0
  86. package/package.json +2 -1
  87. package/src/kernel/boxSelect.ts +73 -0
  88. package/src/kernel/build.ts +332 -0
  89. package/src/kernel/cameraShake.ts +3 -13
  90. package/src/kernel/describeScene.ts +224 -0
  91. package/src/kernel/description.ts +175 -0
  92. package/src/kernel/diff.ts +39 -0
  93. package/src/kernel/equality.ts +101 -0
  94. package/src/kernel/gizmo.ts +478 -0
  95. package/src/kernel/history.ts +118 -0
  96. package/src/kernel/index.ts +97 -0
  97. package/src/kernel/inspect.ts +214 -0
  98. package/src/kernel/lineGeometry.ts +75 -0
  99. package/src/kernel/lockstep.ts +237 -0
  100. package/src/kernel/loop.ts +5 -0
  101. package/src/kernel/materials.ts +3 -0
  102. package/src/kernel/pivot.ts +418 -0
  103. package/src/kernel/pointBuffers.ts +115 -0
  104. package/src/kernel/pointer.ts +55 -12
  105. package/src/kernel/renderKit.ts +23 -1
  106. package/src/kernel/renderer.ts +6 -0
  107. package/src/kernel/sceneChanges.ts +265 -0
  108. package/src/kernel/sceneGraph.ts +319 -8
  109. package/src/kernel/seeded.ts +18 -0
  110. package/src/kernel/shots.ts +167 -0
  111. package/src/kernel/tape.ts +124 -0
  112. package/src/kernel/trail.ts +170 -0
  113. package/src/kernel/viewportGizmo.ts +194 -0
  114. package/src/runtime/Inspector.ts +43 -0
  115. package/src/runtime/SceneFile.ts +46 -0
  116. package/src/runtime/SceneSchema.ts +247 -0
  117. package/src/runtime/errors.ts +6 -0
  118. package/src/runtime/index.ts +3 -1
  119. package/src/webgl/index.ts +4 -0
  120. package/src/webgl/line.ts +66 -0
  121. package/src/webgl/points.ts +205 -0
  122. package/src/webgpu/index.ts +4 -0
  123. package/src/webgpu/line.ts +67 -0
  124. package/src/webgpu/points.ts +193 -0
  125. package/dist/index-8cf8t6by.js.map +0 -41
@@ -3,8 +3,15 @@
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,
9
16
  CameraShake,
10
17
  Detail,
@@ -18,6 +25,8 @@ import type {
18
25
  ContactShadows,
19
26
  Environment,
20
27
  Gestures,
28
+ Hud,
29
+ HudAlignment,
21
30
  GridNode,
22
31
  Sky,
23
32
  Xr,
@@ -344,6 +353,7 @@ export const basic = (options: Partial<Omit<BasicMaterial, '_tag'>> = {}): Basic
344
353
  transparent: options.transparent ?? false,
345
354
  side: options.side ?? 'front',
346
355
  wireframe: options.wireframe ?? false,
356
+ depthTest: options.depthTest ?? true,
347
357
  });
348
358
 
349
359
  /**
@@ -539,6 +549,7 @@ export const group = (
539
549
  readonly body?: Body | null;
540
550
  readonly follow?: XrSpace | null;
541
551
  readonly detail?: Detail | null;
552
+ readonly trail?: Trail | null;
542
553
  },
543
554
  ): GroupNode => ({
544
555
  _tag: 'Group',
@@ -553,6 +564,7 @@ export const group = (
553
564
  body: options.body ?? null,
554
565
  follow: options.follow ?? null,
555
566
  detail: options.detail ?? null,
567
+ trail: options.trail ?? null,
556
568
  });
557
569
 
558
570
  /**
@@ -566,6 +578,272 @@ export const detailed = (options: {
566
578
  readonly hysteresis?: number;
567
579
  }): Detail => ({ distances: options.distances, hysteresis: options.hysteresis ?? 0 });
568
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,
625
+ });
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
+
569
847
  /** The head (the viewer), for content that stays in view. */
570
848
  export const headSpace = (): XrSpace => ({ _tag: 'Head' });
571
849
 
@@ -620,6 +898,8 @@ export const mesh = (
620
898
  readonly castShadow?: boolean;
621
899
  readonly receiveShadow?: boolean;
622
900
  readonly body?: Body | null;
901
+ readonly edges?: Edges | null;
902
+ readonly trail?: Trail | null;
623
903
  },
624
904
  ): MeshNode => ({
625
905
  _tag: 'Mesh',
@@ -635,6 +915,42 @@ export const mesh = (
635
915
  castShadow: options.castShadow ?? false,
636
916
  receiveShadow: options.receiveShadow ?? false,
637
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,
638
954
  });
639
955
 
640
956
  /**
@@ -1386,11 +1702,26 @@ export const overlay = (options: {
1386
1702
  view: options.view ?? null,
1387
1703
  });
1388
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
+
1389
1719
  export const scene = (options: {
1390
1720
  readonly camera?: Camera;
1391
1721
  readonly gestures?: Gestures;
1392
1722
  readonly views?: ReadonlyArray<View>;
1393
1723
  readonly renderTextures?: ReadonlyArray<RenderTexture>;
1724
+ readonly hud?: Hud | null;
1394
1725
  readonly overlays?: ReadonlyArray<Overlay>;
1395
1726
  /** Optional staging: leave out or pass `null` for none, so a Model can switch it off. */
1396
1727
  readonly environment?: Environment | null;
@@ -1407,6 +1738,7 @@ export const scene = (options: {
1407
1738
  gestures: options.gestures ?? 'page',
1408
1739
  views: options.views ?? noViews,
1409
1740
  renderTextures: options.renderTextures ?? noRenderTextures,
1741
+ hud: options.hud ?? null,
1410
1742
  overlays: options.overlays ?? noOverlays,
1411
1743
  environment: options.environment ?? null,
1412
1744
  sky: options.sky ?? null,
@@ -15,20 +15,10 @@ import { SimplexNoise } from 'three/addons/math/SimplexNoise.js';
15
15
 
16
16
  import type { CameraShake } from './description.ts';
17
17
  import { cameraShakeEquals } from './equality.ts';
18
+ import { seededRandom } from './seeded.ts';
18
19
 
19
- /** A small seeded generator (mulberry32), so the noise is the same on every page load. */
20
- const seeded = (seed: number): { readonly random: () => number } => {
21
- let state = seed >>> 0;
22
- return {
23
- random: () => {
24
- state = (state + 0x6d2b79f5) >>> 0;
25
- let t = state;
26
- t = Math.imul(t ^ (t >>> 15), t | 1);
27
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
28
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
29
- },
30
- };
31
- };
20
+ /** Each axis's noise, seeded so the shake is the same on every page load. */
21
+ const seeded = (seed: number) => ({ random: seededRandom(seed) });
32
22
 
33
23
  const yawNoise = new SimplexNoise(seeded(1));
34
24
  const pitchNoise = new SimplexNoise(seeded(2));
@@ -0,0 +1,224 @@
1
+ /**
2
+ * What a scene contains, for an assistant (roadmap, "Beyond parity" 3): its camera, its
3
+ * things with their kind, size, colour and place, its lights, the box they fill and its
4
+ * staging, as data (`describeScene`) and as a few lines of text (`formatSummary`) an
5
+ * agent reads before it acts. An agent that reads the Model can describe a scene
6
+ * without three.js, so sizes come from the geometry's parameters and the node's scale:
7
+ * close enough to place things beside one another and to frame a shot.
8
+ *
9
+ * @module
10
+ */
11
+ import type {
12
+ GeometryRef,
13
+ Rgb,
14
+ SceneDescription,
15
+ SceneNode,
16
+ ShapeGeometry,
17
+ Vec3,
18
+ } from './description.ts';
19
+ import type { Box } from './orbit.ts';
20
+
21
+ export type ThingSummary = {
22
+ readonly key: string;
23
+ /** The node's kind, with its geometry's for a mesh: `Mesh (Box)`. */
24
+ readonly kind: string;
25
+ /** In its parent's space; for things at the top, the world's. */
26
+ readonly position: Vec3;
27
+ /** Width, height and depth, from the geometry and scale; `null` when not known. */
28
+ readonly size: Vec3 | null;
29
+ readonly colour: Rgb | null;
30
+ /** The group it is in, `null` at the top. */
31
+ readonly parent: string | null;
32
+ };
33
+
34
+ export type LightSummary = {
35
+ readonly key: string;
36
+ readonly kind: string;
37
+ readonly colour: Rgb;
38
+ readonly intensity: number;
39
+ readonly position: Vec3 | null;
40
+ };
41
+
42
+ export type SceneSummary = {
43
+ readonly camera: {
44
+ readonly kind: 'Perspective' | 'Orthographic';
45
+ readonly position: Vec3;
46
+ readonly lookAt: Vec3;
47
+ };
48
+ readonly things: ReadonlyArray<ThingSummary>;
49
+ readonly lights: ReadonlyArray<LightSummary>;
50
+ /** The box the top-level things of known size fill, `null` with none. */
51
+ readonly bounds: Box | null;
52
+ readonly staging: ReadonlyArray<string>;
53
+ };
54
+
55
+ /** Width, height and depth of a shape, unscaled; `undefined` for text and shapes. */
56
+ const extentOf = (geometry: ShapeGeometry): Vec3 | undefined => {
57
+ switch (geometry._tag) {
58
+ case 'Box':
59
+ case 'RoundedBox':
60
+ return [geometry.width, geometry.height, geometry.depth];
61
+ case 'Sphere':
62
+ return [geometry.radius * 2, geometry.radius * 2, geometry.radius * 2];
63
+ case 'Cone':
64
+ return [geometry.radius * 2, geometry.height, geometry.radius * 2];
65
+ case 'Cylinder': {
66
+ const across = Math.max(geometry.radiusTop, geometry.radiusBottom) * 2;
67
+ return [across, geometry.height, across];
68
+ }
69
+ case 'Capsule':
70
+ return [geometry.radius * 2, geometry.height + geometry.radius * 2, geometry.radius * 2];
71
+ case 'Torus': {
72
+ const across = (geometry.radius + geometry.tube) * 2;
73
+ return [across, across, geometry.tube * 2];
74
+ }
75
+ case 'Plane':
76
+ return [geometry.width, geometry.height, 0];
77
+ case 'Circle':
78
+ return [geometry.radius * 2, geometry.radius * 2, 0];
79
+ case 'Ring':
80
+ return [geometry.outerRadius * 2, geometry.outerRadius * 2, 0];
81
+ default:
82
+ return undefined;
83
+ }
84
+ };
85
+
86
+ const scaled = (extent: Vec3, scale: Vec3): Vec3 => [
87
+ extent[0] * Math.abs(scale[0]),
88
+ extent[1] * Math.abs(scale[1]),
89
+ extent[2] * Math.abs(scale[2]),
90
+ ];
91
+
92
+ /** The shape a geometry reference draws: its own, a shared one's, or none (lent). */
93
+ const shapeOf = (
94
+ geometry: GeometryRef,
95
+ description: SceneDescription,
96
+ ): ShapeGeometry | undefined => {
97
+ if (geometry._tag === 'LentGeometry') return undefined;
98
+ if (geometry._tag !== 'SharedGeometry') return geometry;
99
+ return description.resources.geometries.find((entry) => entry.id === geometry.id)?.geometry;
100
+ };
101
+
102
+ const sizeOf = (node: SceneNode, description: SceneDescription): Vec3 | null => {
103
+ if (node._tag !== 'Mesh') return null;
104
+ const shape = shapeOf(node.geometry, description);
105
+ const extent = shape === undefined ? undefined : extentOf(shape);
106
+ return extent === undefined ? null : scaled(extent, node.scale);
107
+ };
108
+
109
+ const colourOf = (node: SceneNode): Rgb | null =>
110
+ node._tag === 'Mesh' && 'color' in node.material ? node.material.color : null;
111
+
112
+ const kindOf = (node: SceneNode): string =>
113
+ node._tag === 'Mesh' ? `Mesh (${node.geometry._tag})` : node._tag;
114
+
115
+ const isLight = (node: SceneNode): boolean => node._tag.endsWith('Light');
116
+
117
+ const lightOf = (node: SceneNode): ReadonlyArray<LightSummary> => {
118
+ if (!isLight(node) || !('color' in node) || !('intensity' in node)) return [];
119
+ return [
120
+ {
121
+ key: node.key,
122
+ kind: node._tag,
123
+ colour: node.color,
124
+ intensity: node.intensity,
125
+ position: 'position' in node ? node.position : null,
126
+ },
127
+ ];
128
+ };
129
+
130
+ const thingsIn = (
131
+ nodes: ReadonlyArray<SceneNode>,
132
+ parent: string | null,
133
+ description: SceneDescription,
134
+ ): ReadonlyArray<ThingSummary> =>
135
+ nodes.flatMap((node) => [
136
+ ...(isLight(node)
137
+ ? []
138
+ : [
139
+ {
140
+ key: node.key,
141
+ kind: kindOf(node),
142
+ position: 'position' in node ? node.position : ([0, 0, 0] as const),
143
+ size: sizeOf(node, description),
144
+ colour: colourOf(node),
145
+ parent,
146
+ },
147
+ ]),
148
+ ...(node._tag === 'Group' ? thingsIn(node.children, node.key, description) : []),
149
+ ]);
150
+
151
+ const lightsIn = (nodes: ReadonlyArray<SceneNode>): ReadonlyArray<LightSummary> =>
152
+ nodes.flatMap((node) => [
153
+ ...lightOf(node),
154
+ ...(node._tag === 'Group' ? lightsIn(node.children) : []),
155
+ ]);
156
+
157
+ /** The box top-level things of known size fill, each taken as a box round its place. */
158
+ export const thingsBounds = (things: ReadonlyArray<ThingSummary>): Box | null => {
159
+ const sized = things.filter((thing) => thing.parent === null && thing.size !== null);
160
+ if (sized.length === 0) return null;
161
+ const edge = (axis: 0 | 1 | 2, sign: 1 | -1): ReadonlyArray<number> =>
162
+ sized.map((thing) => thing.position[axis] + sign * ((thing.size?.[axis] ?? 0) / 2));
163
+ const low = (axis: 0 | 1 | 2) => Math.min(...edge(axis, -1));
164
+ const high = (axis: 0 | 1 | 2) => Math.max(...edge(axis, 1));
165
+ return { min: [low(0), low(1), low(2)], max: [high(0), high(1), high(2)] };
166
+ };
167
+
168
+ const stagingOf = (description: SceneDescription): ReadonlyArray<string> => [
169
+ ...(description.environment === null ? [] : ['environment lighting']),
170
+ ...(description.sky === null ? [] : ['sky']),
171
+ ...(description.render.shadows === 'off' ? [] : [`${description.render.shadows} shadows`]),
172
+ ...(description.contactShadows === null ? [] : ['contact shadows']),
173
+ ...description.render.effects.map((effect) => effect._tag.toLowerCase()),
174
+ ...(description.physics === null ? [] : ['physics']),
175
+ ];
176
+
177
+ /** What `description` contains, for an assistant to read. */
178
+ export const describeScene = (description: SceneDescription): SceneSummary => {
179
+ const things = thingsIn(description.children, null, description);
180
+ return {
181
+ camera: {
182
+ kind: description.camera._tag,
183
+ position: description.camera.position,
184
+ lookAt: description.camera.lookAt,
185
+ },
186
+ things,
187
+ lights: lightsIn(description.children),
188
+ bounds: thingsBounds(things),
189
+ staging: stagingOf(description),
190
+ };
191
+ };
192
+
193
+ const number = (value: number): string => String(Math.round(value * 100) / 100);
194
+ const point = (at: Vec3): string => `(${at.map(number).join(', ')})`;
195
+ const hex = (colour: Rgb): string => `#${colour.toString(16).padStart(6, '0')}`;
196
+
197
+ const thingLine = (thing: ThingSummary): string =>
198
+ [
199
+ `- ${thing.key}: ${thing.kind}`,
200
+ thing.size === null ? '' : `, ${thing.size.map(number).join(' × ')}`,
201
+ thing.colour === null ? '' : `, ${hex(thing.colour)}`,
202
+ `, at ${point(thing.position)}`,
203
+ thing.parent === null ? '' : ` in ${thing.parent}`,
204
+ ].join('');
205
+
206
+ const lightLine = (light: LightSummary): string =>
207
+ `- ${light.key}: ${light.kind}, ${hex(light.colour)} × ${number(light.intensity)}${
208
+ light.position === null ? '' : ` at ${point(light.position)}`
209
+ }`;
210
+
211
+ /** The summary as a few lines of text, for a prompt or a tool's answer. */
212
+ export const formatSummary = (summary: SceneSummary): string =>
213
+ [
214
+ `Camera: ${summary.camera.kind.toLowerCase()} at ${point(summary.camera.position)}, looking at ${point(summary.camera.lookAt)}`,
215
+ `Things (${summary.things.length})${
216
+ summary.bounds === null
217
+ ? ''
218
+ : `, within ${point(summary.bounds.min)} to ${point(summary.bounds.max)}`
219
+ }:`,
220
+ ...summary.things.map(thingLine),
221
+ `Lights (${summary.lights.length}):`,
222
+ ...summary.lights.map(lightLine),
223
+ `Staging: ${summary.staging.length === 0 ? 'none' : summary.staging.join(', ')}`,
224
+ ].join('\n');