@opsydyn/effect-three 0.0.0-stage → 0.1.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 (166) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/LICENSE +21 -0
  3. package/README.md +191 -2
  4. package/dist/index-4045wme0.js +129 -0
  5. package/dist/index-4045wme0.js.map +10 -0
  6. package/dist/index-ef8pbv9g.js +5118 -0
  7. package/dist/index-ef8pbv9g.js.map +40 -0
  8. package/dist/index.d.ts +12 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +1361 -0
  11. package/dist/index.js.map +14 -0
  12. package/dist/kernel/animation.d.ts +14 -0
  13. package/dist/kernel/animation.d.ts.map +1 -0
  14. package/dist/kernel/behaviours.d.ts +16 -0
  15. package/dist/kernel/behaviours.d.ts.map +1 -0
  16. package/dist/kernel/bodies.d.ts +60 -0
  17. package/dist/kernel/bodies.d.ts.map +1 -0
  18. package/dist/kernel/bodySources.d.ts +47 -0
  19. package/dist/kernel/bodySources.d.ts.map +1 -0
  20. package/dist/kernel/build.d.ts +564 -0
  21. package/dist/kernel/build.d.ts.map +1 -0
  22. package/dist/kernel/cameraMotion.d.ts +39 -0
  23. package/dist/kernel/cameraMotion.d.ts.map +1 -0
  24. package/dist/kernel/clock.d.ts +17 -0
  25. package/dist/kernel/clock.d.ts.map +1 -0
  26. package/dist/kernel/contactShadowPasses.d.ts +47 -0
  27. package/dist/kernel/contactShadowPasses.d.ts.map +1 -0
  28. package/dist/kernel/description.d.ts +1120 -0
  29. package/dist/kernel/description.d.ts.map +1 -0
  30. package/dist/kernel/diff.d.ts +90 -0
  31. package/dist/kernel/diff.d.ts.map +1 -0
  32. package/dist/kernel/environmentPresets.d.ts +30 -0
  33. package/dist/kernel/environmentPresets.d.ts.map +1 -0
  34. package/dist/kernel/equality.d.ts +64 -0
  35. package/dist/kernel/equality.d.ts.map +1 -0
  36. package/dist/kernel/frames.d.ts +55 -0
  37. package/dist/kernel/frames.d.ts.map +1 -0
  38. package/dist/kernel/index.d.ts +37 -0
  39. package/dist/kernel/index.d.ts.map +1 -0
  40. package/dist/kernel/index.js +335 -0
  41. package/dist/kernel/index.js.map +9 -0
  42. package/dist/kernel/instances.d.ts +26 -0
  43. package/dist/kernel/instances.d.ts.map +1 -0
  44. package/dist/kernel/labels.d.ts +45 -0
  45. package/dist/kernel/labels.d.ts.map +1 -0
  46. package/dist/kernel/loop.d.ts +49 -0
  47. package/dist/kernel/loop.d.ts.map +1 -0
  48. package/dist/kernel/materials.d.ts +24 -0
  49. package/dist/kernel/materials.d.ts.map +1 -0
  50. package/dist/kernel/motion.d.ts +36 -0
  51. package/dist/kernel/motion.d.ts.map +1 -0
  52. package/dist/kernel/orbit.d.ts +127 -0
  53. package/dist/kernel/orbit.d.ts.map +1 -0
  54. package/dist/kernel/overlays.d.ts +16 -0
  55. package/dist/kernel/overlays.d.ts.map +1 -0
  56. package/dist/kernel/performance.d.ts +41 -0
  57. package/dist/kernel/performance.d.ts.map +1 -0
  58. package/dist/kernel/physics.d.ts +166 -0
  59. package/dist/kernel/physics.d.ts.map +1 -0
  60. package/dist/kernel/physicsDebug.d.ts +18 -0
  61. package/dist/kernel/physicsDebug.d.ts.map +1 -0
  62. package/dist/kernel/pointer.d.ts +357 -0
  63. package/dist/kernel/pointer.d.ts.map +1 -0
  64. package/dist/kernel/renderKit.d.ts +48 -0
  65. package/dist/kernel/renderKit.d.ts.map +1 -0
  66. package/dist/kernel/renderer.d.ts +83 -0
  67. package/dist/kernel/renderer.d.ts.map +1 -0
  68. package/dist/kernel/resources.d.ts +112 -0
  69. package/dist/kernel/resources.d.ts.map +1 -0
  70. package/dist/kernel/sceneGraph.d.ts +155 -0
  71. package/dist/kernel/sceneGraph.d.ts.map +1 -0
  72. package/dist/kernel/sceneIndex.d.ts +90 -0
  73. package/dist/kernel/sceneIndex.d.ts.map +1 -0
  74. package/dist/kernel/stage.d.ts +87 -0
  75. package/dist/kernel/stage.d.ts.map +1 -0
  76. package/dist/kernel/textLayout.d.ts +29 -0
  77. package/dist/kernel/textLayout.d.ts.map +1 -0
  78. package/dist/kernel/tween.d.ts +35 -0
  79. package/dist/kernel/tween.d.ts.map +1 -0
  80. package/dist/kernel/xrInput.d.ts +107 -0
  81. package/dist/kernel/xrInput.d.ts.map +1 -0
  82. package/dist/kernel/xrLocomotion.d.ts +38 -0
  83. package/dist/kernel/xrLocomotion.d.ts.map +1 -0
  84. package/dist/runtime/FrameSource.d.ts +29 -0
  85. package/dist/runtime/FrameSource.d.ts.map +1 -0
  86. package/dist/runtime/ResourceRegistry.d.ts +18 -0
  87. package/dist/runtime/ResourceRegistry.d.ts.map +1 -0
  88. package/dist/runtime/SceneRuntime.d.ts +68 -0
  89. package/dist/runtime/SceneRuntime.d.ts.map +1 -0
  90. package/dist/runtime/SceneSchema.d.ts +2536 -0
  91. package/dist/runtime/SceneSchema.d.ts.map +1 -0
  92. package/dist/runtime/errors.d.ts +19 -0
  93. package/dist/runtime/errors.d.ts.map +1 -0
  94. package/dist/runtime/index.d.ts +11 -0
  95. package/dist/runtime/index.d.ts.map +1 -0
  96. package/dist/webgl/contactShadows.d.ts +5 -0
  97. package/dist/webgl/contactShadows.d.ts.map +1 -0
  98. package/dist/webgl/index.d.ts +11 -0
  99. package/dist/webgl/index.d.ts.map +1 -0
  100. package/dist/webgl/index.js +239 -0
  101. package/dist/webgl/index.js.map +13 -0
  102. package/dist/webgl/shaderGrid.d.ts +27 -0
  103. package/dist/webgl/shaderGrid.d.ts.map +1 -0
  104. package/dist/webgl/sky.d.ts +4 -0
  105. package/dist/webgl/sky.d.ts.map +1 -0
  106. package/dist/webgpu/contactShadows.d.ts +5 -0
  107. package/dist/webgpu/contactShadows.d.ts.map +1 -0
  108. package/dist/webgpu/index.d.ts +10 -0
  109. package/dist/webgpu/index.d.ts.map +1 -0
  110. package/dist/webgpu/index.js +216 -0
  111. package/dist/webgpu/index.js.map +13 -0
  112. package/dist/webgpu/shaderGrid.d.ts +5 -0
  113. package/dist/webgpu/shaderGrid.d.ts.map +1 -0
  114. package/dist/webgpu/sky.d.ts +4 -0
  115. package/dist/webgpu/sky.d.ts.map +1 -0
  116. package/package.json +82 -4
  117. package/src/index.ts +11 -0
  118. package/src/kernel/animation.ts +24 -0
  119. package/src/kernel/behaviours.ts +36 -0
  120. package/src/kernel/bodies.ts +338 -0
  121. package/src/kernel/bodySources.ts +203 -0
  122. package/src/kernel/build.ts +1325 -0
  123. package/src/kernel/cameraMotion.ts +202 -0
  124. package/src/kernel/clock.ts +35 -0
  125. package/src/kernel/contactShadowPasses.ts +207 -0
  126. package/src/kernel/description.ts +1197 -0
  127. package/src/kernel/diff.ts +383 -0
  128. package/src/kernel/environmentPresets.ts +38 -0
  129. package/src/kernel/equality.ts +604 -0
  130. package/src/kernel/frames.ts +124 -0
  131. package/src/kernel/index.ts +274 -0
  132. package/src/kernel/instances.ts +97 -0
  133. package/src/kernel/labels.ts +48 -0
  134. package/src/kernel/loop.ts +248 -0
  135. package/src/kernel/materials.ts +258 -0
  136. package/src/kernel/motion.ts +75 -0
  137. package/src/kernel/orbit.ts +554 -0
  138. package/src/kernel/overlays.ts +130 -0
  139. package/src/kernel/performance.ts +101 -0
  140. package/src/kernel/physics.ts +361 -0
  141. package/src/kernel/physicsDebug.ts +68 -0
  142. package/src/kernel/pointer.ts +785 -0
  143. package/src/kernel/renderKit.ts +53 -0
  144. package/src/kernel/renderer.ts +136 -0
  145. package/src/kernel/resources.ts +504 -0
  146. package/src/kernel/sceneGraph.ts +1440 -0
  147. package/src/kernel/sceneIndex.ts +219 -0
  148. package/src/kernel/stage.ts +157 -0
  149. package/src/kernel/textLayout.ts +102 -0
  150. package/src/kernel/tween.ts +80 -0
  151. package/src/kernel/xrInput.ts +230 -0
  152. package/src/kernel/xrLocomotion.ts +72 -0
  153. package/src/runtime/FrameSource.ts +41 -0
  154. package/src/runtime/ResourceRegistry.ts +24 -0
  155. package/src/runtime/SceneRuntime.ts +229 -0
  156. package/src/runtime/SceneSchema.ts +983 -0
  157. package/src/runtime/errors.ts +18 -0
  158. package/src/runtime/index.ts +10 -0
  159. package/src/webgl/contactShadows.ts +67 -0
  160. package/src/webgl/index.ts +19 -0
  161. package/src/webgl/shaderGrid.ts +196 -0
  162. package/src/webgl/sky.ts +42 -0
  163. package/src/webgpu/contactShadows.ts +83 -0
  164. package/src/webgpu/index.ts +19 -0
  165. package/src/webgpu/shaderGrid.ts +158 -0
  166. package/src/webgpu/sky.ts +35 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Flattened, validated view of a scene description, used by the diff.
