@opsydyn/effect-three 0.2.0 → 0.3.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 (44) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/README.md +2 -1
  3. package/dist/{index-01kwfekm.js → index-8cf8t6by.js} +327 -24
  4. package/dist/index-8cf8t6by.js.map +41 -0
  5. package/dist/index.js +61 -8
  6. package/dist/index.js.map +3 -3
  7. package/dist/kernel/behaviours.d.ts.map +1 -1
  8. package/dist/kernel/build.d.ts +56 -1
  9. package/dist/kernel/build.d.ts.map +1 -1
  10. package/dist/kernel/cameraMotion.d.ts +3 -1
  11. package/dist/kernel/cameraMotion.d.ts.map +1 -1
  12. package/dist/kernel/cameraShake.d.ts +33 -0
  13. package/dist/kernel/cameraShake.d.ts.map +1 -0
  14. package/dist/kernel/description.d.ts +85 -1
  15. package/dist/kernel/description.d.ts.map +1 -1
  16. package/dist/kernel/diff.d.ts +3 -1
  17. package/dist/kernel/diff.d.ts.map +1 -1
  18. package/dist/kernel/equality.d.ts +5 -1
  19. package/dist/kernel/equality.d.ts.map +1 -1
  20. package/dist/kernel/index.d.ts +1 -1
  21. package/dist/kernel/index.d.ts.map +1 -1
  22. package/dist/kernel/index.js +12 -2
  23. package/dist/kernel/index.js.map +1 -1
  24. package/dist/kernel/pointer.d.ts.map +1 -1
  25. package/dist/kernel/resources.d.ts.map +1 -1
  26. package/dist/kernel/sceneGraph.d.ts +0 -1
  27. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  28. package/dist/runtime/SceneSchema.d.ts +296 -5
  29. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/kernel/behaviours.ts +5 -0
  32. package/src/kernel/bodySources.ts +1 -1
  33. package/src/kernel/build.ts +98 -0
  34. package/src/kernel/cameraMotion.ts +3 -0
  35. package/src/kernel/cameraShake.ts +97 -0
  36. package/src/kernel/description.ts +91 -1
  37. package/src/kernel/diff.ts +7 -1
  38. package/src/kernel/equality.ts +47 -0
  39. package/src/kernel/index.ts +5 -0
  40. package/src/kernel/pointer.ts +8 -0
  41. package/src/kernel/resources.ts +69 -0
  42. package/src/kernel/sceneGraph.ts +142 -1
  43. package/src/runtime/SceneSchema.ts +58 -4
  44. package/dist/index-01kwfekm.js.map +0 -40
@@ -6,6 +6,8 @@
6
6
  import type {
7
7
  AmbientLightNode,
8
8
  Animation,
9
+ CameraShake,
10
+ Detail,
9
11
  BasicMaterial,
10
12
  Body,
11
13
  Behave,
@@ -40,6 +42,8 @@ import type {
40
42
  ColliderDescription,
41
43
  ColliderShapeDescription,
42
44
  CapsuleGeometry,
45
+ RoundedBoxGeometry,
46
+ BackdropGeometry,
43
47
  CircleGeometry,
44
48
  ConeGeometry,
45
49
  CylinderGeometry,
@@ -106,6 +110,21 @@ export const spin = (options: {
106
110
  phase: options.phase ?? 0,
107
111
  });
108
112
 
113
+ /**
114
+ * Faces the camera every frame, as Drei's `Billboard` (`bf6f4ad`, `src/core/Billboard.tsx`):
115
+ * for labels, markers and sprites that should always read face on. Lock an axis to keep
116
+ * it still, `lockX` and `lockZ` together for something that stays upright and only turns
117
+ * round. To rotate it as well, put it in a rotated group.
118
+ */
119
+ export const billboard = (
120
+ options: { readonly lockX?: boolean; readonly lockY?: boolean; readonly lockZ?: boolean } = {},
121
+ ): Rotation => ({
122
+ _tag: 'Billboard',
123
+ lockX: options.lockX ?? false,
124
+ lockY: options.lockY ?? false,
125
+ lockZ: options.lockZ ?? false,
126
+ });
127
+
109
128
  /** Box geometry. Each dimension defaults to 1. */
110
129
  export const box = (
111
130
  size: { readonly width?: number; readonly height?: number; readonly depth?: number } = {},
@@ -197,6 +216,47 @@ export const capsule = (
197
216
  radialSegments: options.radialSegments ?? 8,
198
217
  });
199
218
 
219
+ /**
220
+ * A box with rounded edges and corners, as Drei's `RoundedBox` (`bf6f4ad`,
221
+ * `src/core/RoundedBox.tsx`), with its defaults: 1 × 1 × 1, radius 0.05, smoothness 4,
222
+ * 4 bevel segments, 1 step, crease angle 0.4.
223
+ */
224
+ export const roundedBox = (
225
+ options: {
226
+ readonly width?: number;
227
+ readonly height?: number;
228
+ readonly depth?: number;
229
+ readonly radius?: number;
230
+ readonly smoothness?: number;
231
+ readonly bevelSegments?: number;
232
+ readonly steps?: number;
233
+ readonly creaseAngle?: number;
234
+ } = {},
235
+ ): RoundedBoxGeometry => ({
236
+ _tag: 'RoundedBox',
237
+ width: options.width ?? 1,
238
+ height: options.height ?? 1,
239
+ depth: options.depth ?? 1,
240
+ radius: options.radius ?? 0.05,
241
+ smoothness: options.smoothness ?? 4,
242
+ bevelSegments: options.bevelSegments ?? 4,
243
+ steps: options.steps ?? 1,
244
+ creaseAngle: options.creaseAngle ?? 0.4,
245
+ });
246
+
247
+ /**
248
+ * A curved photo backdrop, as Drei's `Backdrop` (`bf6f4ad`, `src/core/Backdrop.tsx`), with
249
+ * its defaults: floor 0.25, 20 segments. One unit each way: scale the mesh to size, and
250
+ * give it `receiveShadow` to catch shadows as Drei's prop does.
251
+ */
252
+ export const backdrop = (
253
+ options: { readonly floor?: number; readonly segments?: number } = {},
254
+ ): BackdropGeometry => ({
255
+ _tag: 'Backdrop',
256
+ floor: options.floor ?? 0.25,
257
+ segments: options.segments ?? 20,
258
+ });
259
+
200
260
  /** Ring geometry facing +Z. Defaults (as three.js): radii 0.5 and 1, 32 segments. */
201
261
  export const ring = (
202
262
  options: {
@@ -478,6 +538,7 @@ export const group = (
478
538
  readonly children?: ReadonlyArray<SceneNode>;
479
539
  readonly body?: Body | null;
480
540
  readonly follow?: XrSpace | null;
541
+ readonly detail?: Detail | null;
481
542
  },
482
543
  ): GroupNode => ({
483
544
  _tag: 'Group',
@@ -491,8 +552,20 @@ export const group = (
491
552
  children: options.children ?? [],
492
553
  body: options.body ?? null,
493
554
  follow: options.follow ?? null,
555
+ detail: options.detail ?? null,
494
556
  });
495
557
 
558
+ /**
559
+ * Levels of detail for a group, as Drei's `Detailed` (`bf6f4ad`, `src/core/Detailed.tsx`):
560
+ * `group({ detail: detailed({ distances: [0, 10, 20] }), children: [near, middle, far] })`
561
+ * shows `near` until the camera is 10 away, then `middle`, then `far` from 20. Hysteresis
562
+ * defaults to 0, as Drei's.
563
+ */
564
+ export const detailed = (options: {
565
+ readonly distances: ReadonlyArray<number>;
566
+ readonly hysteresis?: number;
567
+ }): Detail => ({ distances: options.distances, hysteresis: options.hysteresis ?? 0 });
568
+
496
569
  /** The head (the viewer), for content that stays in view. */
497
570
  export const headSpace = (): XrSpace => ({ _tag: 'Head' });
498
571
 
@@ -859,6 +932,8 @@ export const borrowed = (
859
932
  readonly interactive?: boolean;
860
933
  readonly animation?: Animation | null;
861
934
  readonly body?: Body | null;
935
+ readonly castShadow?: boolean;
936
+ readonly receiveShadow?: boolean;
862
937
  },
863
938
  ): BorrowedNode => ({
864
939
  _tag: 'Borrowed',
@@ -872,6 +947,8 @@ export const borrowed = (
872
947
  behaviour: options.behaviour ?? null,
873
948
  interactive: options.interactive ?? false,
874
949
  body: options.body ?? null,
950
+ castShadow: options.castShadow ?? false,
951
+ receiveShadow: options.receiveShadow ?? false,
875
952
  });
876
953
 
877
954
  /**
@@ -1060,6 +1137,7 @@ export const perspectiveCamera = (
1060
1137
  lookAt: options.lookAt ?? origin,
1061
1138
  damping: options.damping ?? 0,
1062
1139
  autoRotate: options.autoRotate ?? 0,
1140
+ shake: options.shake ?? null,
1063
1141
  });
1064
1142
 
1065
1143
  /**
@@ -1077,6 +1155,26 @@ export const orthographicCamera = (
1077
1155
  lookAt: options.lookAt ?? origin,
1078
1156
  damping: options.damping ?? 0,
1079
1157
  autoRotate: options.autoRotate ?? 0,
1158
+ shake: options.shake ?? null,
1159
+ });
1160
+
1161
+ /**
1162
+ * Camera shake, as Drei's `CameraShake` (`bf6f4ad`, `src/core/CameraShake.tsx`), with its
1163
+ * defaults: intensity 1, no decay (rate 0.65 a second when on), and 0.1 radians at 0.1 for
1164
+ * each of yaw, pitch and roll. `perspectiveCamera({ shake: cameraShake({ decay: true }) })`
1165
+ * shakes once and settles; bump `trigger` to shake again.
1166
+ */
1167
+ export const cameraShake = (options: Partial<CameraShake> = {}): CameraShake => ({
1168
+ intensity: options.intensity ?? 1,
1169
+ decay: options.decay ?? false,
1170
+ decayRate: options.decayRate ?? 0.65,
1171
+ maxYaw: options.maxYaw ?? 0.1,
1172
+ maxPitch: options.maxPitch ?? 0.1,
1173
+ maxRoll: options.maxRoll ?? 0.1,
1174
+ yawFrequency: options.yawFrequency ?? 0.1,
1175
+ pitchFrequency: options.pitchFrequency ?? 0.1,
1176
+ rollFrequency: options.rollFrequency ?? 0.1,
1177
+ trigger: options.trigger ?? 0,
1080
1178
  });
1081
1179
 
1082
1180
  const noEffects: ReadonlyArray<PostEffect> = [];
@@ -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,97 @@
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
+
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
+ };
32
+
33
+ const yawNoise = new SimplexNoise(seeded(1));
34
+ const pitchNoise = new SimplexNoise(seeded(2));
35
+ const rollNoise = new SimplexNoise(seeded(3));
36
+
37
+ /** The intensity at `seconds` after the shake began: Drei's, clamped to [0, 1], decaying. */
38
+ export const shakeIntensity = (shake: CameraShake, seconds: number): number => {
39
+ const decayed = shake.decay ? shake.intensity - shake.decayRate * seconds : shake.intensity;
40
+ return Math.min(1, Math.max(0, decayed));
41
+ };
42
+
43
+ export type CameraShaker = {
44
+ /**
45
+ * The camera's shake as described at scene time `now`: one that first appears, changes
46
+ * or is triggered again begins then.
47
+ */
48
+ readonly describe: (shake: CameraShake | null, now: number) => void;
49
+ /**
50
+ * Aims the camera at `target` and adds the shake at scene time `now`, or only aims it
51
+ * when the shake has ended or motion is reduced. Does nothing when nothing was added.
52
+ */
53
+ readonly apply: (
54
+ camera: PerspectiveCamera | OrthographicCamera,
55
+ target: Vector3,
56
+ now: number,
57
+ animating: boolean,
58
+ ) => void;
59
+ /** Whether the camera is shaking at scene time `now`, and so needs frames. */
60
+ readonly shaking: (now: number) => boolean;
61
+ };
62
+
63
+ /** One camera's shake. Allocation-free once created. */
64
+ export const createCameraShaker = (): CameraShaker => {
65
+ let current: CameraShake | null = null;
66
+ let since = 0;
67
+ // Whether the camera wears an offset that a still frame must take off again.
68
+ let offset = false;
69
+
70
+ return {
71
+ describe: (shake, now) => {
72
+ if (shake !== null && (current === null || !cameraShakeEquals(current, shake))) since = now;
73
+ current = shake;
74
+ },
75
+ apply: (camera, target, now, animating) => {
76
+ const shake = current;
77
+ const strength =
78
+ shake === null || !animating ? 0 : shakeIntensity(shake, Math.max(0, now - since)) ** 2;
79
+ if (shake === null || strength === 0) {
80
+ if (offset) camera.lookAt(target);
81
+ offset = false;
82
+ return;
83
+ }
84
+ camera.lookAt(target);
85
+ camera.rotation.set(
86
+ camera.rotation.x +
87
+ shake.maxPitch * strength * pitchNoise.noise(now * shake.pitchFrequency, 1),
88
+ camera.rotation.y + shake.maxYaw * strength * yawNoise.noise(now * shake.yawFrequency, 1),
89
+ camera.rotation.z +
90
+ shake.maxRoll * strength * rollNoise.noise(now * shake.rollFrequency, 1),
91
+ );
92
+ offset = true;
93
+ },
94
+ shaking: (now) =>
95
+ offset || (current !== null && shakeIntensity(current, Math.max(0, now - since)) > 0),
96
+ };
97
+ };
@@ -30,6 +30,17 @@ export type Rotation =
30
30
  readonly axis: Axis;
31
31
  readonly radiansPerSecond: number;
32
32
  readonly phase: number;
33
+ }
34
+ /**
35
+ * Faces the scene's camera every frame, as Drei's `Billboard`: the node turns so its
36
+ * world rotation is the camera's. A locked axis stays at 0 in the node's own Euler
37
+ * angles (Drei keeps its previous value, which starts at 0).
38
+ */
39
+ | {
40
+ readonly _tag: 'Billboard';
41
+ readonly lockX: boolean;
42
+ readonly lockY: boolean;
43
+ readonly lockZ: boolean;
33
44
  };
34
45
 
35
46
  /** Box geometry parameters. */
@@ -90,6 +101,36 @@ export type CapsuleGeometry = {
90
101
  readonly radialSegments: number;
91
102
  };
92
103
 
104
+ /**
105
+ * A box with rounded edges and corners, centred, as Drei's `RoundedBox`: a rounded
106
+ * rectangle extruded and bevelled, with normals creased at `creaseAngle`.
107
+ */
108
+ export type RoundedBoxGeometry = {
109
+ readonly _tag: 'RoundedBox';
110
+ readonly width: number;
111
+ readonly height: number;
112
+ readonly depth: number;
113
+ readonly radius: number;
114
+ /** Segments in each rounded corner of the outline. */
115
+ readonly smoothness: number;
116
+ /** Segments across the bevel, which Drei doubles. */
117
+ readonly bevelSegments: number;
118
+ readonly steps: number;
119
+ /** Faces meeting at more than this angle (radians) keep a hard edge. */
120
+ readonly creaseAngle: number;
121
+ };
122
+
123
+ /**
124
+ * A photo backdrop, as Drei's `Backdrop`: a floor that sweeps up into a wall behind it,
125
+ * one unit each way (scale it to size). The floor lies along +Z towards the viewer, the
126
+ * wall rises at its back, and `floor` extends the flat front.
127
+ */
128
+ export type BackdropGeometry = {
129
+ readonly _tag: 'Backdrop';
130
+ readonly floor: number;
131
+ readonly segments: number;
132
+ };
133
+
93
134
  /** A flat ring (annulus) in the XY plane, facing +Z. */
94
135
  export type RingGeometry = {
95
136
  readonly _tag: 'Ring';
@@ -150,7 +191,9 @@ export type ShapeGeometry =
150
191
  | ConeGeometry
151
192
  | CapsuleGeometry
152
193
  | RingGeometry
153
- | CircleGeometry;
194
+ | CircleGeometry
195
+ | RoundedBoxGeometry
196
+ | BackdropGeometry;
154
197
 
155
198
  /** Which faces a material draws. */
156
199
  export type MaterialSide = 'front' | 'back' | 'double';
@@ -342,6 +385,22 @@ export type GroupNode = {
342
385
  * not tracked (or no session presents). `null` for the group's own transform.
343
386
  */
344
387
  readonly follow: XrSpace | null;
388
+ /**
389
+ * Levels of detail: the group shows one child at a time, chosen by the camera's
390
+ * distance (Drei's `Detailed`, three's `LOD`), or `null` to show them all.
391
+ */
392
+ readonly detail: Detail | null;
393
+ };
394
+
395
+ /**
396
+ * Levels of detail for a group's children, in order: child `i` shows from
397
+ * `distances[i]` (scene units from the camera, divided by its zoom) until the next level's
398
+ * distance. `hysteresis` (a fraction) keeps a shown level until the camera is that much
399
+ * nearer than its distance, so the switch does not flicker at the boundary.
400
+ */
401
+ export type Detail = {
402
+ readonly distances: ReadonlyArray<number>;
403
+ readonly hysteresis: number;
345
404
  };
346
405
 
347
406
  /** The joints of a tracked hand, as WebXR names them (`XRHandJoint`). */
@@ -656,6 +715,13 @@ export type BorrowedNode = {
656
715
  readonly animation: Animation | null;
657
716
  /** A rigid body whose colliders are the model's meshes, once it is lent; or `null`. */
658
717
  readonly body: Body | null;
718
+ /**
719
+ * Every mesh in what it shows casts (receives) shadows, as Drei's `Clone` shorthands;
720
+ * `false` leaves each mesh's own setting. Set on the lent object itself where it is
721
+ * shown as lent rather than cloned.
722
+ */
723
+ readonly castShadow: boolean;
724
+ readonly receiveShadow: boolean;
659
725
  };
660
726
 
661
727
  /** Where a label's origin is along its width: an edge, the middle, or a fraction of it. */
@@ -902,6 +968,8 @@ export type PerspectiveCamera = {
902
968
  * (`autoRotated(camera, detail)`). A new position or `lookAt` turns on from there.
903
969
  */
904
970
  readonly autoRotate: number;
971
+ /** Shakes the rendered camera (Drei's `CameraShake`), or `null` for none. */
972
+ readonly shake: CameraShake | null;
905
973
  };
906
974
 
907
975
  /**
@@ -919,6 +987,28 @@ export type OrthographicCamera = {
919
987
  readonly damping: number;
920
988
  /** As on `PerspectiveCamera`. */
921
989
  readonly autoRotate: number;
990
+ /** As on `PerspectiveCamera`. */
991
+ readonly shake: CameraShake | null;
992
+ };
993
+
994
+ /**
995
+ * Camera shake, as Drei's `CameraShake`: yaw, pitch and roll of up to `max*` radians,
996
+ * from noise at `*Frequency`, scaled by `intensity` squared (0 to 1). With `decay` the
997
+ * intensity falls by `decayRate` a second from when the shake began, so a decaying shake
998
+ * is a one-off: it begins when it first appears or changes, and `trigger` (a count the
999
+ * Model bumps) begins the same shake again. Reduced motion holds the camera still.
1000
+ */
1001
+ export type CameraShake = {
1002
+ readonly intensity: number;
1003
+ readonly decay: boolean;
1004
+ readonly decayRate: number;
1005
+ readonly maxYaw: number;
1006
+ readonly maxPitch: number;
1007
+ readonly maxRoll: number;
1008
+ readonly yawFrequency: number;
1009
+ readonly pitchFrequency: number;
1010
+ readonly rollFrequency: number;
1011
+ readonly trigger: number;
922
1012
  };
923
1013
 
924
1014
  /** The single active camera. */
@@ -31,6 +31,7 @@ import {
31
31
  labelMaterialEquals,
32
32
  labelTypeEquals,
33
33
  xrSpaceEquals,
34
+ detailEquals,
34
35
  shaderGridLookEquals,
35
36
  instancesEqual,
36
37
  materialRefEquals,
@@ -82,7 +83,9 @@ export type Field =
82
83
  /** A shader grid's look: anything but where it is. */
83
84
  | 'grid'
84
85
  /** The tracked space a group follows. */
85
- | 'follow';
86
+ | 'follow'
87
+ /** A group's levels of detail. */
88
+ | 'detail';
86
89
 
87
90
  /** A single change to apply to the hosted scene graph. */
88
91
  export type Op =
@@ -131,6 +134,7 @@ export const changedFields = (
131
134
  if (prev.interactive !== next.interactive) fields.push('interactive');
132
135
  if (!bodyEquals(prev.body, next.body)) fields.push('body');
133
136
  if (!xrSpaceEquals(prev.follow, next.follow)) fields.push('follow');
137
+ if (!detailEquals(prev.detail, next.detail)) fields.push('detail');
134
138
  return fields;
135
139
  }
136
140
  case 'Mesh': {
@@ -175,6 +179,8 @@ export const changedFields = (
175
179
  if (!animationEquals(prev.animation, next.animation)) fields.push('animation');
176
180
  if (prev.interactive !== next.interactive) fields.push('interactive');
177
181
  if (!bodyEquals(prev.body, next.body)) fields.push('body');
182
+ if (prev.castShadow !== next.castShadow) fields.push('castShadow');
183
+ if (prev.receiveShadow !== next.receiveShadow) fields.push('receiveShadow');
178
184
  return fields;
179
185
  }
180
186
  case 'Label': {
@@ -7,6 +7,8 @@
7
7
  import type {
8
8
  Animation,
9
9
  Behave,
10
+ CameraShake,
11
+ Detail,
10
12
  Body,
11
13
  BodyRef,
12
14
  GeometryRef,
@@ -46,6 +48,17 @@ import type {
46
48
  export const vec3Equals = (a: Vec3, b: Vec3): boolean =>
47
49
  a === b || (Object.is(a[0], b[0]) && Object.is(a[1], b[1]) && Object.is(a[2], b[2]));
48
50
 
51
+ /** Levels-of-detail equality: the same distances and hysteresis. */
52
+ export const detailEquals = (a: Detail | null, b: Detail | null): boolean => {
53
+ if (a === b) return true;
54
+ if (a === null || b === null) return false;
55
+ return (
56
+ Object.is(a.hysteresis, b.hysteresis) &&
57
+ a.distances.length === b.distances.length &&
58
+ a.distances.every((distance, index) => Object.is(distance, b.distances[index]))
59
+ );
60
+ };
61
+
49
62
  /** Rotation equality, including behaviour parameters. */
50
63
  export const rotationEquals = (a: Rotation, b: Rotation): boolean => {
51
64
  if (a === b) return true;
@@ -57,6 +70,9 @@ export const rotationEquals = (a: Rotation, b: Rotation): boolean => {
57
70
  Object.is(a.phase, b.phase)
58
71
  );
59
72
  }
73
+ if (a._tag === 'Billboard' && b._tag === 'Billboard') {
74
+ return a.lockX === b.lockX && a.lockY === b.lockY && a.lockZ === b.lockZ;
75
+ }
60
76
  return false;
61
77
  };
62
78
 
@@ -120,6 +136,20 @@ export const shapeEquals = (a: ShapeGeometry, b: ShapeGeometry): boolean => {
120
136
  );
121
137
  case 'Circle':
122
138
  return b._tag === 'Circle' && Object.is(a.radius, b.radius) && a.segments === b.segments;
139
+ case 'RoundedBox':
140
+ return (
141
+ b._tag === 'RoundedBox' &&
142
+ Object.is(a.width, b.width) &&
143
+ Object.is(a.height, b.height) &&
144
+ Object.is(a.depth, b.depth) &&
145
+ Object.is(a.radius, b.radius) &&
146
+ a.smoothness === b.smoothness &&
147
+ a.bevelSegments === b.bevelSegments &&
148
+ a.steps === b.steps &&
149
+ Object.is(a.creaseAngle, b.creaseAngle)
150
+ );
151
+ case 'Backdrop':
152
+ return b._tag === 'Backdrop' && Object.is(a.floor, b.floor) && a.segments === b.segments;
123
153
  case 'Text':
124
154
  return (
125
155
  b._tag === 'Text' &&
@@ -223,6 +253,22 @@ export const materialRefEquals = (a: MaterialRef, b: MaterialRef): boolean => {
223
253
  };
224
254
 
225
255
  /** Camera equality: same kind, same lens and same placement. */
256
+ /** Camera shake equality: every setting, and the trigger count. */
257
+ export const cameraShakeEquals = (a: CameraShake | null, b: CameraShake | null): boolean =>
258
+ a === b ||
259
+ (a !== null &&
260
+ b !== null &&
261
+ Object.is(a.intensity, b.intensity) &&
262
+ a.decay === b.decay &&
263
+ Object.is(a.decayRate, b.decayRate) &&
264
+ Object.is(a.maxYaw, b.maxYaw) &&
265
+ Object.is(a.maxPitch, b.maxPitch) &&
266
+ Object.is(a.maxRoll, b.maxRoll) &&
267
+ Object.is(a.yawFrequency, b.yawFrequency) &&
268
+ Object.is(a.pitchFrequency, b.pitchFrequency) &&
269
+ Object.is(a.rollFrequency, b.rollFrequency) &&
270
+ Object.is(a.trigger, b.trigger));
271
+
226
272
  export const cameraEquals = (a: Camera, b: Camera): boolean => {
227
273
  if (a === b) return true;
228
274
  const sameLens =
@@ -234,6 +280,7 @@ export const cameraEquals = (a: Camera, b: Camera): boolean => {
234
280
  Object.is(a.far, b.far) &&
235
281
  Object.is(a.damping, b.damping) &&
236
282
  Object.is(a.autoRotate, b.autoRotate) &&
283
+ cameraShakeEquals(a.shake, b.shake) &&
237
284
  vec3Equals(a.position, b.position) &&
238
285
  vec3Equals(a.lookAt, b.lookAt)
239
286
  );
@@ -96,12 +96,17 @@ export {
96
96
  pointLight,
97
97
  renderSettings,
98
98
  ring,
99
+ roundedBox,
100
+ backdrop,
99
101
  scene,
100
102
  sharedGeometry,
101
103
  lentGeometry,
102
104
  sharedMaterial,
103
105
  sphere,
104
106
  spin,
107
+ cameraShake,
108
+ detailed,
109
+ billboard,
105
110
  spotLight,
106
111
  standard,
107
112
  overlay,
@@ -359,6 +359,12 @@ const pathOf = (graph: SceneGraph, object: Object3D): ReadonlyArray<string> => {
359
359
  return path;
360
360
  };
361
361
 
362
+ /** Whether an object and every ancestor are visible. */
363
+ const shown = (object: Object3D): boolean => {
364
+ for (let at: Object3D | null = object; at !== null; at = at.parent) if (!at.visible) return false;
365
+ return true;
366
+ };
367
+
362
368
  /** Creates a raycasting picker over a graph's interactive meshes and groups. */
363
369
  export const createPicker = (graph: SceneGraph): Picker => {
364
370
  const raycaster = new Raycaster();
@@ -403,6 +409,8 @@ export const createPicker = (graph: SceneGraph): Picker => {
403
409
  const nearestHit = (view: string | undefined): Hit | undefined => {
404
410
  // Recursive, so an interactive group makes every mesh inside it pickable.
405
411
  for (const intersection of raycaster.intersectObjects(graph.interactiveObjects(), true)) {
412
+ // Hidden things are not hit: a level of detail not showing, as three's `LOD.raycast`.
413
+ if (!shown(intersection.object)) continue;
406
414
  const owner = keyedOwner(graph, intersection.object);
407
415
  const key = owner === undefined ? undefined : graph.keyOf(owner);
408
416
  if (owner !== undefined && key !== undefined) {
@@ -22,17 +22,21 @@ import {
22
22
  CircleGeometry,
23
23
  ConeGeometry,
24
24
  CylinderGeometry,
25
+ Euler,
25
26
  ExtrudeGeometry,
26
27
  Material,
27
28
  type Object3D,
28
29
  type Texture,
29
30
  PlaneGeometry,
31
+ Quaternion,
30
32
  RingGeometry,
33
+ Shape,
31
34
  ShapeGeometry as ShapeGeometry3,
32
35
  SphereGeometry,
33
36
  TorusGeometry,
34
37
  } from 'three';
35
38
  import type { Font } from 'three/addons/loaders/FontLoader.js';
39
+ import { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js';
36
40
 
37
41
  import type {
38
42
  GeometryRef,
@@ -191,6 +195,67 @@ const buildText = (shape: TextGeometry, font: Font | undefined): BufferGeometry
191
195
  return geometry;
192
196
  };
193
197
 
198
+ /** Drei's `RoundedBox` nudge, keeping its corner arcs from being zero length. */
199
+ const roundedEpsilon = 0.00001;
200
+
201
+ /**
202
+ * Drei's `RoundedBox` (`bf6f4ad`): the corners' arcs drawn tiny and the bevel making the
203
+ * rounding, extruded through the depth less the bevels, centred, and its normals creased
204
+ * (three's `toCreasedNormals`, which Drei takes from three-stdlib).
205
+ */
206
+ const buildRoundedBox = (shape: Extract<ShapeGeometry, { readonly _tag: 'RoundedBox' }>) => {
207
+ const { width, height, depth, radius } = shape;
208
+ const outline = new Shape();
209
+ const inset = radius - roundedEpsilon;
210
+ const e = roundedEpsilon;
211
+ outline.absarc(e, e, e, -Math.PI / 2, -Math.PI, true);
212
+ outline.absarc(e, height - inset * 2, e, Math.PI, Math.PI / 2, true);
213
+ outline.absarc(width - inset * 2, height - inset * 2, e, Math.PI / 2, 0, true);
214
+ outline.absarc(width - inset * 2, e, e, 0, -Math.PI / 2, true);
215
+ const geometry = new ExtrudeGeometry(outline, {
216
+ depth: depth - radius * 2,
217
+ bevelEnabled: true,
218
+ bevelSegments: shape.bevelSegments * 2,
219
+ steps: shape.steps,
220
+ bevelSize: radius - roundedEpsilon,
221
+ bevelThickness: radius,
222
+ curveSegments: shape.smoothness,
223
+ });
224
+ geometry.center();
225
+ return toCreasedNormals(geometry, shape.creaseAngle);
226
+ };
227
+
228
+ /** Drei's `Backdrop` mesh turn, built into the geometry: the floor flat, the wall behind. */
229
+ const backdropTurn = new Quaternion().setFromEuler(new Euler(-Math.PI / 2, 0, Math.PI / 2));
230
+
231
+ /** Drei's `Backdrop` easing: the floor stays flat, then sweeps up. */
232
+ const easeInExpo = (x: number): number => (x === 0 ? 0 : 2 ** (10 * x - 10));
233
+
234
+ /**
235
+ * Drei's `Backdrop` (`bf6f4ad`): a unit plane whose rows rise by an exponential ease, its
236
+ * first row pulled forward by `floor`, then turned as Drei turns its mesh.
237
+ */
238
+ const buildBackdrop = (shape: Extract<ShapeGeometry, { readonly _tag: 'Backdrop' }>) => {
239
+ const { segments, floor } = shape;
240
+ const geometry = new PlaneGeometry(1, 1, segments, segments);
241
+ const position = geometry.getAttribute('position');
242
+ const offset = 0.5;
243
+ let vertex = 0;
244
+ for (let x = 0; x < segments + 1; x++) {
245
+ for (let y = 0; y < segments + 1; y++) {
246
+ position.setXYZ(
247
+ vertex++,
248
+ x / segments - offset + (x === 0 ? -floor : 0),
249
+ y / segments - offset,
250
+ easeInExpo(x / segments),
251
+ );
252
+ }
253
+ }
254
+ position.needsUpdate = true;
255
+ geometry.computeVertexNormals();
256
+ return geometry.applyQuaternion(backdropTurn);
257
+ };
258
+
194
259
  /** Builds the three.js geometry for a shape description. */
195
260
  const buildShape = (
196
261
  shape: ShapeGeometry,
@@ -232,6 +297,10 @@ const buildShape = (
232
297
  return new RingGeometry(shape.innerRadius, shape.outerRadius, shape.thetaSegments);
233
298
  case 'Circle':
234
299
  return new CircleGeometry(shape.radius, shape.segments);
300
+ case 'RoundedBox':
301
+ return buildRoundedBox(shape);
302
+ case 'Backdrop':
303
+ return buildBackdrop(shape);
235
304
  }
236
305
  };
237
306