3
+ *
4
+ * @module
5
+ */
6
+ import type { Joint, SceneDescription, SceneNode } from './description.ts';
7
+
8
+ /** Parent of a node: another node's key, or `null` for the scene root. */
9
+ export type ParentKey = string | null;
10
+
11
+ /** A node with its position in the tree. */
12
+ export type IndexedNode = {
13
+ readonly node: SceneNode;
14
+ readonly parent: ParentKey;
15
+ };
16
+
17
+ /** A description is rejected as a whole; nothing is applied (no partial updates). */
18
+ export type DescriptionError =
19
+ | { readonly _tag: 'DuplicateKey'; readonly key: string }
20
+ | {
21
+ readonly _tag: 'DuplicateResourceId';
22
+ readonly resource: 'geometry' | 'material';
23
+ readonly id: string;
24
+ }
25
+ | { readonly _tag: 'UnknownSharedGeometry'; readonly nodeKey: string; readonly id: string }
26
+ | { readonly _tag: 'UnknownSharedMaterial'; readonly nodeKey: string; readonly id: string }
27
+ | { readonly _tag: 'DuplicateInstanceKey'; readonly nodeKey: string; readonly key: string }
28
+ | { readonly _tag: 'UnknownBehaviour'; readonly nodeKey: string; readonly name: string }
29
+ | { readonly _tag: 'DuplicateViewKey'; readonly key: string }
30
+ | { readonly _tag: 'DuplicateRenderTextureId'; readonly id: string }
31
+ | { readonly _tag: 'DuplicateOverlayKey'; readonly key: string }
32
+ | { readonly _tag: 'UnknownOverlayNode'; readonly key: string; readonly node: string }
33
+ | { readonly _tag: 'UnknownOverlayView'; readonly key: string; readonly view: string }
34
+ | { readonly _tag: 'UnknownMeasuredNode'; readonly key: string }
35
+ /** Two joints share a key. */
36
+ | { readonly _tag: 'DuplicateJointKey'; readonly key: string }
37
+ /** A joint names a node that is not in the scene. */
38
+ | { readonly _tag: 'UnknownJointBody'; readonly joint: string; readonly body: string };
39
+
40
+ /** A validated scene. Keys are unique across the whole scene. */
41
+ export type SceneIndex = {
42
+ readonly description: SceneDescription | null;
43
+ readonly nodes: ReadonlyMap<string, IndexedNode>;
44
+ /** Child keys per parent, in description order. Includes the root (`null`). */
45
+ readonly children: ReadonlyMap<ParentKey, ReadonlyArray<string>>;
46
+ /** Keys in depth-first pre-order: parents always precede their children. */
47
+ readonly preOrder: ReadonlyArray<string>;
48
+ };
49
+
50
+ /** The index of the empty scene that every graph starts from. */
51
+ export const emptyIndex: SceneIndex = {
52
+ description: null,
53
+ nodes: new Map(),
54
+ children: new Map([[null, []]]),
55
+ preOrder: [],
56
+ };
57
+
58
+ export type IndexResult =
59
+ | { readonly _tag: 'Indexed'; readonly index: SceneIndex }
60
+ | { readonly _tag: 'Invalid'; readonly error: DescriptionError };
61
+
62
+ /** A node waiting to be indexed: its index entry, and the list its key joins. */
63
+ type Stacked = IndexedNode & { readonly siblings: Array<string> };
64
+
65
+ /** The first instance key used twice, if any. */
66
+ const firstDuplicateInstance = (
67
+ instances: ReadonlyArray<{ readonly key: string }>,
68
+ ): string | undefined => {
69
+ const seen = new Set<string>();
70
+ for (const { key } of instances) {
71
+ if (seen.has(key)) return key;
72
+ seen.add(key);
73
+ }
74
+ return undefined;
75
+ };
76
+
77
+ /** The first joint error: a key used twice, or a body naming no node. */
78
+ const jointsError = (
79
+ joints: ReadonlyArray<Joint>,
80
+ nodes: ReadonlyMap<string, IndexedNode>,
81
+ ): DescriptionError | undefined => {
82
+ const duplicate = firstDuplicateInstance(joints);
83
+ if (duplicate !== undefined) return { _tag: 'DuplicateJointKey', key: duplicate };
84
+ for (const joint of joints) {
85
+ for (const { key } of [joint.a, joint.b]) {
86
+ if (!nodes.has(key)) return { _tag: 'UnknownJointBody', joint: joint.key, body: key };
87
+ }
88
+ }
89
+ return undefined;
90
+ };
91
+
92
+ /** Validates and flattens a description. */
93
+ export const indexScene = (
94
+ description: SceneDescription,
95
+ behaviours: ReadonlySet<string> = new Set(),
96
+ ): IndexResult => {
97
+ const duplicateTexture = firstDuplicateInstance(
98
+ description.renderTextures.map((texture) => ({ key: texture.id })),
99
+ );
100
+ if (duplicateTexture !== undefined) {
101
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateRenderTextureId', id: duplicateTexture } };
102
+ }
103
+ const duplicateView = firstDuplicateInstance(description.views);
104
+ if (duplicateView !== undefined) {
105
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateViewKey', key: duplicateView } };
106
+ }
107
+ const duplicateOverlay = firstDuplicateInstance(description.overlays);
108
+ if (duplicateOverlay !== undefined) {
109
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateOverlayKey', key: duplicateOverlay } };
110
+ }
111
+ const viewKeys = new Set(description.views.map((view) => view.key));
112
+ const strayView = description.overlays.find(
113
+ (overlay) => overlay.view !== null && !viewKeys.has(overlay.view),
114
+ );
115
+ if (strayView?.view != null) {
116
+ return {
117
+ _tag: 'Invalid',
118
+ error: { _tag: 'UnknownOverlayView', key: strayView.key, view: strayView.view },
119
+ };
120
+ }
121
+ const geometryIds = new Set<string>();
122
+ for (const { id } of description.resources.geometries) {
123
+ if (geometryIds.has(id)) {
124
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateResourceId', resource: 'geometry', id } };
125
+ }
126
+ geometryIds.add(id);
127
+ }
128
+ const materialIds = new Set<string>();
129
+ for (const { id } of description.resources.materials) {
130
+ if (materialIds.has(id)) {
131
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateResourceId', resource: 'material', id } };
132
+ }
133
+ materialIds.add(id);
134
+ }
135
+
136
+ const nodes = new Map<string, IndexedNode>();
137
+ const rootChildren: Array<string> = [];
138
+ const children = new Map<ParentKey, Array<string>>([[null, rootChildren]]);
139
+ const preOrder: Array<string> = [];
140
+ // Explicit stack instead of recursion: deep scenes cannot overflow the call stack. Each
141
+ // item becomes the node's index entry, and carries its parent's child list, so indexing
142
+ // a node allocates nothing else.
143
+ const stack: Array<Stacked> = [];
144
+ for (let i = description.children.length - 1; i >= 0; i--) {
145
+ const node = description.children[i];
146
+ if (node !== undefined) stack.push({ node, parent: null, siblings: rootChildren });
147
+ }
148
+ for (let item = stack.pop(); item !== undefined; item = stack.pop()) {
149
+ const { node } = item;
150
+ if (nodes.has(node.key))
151
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateKey', key: node.key } };
152
+ if (
153
+ node._tag === 'Label' &&
154
+ node.material?._tag === 'SharedMaterial' &&
155
+ !materialIds.has(node.material.id)
156
+ ) {
157
+ return {
158
+ _tag: 'Invalid',
159
+ error: { _tag: 'UnknownSharedMaterial', nodeKey: node.key, id: node.material.id },
160
+ };
161
+ }
162
+ if (node._tag === 'Mesh' || node._tag === 'Instances') {
163
+ if (node.geometry._tag === 'SharedGeometry' && !geometryIds.has(node.geometry.id)) {
164
+ return {
165
+ _tag: 'Invalid',
166
+ error: { _tag: 'UnknownSharedGeometry', nodeKey: node.key, id: node.geometry.id },
167
+ };
168
+ }
169
+ if (node.material._tag === 'SharedMaterial' && !materialIds.has(node.material.id)) {
170
+ return {
171
+ _tag: 'Invalid',
172
+ error: { _tag: 'UnknownSharedMaterial', nodeKey: node.key, id: node.material.id },
173
+ };
174
+ }
175
+ }
176
+ if ('behaviour' in node && node.behaviour !== null && !behaviours.has(node.behaviour.name)) {
177
+ return {
178
+ _tag: 'Invalid',
179
+ error: { _tag: 'UnknownBehaviour', nodeKey: node.key, name: node.behaviour.name },
180
+ };
181
+ }
182
+ if (node._tag === 'Instances') {
183
+ const duplicate = firstDuplicateInstance(node.instances);
184
+ if (duplicate !== undefined) {
185
+ return {
186
+ _tag: 'Invalid',
187
+ error: { _tag: 'DuplicateInstanceKey', nodeKey: node.key, key: duplicate },
188
+ };
189
+ }
190
+ }
191
+ nodes.set(node.key, item);
192
+ preOrder.push(node.key);
193
+ item.siblings.push(node.key);
194
+ if (node._tag === 'Group') {
195
+ const siblings: Array<string> = [];
196
+ children.set(node.key, siblings);
197
+ for (let i = node.children.length - 1; i >= 0; i--) {
198
+ const child = node.children[i];
199
+ if (child !== undefined) stack.push({ node: child, parent: node.key, siblings });
200
+ }
201
+ }
202
+ }
203
+ const strayNode = description.overlays.find(
204
+ (overlay) => overlay.node !== null && !nodes.has(overlay.node),
205
+ );
206
+ if (strayNode?.node != null) {
207
+ return {
208
+ _tag: 'Invalid',
209
+ error: { _tag: 'UnknownOverlayNode', key: strayNode.key, node: strayNode.node },
210
+ };
211
+ }
212
+ const unmeasurable = description.measure.find((key) => !nodes.has(key));
213
+ if (unmeasurable !== undefined) {
214
+ return { _tag: 'Invalid', error: { _tag: 'UnknownMeasuredNode', key: unmeasurable } };
215
+ }
216
+ const jointError = jointsError(description.physics?.joints ?? [], nodes);
217
+ if (jointError !== undefined) return { _tag: 'Invalid', error: jointError };
218
+ return { _tag: 'Indexed', index: { description, nodes, children, preOrder } };
219
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A ready-lit stage for a model, as Drei's `Stage` (`src/core/Stage.tsx` at `bf6f4ad`):
3
+ * an ambient light, a main spot light and a fill point light placed by a lighting preset
4
+ * and scaled by the content's size, contact shadows at its base, and an environment.
5
+ *
6
+ * Drei's `Stage` measures and centres its children and can fit the camera to them. Here
7
+ * that is a loop through the Model, as controls are (ADR-006): measure the content
8
+ * (`scene({ measure })` reports its box), place it at `centerOffset(box)` as Drei's
9
+ * `Center` would, light it with `stage(stageBounds(box))`, and frame it with `fitCamera`
10
+ * as Drei's `Bounds` would.
11
+ *
12
+ * @module
13
+ */
14
+ import {
15
+ ambientLight,
16
+ contactShadows as contactShadowsOf,
17
+ environment as environmentOf,
18
+ pointLight,
19
+ spotLight,
20
+ } from './build.ts';
21
+ import type { ContactShadows, Environment, SceneNode, Vec3 } from './description.ts';
22
+ import type { Box } from './orbit.ts';
23
+
24
+ /** Where the main and fill lights sit, in multiples of the content's radius. */
25
+ export type StageLighting = { readonly main: Vec3; readonly fill: Vec3 };
26
+
27
+ /** Drei's lighting presets. */
28
+ export const stagePresets = {
29
+ rembrandt: { main: [1, 2, 1], fill: [-2, -0.5, -2] },
30
+ portrait: { main: [-1, 2, 0.5], fill: [-1, 0.5, -1.5] },
31
+ upfront: { main: [0, 2, 1], fill: [-1, 0.5, -1.5] },
32
+ soft: { main: [-2, 4, 4], fill: [-1, 0.5, -1.5] },
33
+ } satisfies Record<string, StageLighting>;
34
+
35
+ export type StagePreset = keyof typeof stagePresets;
36
+
37
+ /** The scene parts a stage contributes: spread them into `scene`. */
38
+ export type Stage = {
39
+ /** The stage's lights; add them to the scene's children. */
40
+ readonly lights: ReadonlyArray<SceneNode>;
41
+ readonly contactShadows: ContactShadows | null;
42
+ readonly environment: Environment | null;
43
+ };
44
+
45
+ const scaled = (point: Vec3, by: number): Vec3 => [point[0] * by, point[1] * by, point[2] * by];
46
+
47
+ /**
48
+ * Lights, ground shadows and an environment for content centred on the origin with
49
+ * bounding radius `radius` and height `height`. Defaults as Drei's `Stage`: the
50
+ * Rembrandt preset, intensity 0.5, contact shadows (`radius × 4` across, reaching
51
+ * `radius` up, blur 2) and a spot light that casts shadow maps when the scene enables
52
+ * them (bias −0.0001, 1024 texels). Drei's default environment is the "city" HDR from a
53
+ * CDN; here it is three.js's `RoomEnvironment`, built in code. `keyPrefix` names the
54
+ * lights (`stage-ambient`, `stage-main`, `stage-fill` by default).
55
+ */
56
+ export const stage = (options: {
57
+ readonly radius: number;
58
+ readonly height: number;
59
+ readonly preset?: StagePreset | StageLighting;
60
+ readonly intensity?: number;
61
+ readonly shadows?: 'contact' | 'none';
62
+ readonly environment?: Environment | null;
63
+ readonly keyPrefix?: string;
64
+ }): Stage => {
65
+ const { radius, height } = options;
66
+ const lighting =
67
+ typeof options.preset === 'object'
68
+ ? options.preset
69
+ : stagePresets[options.preset ?? 'rembrandt'];
70
+ const intensity = options.intensity ?? 0.5;
71
+ const prefix = options.keyPrefix ?? 'stage';
72
+ const shadowed = (options.shadows ?? 'contact') === 'contact';
73
+ return {
74
+ lights: [
75
+ ambientLight({ key: `${prefix}-ambient`, intensity: intensity / 3 }),
76
+ spotLight({
77
+ key: `${prefix}-main`,
78
+ position: scaled(lighting.main, radius),
79
+ penumbra: 1,
80
+ intensity: intensity * 2,
81
+ castShadow: shadowed,
82
+ shadowBias: -0.0001,
83
+ shadowMapSize: 1024,
84
+ }),
85
+ pointLight({ key: `${prefix}-fill`, position: scaled(lighting.fill, radius), intensity }),
86
+ ],
87
+ contactShadows: shadowed
88
+ ? contactShadowsOf({ position: [0, -height / 2, 0], size: radius * 4, far: radius, blur: 2 })
89
+ : null,
90
+ environment: options.environment === undefined ? environmentOf() : options.environment,
91
+ };
92
+ };
93
+
94
+ /**
95
+ * Which way to align content instead of centring it, per axis, as Drei's `Center` props:
96
+ * `top` stands it on y = 0 (on top of the origin), `bottom` hangs it below; `left` puts
97
+ * it left of x = 0, `right` right of it; `front` before z = 0, `back` behind. `disableY`
98
+ * leaves that axis as it is.
99
+ */
100
+ export type CenterAlign = {
101
+ readonly top?: boolean;
102
+ readonly bottom?: boolean;
103
+ readonly left?: boolean;
104
+ readonly right?: boolean;
105
+ readonly front?: boolean;
106
+ readonly back?: boolean;
107
+ readonly disableX?: boolean;
108
+ readonly disableY?: boolean;
109
+ readonly disableZ?: boolean;
110
+ };
111
+
112
+ /**
113
+ * One axis of Drei's `Center`: the shift that puts the box's middle at 0, moved half its
114
+ * size up the axis (`above`, so its low side is at 0) or down it (`below`).
115
+ */
116
+ const alignAxis = (
117
+ min: number,
118
+ max: number,
119
+ above: boolean | undefined,
120
+ below: boolean | undefined,
121
+ disabled: boolean | undefined,
122
+ ): number => {
123
+ if (disabled === true) return 0;
124
+ const half = (max - min) / 2;
125
+ const shift = above === true ? half : below === true ? -half : 0;
126
+ return -(min + max) / 2 + shift;
127
+ };
128
+
129
+ /**
130
+ * Where to put content whose box (as measured where it is drawn now, at no offset) is
131
+ * `box` so that it is centred on the origin, or aligned by `align`: Drei's `Center`
132
+ * (`src/core/Center.tsx` at `bf6f4ad`), which sets its inner group to this position.
133
+ */
134
+ export const centerOffset = (box: Box, align: CenterAlign = {}): Vec3 => [
135
+ alignAxis(box.min[0], box.max[0], align.right, align.left, align.disableX),
136
+ alignAxis(box.min[1], box.max[1], align.top, align.bottom, align.disableY),
137
+ alignAxis(box.min[2], box.max[2], align.front, align.back, align.disableZ),
138
+ ];
139
+
140
+ /**
141
+ * The sizes Drei's `Stage` takes from `Center`'s report: the box's bounding sphere radius
142
+ * (half its diagonal) and its width, height and depth. Pass to `stage` for lights and
143
+ * shadows scaled to the content.
144
+ */
145
+ export const stageBounds = (
146
+ box: Box,
147
+ ): {
148
+ readonly radius: number;
149
+ readonly width: number;
150
+ readonly height: number;
151
+ readonly depth: number;
152
+ } => {
153
+ const width = box.max[0] - box.min[0];
154
+ const height = box.max[1] - box.min[1];
155
+ const depth = box.max[2] - box.min[2];
156
+ return { radius: Math.hypot(width, height, depth) / 2, width, height, depth };
157
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Lays out text from a typeface font as shapes, with letter spacing, line height and
3
+ * alignment.
4
+ *
5
+ * Behavioural reference: three.js `FontLoader.js` `createPaths` at 0.186.1 (each glyph
6
+ * advanced by its `ha`, a new line down by the font's bounding box height plus its
7
+ * underline thickness, a missing glyph drawn as `?`). Drei's `Text3D` (`bf6f4ad`) takes
8
+ * `letterSpacing` and `lineHeight` and hands them to `TextGeometry`, which ignores them;
9
+ * here they are applied, in the units troika's `Text` uses: letter spacing in ems, line
10
+ * height as a multiple of the font's own. At the defaults the shapes are three's.
11
+ *
12
+ * @module
13
+ */
14
+ import { Vector2, type Path, type Shape } from 'three';
15
+ import type { Font } from 'three/addons/loaders/FontLoader.js';
16
+
17
+ import type { TextAlign } from './description.ts';
18
+
19
+ /** How to lay the text out. */
20
+ export type TextLayout = {
21
+ readonly size: number;
22
+ /** Extra space after each glyph, in ems (multiples of `size`). */
23
+ readonly letterSpacing: number;
24
+ /** Line spacing as a multiple of the font's. */
25
+ readonly lineHeight: number;
26
+ /** Where each line sits within the widest. */
27
+ readonly align: TextAlign;
28
+ };
29
+
30
+ type Glyph = { readonly ha: number };
31
+
32
+ const corners = ['v0', 'v1', 'v2', 'v3'] as const;
33
+
34
+ /** Moves every point of a path's curves by `(dx, dy)`. */
35
+ const shiftPath = (path: Path, dx: number, dy: number): void => {
36
+ for (const curve of path.curves) {
37
+ for (const corner of corners) {
38
+ const point: unknown = Reflect.get(curve, corner);
39
+ if (point instanceof Vector2) point.set(point.x + dx, point.y + dy);
40
+ }
41
+ }
42
+ };
43
+
44
+ /** Moves a shape and its holes. */
45
+ const shiftShape = (shape: Shape, dx: number, dy: number): Shape => {
46
+ shiftPath(shape, dx, dy);
47
+ for (const hole of shape.holes) shiftPath(hole, dx, dy);
48
+ return shape;
49
+ };
50
+
51
+ /** A glyph, or `?` for one the font lacks; neither, and the character is skipped. */
52
+ const glyphOf = (font: Font, char: string): Glyph | undefined => {
53
+ const glyphs: Readonly<Record<string, Glyph | undefined>> = font.data.glyphs;
54
+ return glyphs[char] ?? glyphs['?'];
55
+ };
56
+
57
+ type Line = {
58
+ readonly chars: ReadonlyArray<string>;
59
+ readonly starts: ReadonlyArray<number>;
60
+ readonly width: number;
61
+ };
62
+
63
+ /** A line's drawable glyphs, where each starts, and its width (without trailing spacing). */
64
+ const lineOf = (font: Font, line: string, scale: number, spacing: number): Line => {
65
+ const chars: Array<string> = [];
66
+ const starts: Array<number> = [];
67
+ let x = 0;
68
+ for (const char of Array.from(line)) {
69
+ const glyph = glyphOf(font, char);
70
+ if (glyph === undefined) continue;
71
+ chars.push(char);
72
+ starts.push(x);
73
+ x += glyph.ha * scale + spacing;
74
+ }
75
+ return { chars, starts, width: chars.length === 0 ? 0 : x - spacing };
76
+ };
77
+
78
+ const alignShift = { left: () => 0, center: (gap: number) => gap / 2, right: (gap: number) => gap };
79
+
80
+ /** The shapes of `text` laid out by `layout`, the first line's baseline at y = 0. */
81
+ export const layoutText = (font: Font, text: string, layout: TextLayout): ReadonlyArray<Shape> => {
82
+ const { data } = font;
83
+ const scale = layout.size / data.resolution;
84
+ const lineStep =
85
+ (data.boundingBox.yMax - data.boundingBox.yMin + data.underlineThickness) *
86
+ scale *
87
+ layout.lineHeight;
88
+ const spacing = layout.letterSpacing * layout.size;
89
+ const lines = text.split('\n').map((line) => lineOf(font, line, scale, spacing));
90
+ const widest = Math.max(0, ...lines.map((line) => line.width));
91
+ const shapes: Array<Shape> = [];
92
+ lines.forEach((line, row) => {
93
+ const indent = alignShift[layout.align](widest - line.width);
94
+ line.chars.forEach((char, i) => {
95
+ const x = indent + (line.starts[i] ?? 0);
96
+ for (const shape of font.generateShapes(char, layout.size)) {
97
+ shapes.push(shiftShape(shape, x, -row * lineStep));
98
+ }
99
+ });
100
+ });
101
+ return shapes;
102
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Eased transitions of position and scale. A tween's pose is a pure function of scene
3
+ * time: `from + (to - from) * easing(progress)`, so frames are deterministic.
4
+ *
5
+ * @module
6
+ */
7
+ import type { Object3D, Vector3Tuple } from 'three';
8
+
9
+ import type { Easing, Vec3 } from './description.ts';
10
+
11
+ const backOvershoot = 1.70158;
12
+
13
+ /** Maps linear progress in [0, 1] through an easing curve. */
14
+ export const easeProgress = (easing: Easing, progress: number): number => {
15
+ switch (easing) {
16
+ case 'linear':
17
+ return progress;
18
+ case 'easeInOutCubic':
19
+ return progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2;
20
+ case 'easeOutCubic':
21
+ return 1 - (1 - progress) ** 3;
22
+ case 'easeOutBack':
23
+ return 1 + (backOvershoot + 1) * (progress - 1) ** 3 + backOvershoot * (progress - 1) ** 2;
24
+ }
25
+ };
26
+
27
+ /** A position and scale transition in flight. */
28
+ export type Tween = {
29
+ readonly fromPosition: Vector3Tuple;
30
+ readonly fromScale: Vector3Tuple;
31
+ readonly toPosition: Vec3;
32
+ readonly toScale: Vec3;
33
+ /** Scene time the tween started, in seconds. */
34
+ readonly start: number;
35
+ readonly seconds: number;
36
+ readonly easing: Easing;
37
+ };
38
+
39
+ /** Starts a tween from the object's current pose towards the targets. */
40
+ export const startTween = (
41
+ object: Object3D,
42
+ target: { readonly position: Vec3; readonly scale: Vec3 },
43
+ start: number,
44
+ transition: { readonly seconds: number; readonly easing: Easing },
45
+ ): Tween => ({
46
+ fromPosition: object.position.toArray(),
47
+ fromScale: object.scale.toArray(),
48
+ toPosition: target.position,
49
+ toScale: target.scale,
50
+ start,
51
+ seconds: transition.seconds,
52
+ easing: transition.easing,
53
+ });
54
+
55
+ const lerp = (from: number, to: number, amount: number): number => from + (to - from) * amount;
56
+
57
+ /**
58
+ * Writes the tween's pose at `elapsedSeconds` into the object without allocating.
59
+ * Returns true once the tween has finished, after writing the exact targets.
60
+ */
61
+ export const applyTween = (object: Object3D, tween: Tween, elapsedSeconds: number): boolean => {
62
+ const progress = Math.min(1, Math.max(0, (elapsedSeconds - tween.start) / tween.seconds));
63
+ if (progress >= 1) {
64
+ object.position.set(tween.toPosition[0], tween.toPosition[1], tween.toPosition[2]);
65
+ object.scale.set(tween.toScale[0], tween.toScale[1], tween.toScale[2]);
66
+ return true;
67
+ }
68
+ const amount = easeProgress(tween.easing, progress);
69
+ object.position.set(
70
+ lerp(tween.fromPosition[0], tween.toPosition[0], amount),
71
+ lerp(tween.fromPosition[1], tween.toPosition[1], amount),
72
+ lerp(tween.fromPosition[2], tween.toPosition[2], amount),
73
+ );
74
+ object.scale.set(
75
+ lerp(tween.fromScale[0], tween.toScale[0], amount),
76
+ lerp(tween.fromScale[1], tween.toScale[1], amount),
77
+ lerp(tween.fromScale[2], tween.toScale[2], amount),
78
+ );
79
+ return false;
80
+ };