@opsydyn/effect-three 0.4.0 → 0.6.4

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 (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -2
  3. package/dist/{index-h3vf50fn.js → index-re3rrfwd.js} +880 -79
  4. package/dist/index-re3rrfwd.js.map +56 -0
  5. package/dist/index.js +71 -2
  6. package/dist/index.js.map +3 -3
  7. package/dist/kernel/bodies.d.ts +20 -1
  8. package/dist/kernel/bodies.d.ts.map +1 -1
  9. package/dist/kernel/build.d.ts +24 -2
  10. package/dist/kernel/build.d.ts.map +1 -1
  11. package/dist/kernel/description.d.ts +68 -0
  12. package/dist/kernel/description.d.ts.map +1 -1
  13. package/dist/kernel/equality.d.ts +3 -1
  14. package/dist/kernel/equality.d.ts.map +1 -1
  15. package/dist/kernel/index.d.ts +7 -5
  16. package/dist/kernel/index.d.ts.map +1 -1
  17. package/dist/kernel/index.js +20 -2
  18. package/dist/kernel/index.js.map +1 -1
  19. package/dist/kernel/loop.d.ts.map +1 -1
  20. package/dist/kernel/paintOrder.d.ts +53 -0
  21. package/dist/kernel/paintOrder.d.ts.map +1 -0
  22. package/dist/kernel/physics.d.ts +39 -0
  23. package/dist/kernel/physics.d.ts.map +1 -1
  24. package/dist/kernel/pointer.d.ts +19 -1
  25. package/dist/kernel/pointer.d.ts.map +1 -1
  26. package/dist/kernel/preview.d.ts +32 -0
  27. package/dist/kernel/preview.d.ts.map +1 -0
  28. package/dist/kernel/sceneGraph.d.ts +3 -1
  29. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  30. package/dist/kernel/sceneIndex.d.ts +5 -0
  31. package/dist/kernel/sceneIndex.d.ts.map +1 -1
  32. package/dist/kernel/timeline.d.ts +60 -0
  33. package/dist/kernel/timeline.d.ts.map +1 -0
  34. package/dist/runtime/SceneFile.d.ts +14 -0
  35. package/dist/runtime/SceneFile.d.ts.map +1 -1
  36. package/dist/runtime/SceneSchema.d.ts +86 -0
  37. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  38. package/package.json +3 -2
  39. package/src/kernel/bodies.ts +299 -17
  40. package/src/kernel/build.ts +42 -1
  41. package/src/kernel/description.ts +68 -0
  42. package/src/kernel/equality.ts +22 -0
  43. package/src/kernel/index.ts +13 -1
  44. package/src/kernel/loop.ts +2 -0
  45. package/src/kernel/paintOrder.ts +274 -0
  46. package/src/kernel/physics.ts +86 -1
  47. package/src/kernel/pointer.ts +43 -5
  48. package/src/kernel/preview.ts +467 -0
  49. package/src/kernel/sceneGraph.ts +35 -3
  50. package/src/kernel/sceneIndex.ts +8 -0
  51. package/src/kernel/timeline.ts +93 -0
  52. package/src/runtime/SceneSchema.ts +72 -0
  53. package/dist/index-h3vf50fn.js.map +0 -53
@@ -0,0 +1,467 @@
1
+ /**
2
+ * A still of a scene as SVG text, drawn without a browser or a GPU: for previews made on a
3
+ * server, in a build or in a test. The description is applied to a scene graph as it is
4
+ * for the renderer, posed at a moment of scene time, projected with three's `Projector`
5
+ * and painted as flat-shaded paths, as three's `SVGRenderer` does
6
+ * (`examples/jsm/renderers/SVGRenderer.js` at 0.186.1), but in the order a binary space
7
+ * partition gives (`paintOrder.ts`) rather than by depth, so that faces meeting (a floor
8
+ * and what stands on it) are painted in the right order. Each face is lit by the diffuse
9
+ * terms of three's shaders (`lights_pars_begin` and `lights_physical_pars_fragment` at
10
+ * 0.186.1): ambient and hemisphere light, then directional, point and spot light by the
11
+ * face's normal with their distance and cone falloff, over π (`BRDF_Lambert`).
12
+ *
13
+ * It is a preview, not the picture: no textures, shadows, specular highlights, fog,
14
+ * environment lighting, tone mapping, post-processing, views, HUD or render-kit parts
15
+ * (sky, shader grids, wide lines), and no labels. Borrowed models and text are drawn when
16
+ * `store` lends their objects and fonts. Skinned models stand in their rest pose.
17
+ *
18
+ * @module
19
+ */
20
+ import {
21
+ Box2,
22
+ Color,
23
+ type ColorSpace,
24
+ DirectionalLight,
25
+ HemisphereLight,
26
+ InstancedMesh,
27
+ LinearSRGBColorSpace,
28
+ LineBasicMaterial,
29
+ LineDashedMaterial,
30
+ type Material,
31
+ Matrix3,
32
+ Matrix4,
33
+ Mesh,
34
+ MeshBasicMaterial,
35
+ MeshLambertMaterial,
36
+ MeshNormalMaterial,
37
+ MeshPhongMaterial,
38
+ MeshStandardMaterial,
39
+ MeshToonMaterial,
40
+ Light,
41
+ Line,
42
+ type Object3D,
43
+ PointLight,
44
+ Points,
45
+ Scene,
46
+ Sprite,
47
+ PointsMaterial,
48
+ SpotLight,
49
+ SpriteMaterial,
50
+ SRGBColorSpace,
51
+ Vector2,
52
+ Vector3,
53
+ AmbientLight,
54
+ } from 'three';
55
+ import {
56
+ Projector,
57
+ RenderableFace,
58
+ RenderableLine,
59
+ RenderableSprite,
60
+ } from 'three/addons/renderers/Projector.js';
61
+
62
+ import type { Rgb, SceneDescription } from './description.ts';
63
+ import {
64
+ type Facet,
65
+ type Mark,
66
+ paintOrder,
67
+ type Plane,
68
+ split,
69
+ type Unit,
70
+ unitOf,
71
+ } from './paintOrder.ts';
72
+ import type { ResourceStore } from './resources.ts';
73
+ import { createSceneGraph, type SceneGraphOptions } from './sceneGraph.ts';
74
+ import type { DescriptionError } from './sceneIndex.ts';
75
+
76
+ export type PreviewOptions = {
77
+ /** The picture's size in pixels; the camera takes its aspect ratio. */
78
+ readonly width: number;
79
+ readonly height: number;
80
+ /** The moment of scene time shown, in seconds: behaviours and clips as they are then. */
81
+ readonly at?: number;
82
+ /** A colour behind the scene; transparent without one. */
83
+ readonly background?: Rgb | null;
84
+ /** Lends what borrowed nodes, maps by id and text name; nothing is lent without one. */
85
+ readonly store?: ResourceStore;
86
+ /** Behaviours nodes may name, beyond the built-in ones, as for the scene graph. */
87
+ readonly behaviours?: SceneGraphOptions['behaviours'];
88
+ /** Digits after the decimal point in coordinates. */
89
+ readonly precision?: number;
90
+ };
91
+
92
+ export type PreviewResult =
93
+ | { readonly _tag: 'Preview'; readonly svg: string }
94
+ | { readonly _tag: 'Invalid'; readonly error: DescriptionError };
95
+
96
+ /** Pixels each edge of a face is widened by, as `SVGRenderer`'s `overdraw`. */
97
+ const overdraw = 0.5;
98
+
99
+ const black = new Color(0, 0, 0);
100
+
101
+ /** Diffuse irradiance on a face from every light, before `BRDF_Lambert`'s 1 / π. */
102
+ const irradianceAt = (
103
+ lights: ReadonlyArray<Light>,
104
+ position: Vector3,
105
+ normal: Vector3,
106
+ into: Color,
107
+ ): Color => {
108
+ into.copy(black);
109
+ const lightPosition = new Vector3();
110
+ const toLight = new Vector3();
111
+ const direction = new Vector3();
112
+ for (const light of lights) {
113
+ const color = light.color.clone().multiplyScalar(light.intensity);
114
+ if (light instanceof AmbientLight) {
115
+ into.add(color);
116
+ } else if (light instanceof HemisphereLight) {
117
+ lightPosition.setFromMatrixPosition(light.matrixWorld).normalize();
118
+ const weight = 0.5 * normal.dot(lightPosition) + 0.5;
119
+ into.add(light.groundColor.clone().lerp(light.color, weight).multiplyScalar(light.intensity));
120
+ } else if (light instanceof DirectionalLight) {
121
+ lightPosition.setFromMatrixPosition(light.matrixWorld);
122
+ direction.setFromMatrixPosition(light.target.matrixWorld);
123
+ toLight.subVectors(lightPosition, direction).normalize();
124
+ into.add(color.multiplyScalar(Math.max(normal.dot(toLight), 0)));
125
+ } else if (light instanceof PointLight || light instanceof SpotLight) {
126
+ lightPosition.setFromMatrixPosition(light.matrixWorld);
127
+ toLight.subVectors(lightPosition, position);
128
+ const distance = toLight.length();
129
+ toLight.normalize();
130
+ const cone = light instanceof SpotLight ? spotAttenuation(light, toLight) : 1;
131
+ const falloff = distanceAttenuation(distance, light.distance, light.decay);
132
+ into.add(color.multiplyScalar(Math.max(normal.dot(toLight), 0) * cone * falloff));
133
+ }
134
+ }
135
+ return into;
136
+ };
137
+
138
+ /** three's `getDistanceAttenuation`. */
139
+ const distanceAttenuation = (distance: number, cutoff: number, decay: number): number => {
140
+ const falloff = 1 / Math.max(distance ** decay, 0.01);
141
+ return cutoff > 0
142
+ ? falloff * Math.min(Math.max(1 - (distance / cutoff) ** 4, 0), 1) ** 2
143
+ : falloff;
144
+ };
145
+
146
+ const smoothstep = (low: number, high: number, x: number): number => {
147
+ const t = Math.min(Math.max((x - low) / (high - low), 0), 1);
148
+ return t * t * (3 - 2 * t);
149
+ };
150
+
151
+ /** three's `getSpotAttenuation`, with the cone and penumbra cosines `WebGLLights` gives it. */
152
+ const spotAttenuation = (light: SpotLight, toLight: Vector3): number => {
153
+ const direction = new Vector3()
154
+ .setFromMatrixPosition(light.matrixWorld)
155
+ .sub(new Vector3().setFromMatrixPosition(light.target.matrixWorld))
156
+ .normalize();
157
+ return smoothstep(
158
+ Math.cos(light.angle),
159
+ Math.cos(light.angle * (1 - light.penumbra)),
160
+ direction.dot(toLight),
161
+ );
162
+ };
163
+
164
+ type Painter = {
165
+ readonly lights: ReadonlyArray<Light>;
166
+ readonly normalView: Matrix3;
167
+ readonly colorSpace: ColorSpace;
168
+ };
169
+
170
+ /**
171
+ * A face's colour, as `SVGRenderer.renderFace3` with three's physical diffuse term, lit
172
+ * from its object's `centre` so that the faces of one flat side share a colour; `undefined`
173
+ * for a material it does not draw. A metal is lit as if matte: without the environment it
174
+ * reflects, three would draw it nearly black.
175
+ */
176
+ const faceColor = (
177
+ painter: Painter,
178
+ face: RenderableFace,
179
+ material: Material,
180
+ centre: Vector3,
181
+ ): Color | undefined => {
182
+ if (material instanceof MeshBasicMaterial) {
183
+ const color = material.color.clone();
184
+ return material.vertexColors ? color.multiply(face.color) : color;
185
+ }
186
+ if (material instanceof MeshNormalMaterial) {
187
+ const normal = face.normalModel.clone().applyMatrix3(painter.normalView).normalize();
188
+ return new Color(normal.x, normal.y, normal.z).multiplyScalar(0.5).addScalar(0.5);
189
+ }
190
+ if (
191
+ material instanceof MeshLambertMaterial ||
192
+ material instanceof MeshPhongMaterial ||
193
+ material instanceof MeshStandardMaterial ||
194
+ material instanceof MeshToonMaterial
195
+ ) {
196
+ const diffuse = material.color.clone();
197
+ if (material.vertexColors) diffuse.multiply(face.color);
198
+ return irradianceAt(painter.lights, centre, face.normalModel, new Color())
199
+ .multiply(diffuse)
200
+ .multiplyScalar(1 / Math.PI)
201
+ .add(material.emissive.clone().multiplyScalar(material.emissiveIntensity));
202
+ }
203
+ return undefined;
204
+ };
205
+
206
+ /** A colour as SVG writes it, clamped to what a display shows. */
207
+ const styleOf = (color: Color, colorSpace: ColorSpace): string => {
208
+ const shown = new Color(
209
+ Math.min(Math.max(color.r, 0), 1),
210
+ Math.min(Math.max(color.g, 0), 1),
211
+ Math.min(Math.max(color.b, 0), 1),
212
+ );
213
+ return `#${shown.getHexString(colorSpace)}`;
214
+ };
215
+
216
+ /** What a material's opacity is worth: three blends only a transparent material. */
217
+ const opacityOf = (material: Material): number => (material.transparent ? material.opacity : 1);
218
+
219
+ /**
220
+ * Stands in for what an object draws, posed where it is in the world, for `Projector` to
221
+ * project alone: each instance of an instanced mesh as a mesh of its own (`Projector` draws
222
+ * an instanced mesh's geometry once), its colour copied into its material.
223
+ */
224
+ const standInsFor = (
225
+ object: Object3D,
226
+ ): ReadonlyArray<{ readonly standIn: Object3D; readonly tinted?: Material }> => {
227
+ if (!(object instanceof InstancedMesh)) {
228
+ const standIn = object.clone(false);
229
+ standIn.matrix.copy(object.matrixWorld);
230
+ return [{ standIn }];
231
+ }
232
+ const material = Array.isArray(object.material) ? object.material[0] : object.material;
233
+ if (material === undefined) return [];
234
+ const matrix = new Matrix4();
235
+ const standIns: Array<{ readonly standIn: Object3D; readonly tinted?: Material }> = [];
236
+ for (let i = 0; i < object.count; i++) {
237
+ object.getMatrixAt(i, matrix);
238
+ const tinted = object.instanceColor === null ? undefined : material.clone();
239
+ if (tinted !== undefined && 'color' in tinted && tinted.color instanceof Color) {
240
+ tinted.color.multiply(object.getColorAt(i, new Color()));
241
+ }
242
+ const standIn = new Mesh(object.geometry, tinted ?? material);
243
+ standIn.matrix.multiplyMatrices(object.matrixWorld, matrix);
244
+ standIn.renderOrder = object.renderOrder;
245
+ standIns.push(tinted === undefined ? { standIn } : { standIn, tinted });
246
+ }
247
+ return standIns;
248
+ };
249
+
250
+ const number =
251
+ (precision: number) =>
252
+ (value: number): string =>
253
+ String(Number(value.toFixed(precision)));
254
+
255
+ /** A shape's paint, and for a mark its outline on the picture. */
256
+ type Item = { readonly paint: string; readonly d: string };
257
+
258
+ /** Widens each edge of a polygon on the picture by `overdraw` pixels, as `SVGRenderer.expand`. */
259
+ const widened = (points: ReadonlyArray<Vector2>): ReadonlyArray<Vector2> => {
260
+ const out = points.map((point) => point.clone());
261
+ for (const [i, from] of out.entries()) {
262
+ const to = out[(i + 1) % out.length];
263
+ const length = to === undefined ? 0 : from.distanceTo(to);
264
+ if (to === undefined || length === 0) continue;
265
+ const step = new Vector2().subVectors(to, from).multiplyScalar(overdraw / length);
266
+ to.add(step);
267
+ from.sub(step);
268
+ }
269
+ return out;
270
+ };
271
+
272
+ /**
273
+ * A face's paint. An opaque one is also outlined in its colour, a little (the width is set
274
+ * once, on the group of every path), so that the pieces a face is cut into show no seams.
275
+ */
276
+ const facetPaint = (fill: string, opacity: number): string =>
277
+ opacity < 1
278
+ ? `fill="${fill}" fill-opacity="${String(Number(opacity.toFixed(2)))}"`
279
+ : `fill="${fill}" stroke="${fill}"`;
280
+
281
+ /** Where an object's bounds are centred in the world. */
282
+ const centreOf = (object: Object3D): Vector3 => {
283
+ if (!(object instanceof Mesh)) return new Vector3().setFromMatrixPosition(object.matrixWorld);
284
+ const geometry = object.geometry;
285
+ if (geometry.boundingSphere === null) geometry.computeBoundingSphere();
286
+ return (geometry.boundingSphere?.center.clone() ?? new Vector3()).applyMatrix4(
287
+ object.matrixWorld,
288
+ );
289
+ };
290
+
291
+ const isDrawn = (object: Object3D): boolean =>
292
+ object instanceof Mesh ||
293
+ object instanceof Line ||
294
+ object instanceof Points ||
295
+ object instanceof Sprite;
296
+
297
+ /**
298
+ * Draws `description` as SVG text `width` by `height` pixels, at `at` seconds of scene time,
299
+ * or says why the description is invalid.
300
+ */
301
+ export const previewSvg = (
302
+ description: SceneDescription,
303
+ options: PreviewOptions,
304
+ ): PreviewResult => {
305
+ const { width, height } = options;
306
+ const graph = createSceneGraph({
307
+ ...(options.store === undefined ? {} : { store: options.store }),
308
+ ...(options.behaviours === undefined ? {} : { behaviours: options.behaviours }),
309
+ });
310
+ const applied = graph.reconcile(description);
311
+ if (applied._tag === 'Invalid') {
312
+ graph.dispose();
313
+ return applied;
314
+ }
315
+ graph.resize(width, height);
316
+ graph.tick(options.at ?? 0);
317
+ const { scene, camera } = graph;
318
+ scene.updateMatrixWorld();
319
+ camera.updateMatrixWorld();
320
+
321
+ const lights: Array<Light> = [];
322
+ const drawn: Array<Object3D> = [];
323
+ scene.traverseVisible((object) => {
324
+ if (object instanceof Light) lights.push(object);
325
+ else if (isDrawn(object)) drawn.push(object);
326
+ });
327
+ const colorSpace =
328
+ graph.renderSettings().colorSpace === 'linear' ? LinearSRGBColorSpace : SRGBColorSpace;
329
+ const painter: Painter = {
330
+ lights,
331
+ normalView: new Matrix3().getNormalMatrix(camera.matrixWorldInverse),
332
+ colorSpace,
333
+ };
334
+ const halfWidth = width / 2;
335
+ const halfHeight = height / 2;
336
+ const at = number(options.precision ?? 1);
337
+ const eye = new Vector3().setFromMatrixPosition(camera.matrixWorld);
338
+ const ahead = camera.getWorldDirection(new Vector3());
339
+ // Only what is past the near plane is drawn: the rest would project behind the eye.
340
+ const near: Plane = { normal: ahead, constant: ahead.dot(eye) + camera.near };
341
+ const xy = (x: number, y: number): string => `${at(x * halfWidth)},${at(-y * halfHeight)}`;
342
+
343
+ /** A face as lit, the part of it past the near plane; none for a material not drawn. */
344
+ function facetOf(
345
+ face: RenderableFace,
346
+ material: Material,
347
+ centre: Vector3,
348
+ opacity: number,
349
+ ): Facet<Item> | undefined {
350
+ const color = faceColor(painter, face, material, centre);
351
+ if (color === undefined) return undefined;
352
+ const points = [face.v1, face.v2, face.v3].map((vertex) => vertex.positionWorld.clone());
353
+ const paint = facetPaint(styleOf(color, colorSpace), opacity);
354
+ return split(near, { _tag: 'Facet', points, item: { paint, d: '' } }).front;
355
+ }
356
+
357
+ // Each object stands in alone, so that what it draws is known as one unit. What is not
358
+ // depth-tested (gizmos drawn over everything) is painted after the rest, by render
359
+ // order, as three draws it.
360
+ const projector = new Projector();
361
+ const holder = new Scene();
362
+ const units: Array<Unit<Item>> = [];
363
+ const over: Array<{ readonly renderOrder: number; readonly item: Facet<Item> | Mark<Item> }> = [];
364
+ const tinted: Array<Material> = [];
365
+ for (const object of drawn) {
366
+ for (const { standIn, tinted: copy } of standInsFor(object)) {
367
+ if (copy !== undefined) tinted.push(copy);
368
+ standIn.matrixAutoUpdate = false;
369
+ holder.add(standIn);
370
+ const projected = projector.projectScene(holder, camera, false, true);
371
+ holder.remove(standIn);
372
+ const centre = centreOf(standIn);
373
+ const facets: Array<Facet<Item>> = [];
374
+ const marks: Array<Mark<Item>> = [];
375
+ const keep = (material: Material, item: Facet<Item> | Mark<Item>): void => {
376
+ if (!material.depthTest) over.push({ renderOrder: standIn.renderOrder, item });
377
+ else if (item._tag === 'Facet') facets.push(item);
378
+ else marks.push(item);
379
+ };
380
+ for (const element of projected.elements) {
381
+ const material: Material | null = element.material;
382
+ if (material === null || material.opacity === 0) continue;
383
+ const opacity = opacityOf(material);
384
+ const faded = opacity < 1 ? ` fill-opacity="${at(opacity)}"` : '';
385
+ if (element instanceof RenderableFace) {
386
+ const seen = facetOf(element, material, centre, opacity);
387
+ if (seen === undefined) continue;
388
+ keep(material, seen);
389
+ } else if (element instanceof RenderableLine && material instanceof LineBasicMaterial) {
390
+ const a = element.v1.positionScreen;
391
+ const b = element.v2.positionScreen;
392
+ const dashes =
393
+ material instanceof LineDashedMaterial
394
+ ? ` stroke-dasharray="${at(material.dashSize)},${at(material.gapSize)}"`
395
+ : '';
396
+ const stroked = opacity < 1 ? ` stroke-opacity="${at(opacity)}"` : '';
397
+ keep(material, {
398
+ _tag: 'Mark',
399
+ at: element.v1.positionWorld.clone().lerp(element.v2.positionWorld, 0.5),
400
+ item: {
401
+ paint: `fill="none" stroke="${styleOf(material.color, colorSpace)}"${stroked} stroke-width="${at(material.linewidth)}" stroke-linecap="${material.linecap}"${dashes}`,
402
+ d: `M${xy(a.x, a.y)}L${xy(b.x, b.y)}`,
403
+ },
404
+ });
405
+ } else if (
406
+ element instanceof RenderableSprite &&
407
+ (material instanceof SpriteMaterial || material instanceof PointsMaterial)
408
+ ) {
409
+ const size = material instanceof PointsMaterial ? material.size : 1;
410
+ const w = element.scale.x * halfWidth * size;
411
+ const h = element.scale.y * halfHeight * size;
412
+ const x = element.x * halfWidth;
413
+ const y = -element.y * halfHeight;
414
+ keep(material, {
415
+ _tag: 'Mark',
416
+ at: new Vector3(element.x, element.y, element.z).unproject(camera),
417
+ item: {
418
+ paint: `fill="${styleOf(material.color, colorSpace)}"${faded}`,
419
+ d: `M${at(x - w / 2)},${at(y - h / 2)}h${at(w)}v${at(h)}h${at(-w)}z`,
420
+ },
421
+ });
422
+ }
423
+ }
424
+ if (facets.length + marks.length > 0) units.push(unitOf(facets, marks, eye));
425
+ }
426
+ }
427
+ for (const material of tinted) material.dispose();
428
+ graph.dispose();
429
+
430
+ const ordered = [
431
+ ...paintOrder(units, eye),
432
+ ...over.toSorted((a, b) => a.renderOrder - b.renderOrder).map(({ item }) => item),
433
+ ];
434
+
435
+ // Consecutive shapes of one paint share a path, as `SVGRenderer.addPath` does.
436
+ const clip = new Box2(new Vector2(-halfWidth, -halfHeight), new Vector2(halfWidth, halfHeight));
437
+ const toPicture = (point: Vector3): Vector2 => {
438
+ const projectedPoint = point.clone().project(camera);
439
+ return new Vector2(projectedPoint.x * halfWidth, -projectedPoint.y * halfHeight);
440
+ };
441
+ /** A shape's outline on the picture, or none when it falls outside it. */
442
+ function outlineOf(shape: Facet<Item> | Mark<Item>): string | undefined {
443
+ if (shape._tag === 'Mark') return shape.item.d;
444
+ const corners = widened(shape.points.map(toPicture));
445
+ return new Box2().setFromPoints([...corners]).intersectsBox(clip)
446
+ ? `M${corners.map((corner) => `${at(corner.x)},${at(corner.y)}`).join('L')}z`
447
+ : undefined;
448
+ }
449
+ const groups: Array<{ readonly paint: string; readonly outlines: Array<string> }> = [];
450
+ for (const shape of ordered) {
451
+ const outline = outlineOf(shape);
452
+ const last = groups.at(-1);
453
+ if (outline === undefined) continue;
454
+ if (last?.paint === shape.item.paint) last.outlines.push(outline);
455
+ else groups.push({ paint: shape.item.paint, outlines: [outline] });
456
+ }
457
+ const paths = groups.map(({ paint, outlines }) => `<path ${paint} d="${outlines.join('')}"/>`);
458
+
459
+ const background =
460
+ options.background === undefined || options.background === null
461
+ ? ''
462
+ : `<rect x="${at(-halfWidth)}" y="${at(-halfHeight)}" width="${at(width)}" height="${at(height)}" fill="${styleOf(new Color(options.background), colorSpace)}"/>`;
463
+ return {
464
+ _tag: 'Preview',
465
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${at(width)}" height="${at(height)}" viewBox="${at(-halfWidth)} ${at(-halfHeight)} ${at(width)} ${at(height)}">${background}<g stroke-width="0.6" stroke-linejoin="round">${paths.join('')}</g></svg>`,
466
+ };
467
+ };
@@ -30,7 +30,12 @@ import { clone as cloneSkinned } from 'three/addons/utils/SkeletonUtils.js';
30
30
 
31
31
  import { clipRunning, clipTime } from './animation.ts';
32
32
  import { writeRotation } from './behaviours.ts';
33
- import { type BodyCollision, type BodyContactForce, createBodyHost } from './bodies.ts';
33
+ import {
34
+ type BodyCollision,
35
+ type BodyContactForce,
36
+ createBodyHost,
37
+ type PhysicsReached,
38
+ } from './bodies.ts';
34
39
  import { type BodySource, instanceSources, nodeSource, placeObject } from './bodySources.ts';
35
40
  import { orthographicCamera, renderSettings, scene as describeScene } from './build.ts';
36
41
  import { type CameraMotion, createCameraMotion } from './cameraMotion.ts';
@@ -298,6 +303,8 @@ export type SceneGraph = {
298
303
  readonly collisions: () => ReadonlyArray<BodyCollision>;
299
304
  /** Contact forces over their bodies' thresholds since the last call. */
300
305
  readonly contactForces: () => ReadonlyArray<BodyContactForce>;
306
+ /** Steps a world on `physics({ until })` has reached since the last call. */
307
+ readonly physicsReached: () => ReadonlyArray<PhysicsReached>;
301
308
  /** How the scene is presented immersively, for the host; `null` if it never is. */
302
309
  readonly xr: () => Xr | null;
303
310
  /** Keys of the nodes whose bounds the frame loop reports. */
@@ -614,6 +621,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
614
621
  const spinning = new Set<Entry>();
615
622
  // Billboards: turned towards the camera each frame, after everything else has moved.
616
623
  const facing = new Set<Entry>();
624
+ // Parents first, so a billboard inside a billboard reads this frame's parent turn.
625
+ const facingOrder: Array<Entry> = [];
617
626
  const cameraTurn = new Quaternion();
618
627
  const parentTurn = new Quaternion();
619
628
  // Points that move by themselves (sparkles, stars), ticked in scene time while animating.
@@ -716,6 +725,24 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
716
725
  if (lockZ) object.rotation.z = 0;
717
726
  };
718
727
 
728
+ const depthOf = (object: Object3D): number => {
729
+ let depth = 0;
730
+ for (let parent = object.parent; parent !== null; parent = parent.parent) depth += 1;
731
+ return depth;
732
+ };
733
+
734
+ /**
735
+ * Faces every billboard, shallowest first: a nested one is turned against its parent's
736
+ * world rotation, so the parent must have turned this frame. Drei gets this from React
737
+ * running a parent's frame callback before its child's.
738
+ */
739
+ const faceCameras = (): void => {
740
+ facingOrder.length = 0;
741
+ facing.forEach((entry) => facingOrder.push(entry));
742
+ facingOrder.sort((a, b) => depthOf(a.object) - depthOf(b.object));
743
+ facingOrder.forEach(faceCamera);
744
+ };
745
+
719
746
  /** Where a trail follows its node: the world position, or with `local` its own. */
720
747
  const trailTarget = (object: Object3D, local: boolean): Vector3 =>
721
748
  local ? trailPlace.copy(object.position) : object.getWorldPosition(trailPlace);
@@ -1543,6 +1570,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1543
1570
  physics: null,
1544
1571
  xr: null,
1545
1572
  measure: [],
1573
+ assets: [],
1546
1574
  render: parent.render,
1547
1575
  resources: parent.resources,
1548
1576
  children: texture.children,
@@ -1712,6 +1740,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1712
1740
  if ((physicsDescription?.debug ?? false) !== (next.physics?.debug ?? false)) invalidate();
1713
1741
  physicsDescription = next.physics;
1714
1742
  bodyHost.sync(next.physics, bodySources());
1743
+ // A world on `until` that owes steps needs frames, though nothing else may be moving.
1744
+ if (bodyHost.owesSteps()) invalidate();
1715
1745
  // Read by the host when it presents the scene immersively: no operation, no frame.
1716
1746
  xrDescription = next.xr;
1717
1747
  // Measured after the next drawn frame; a new list needs one.
@@ -1749,7 +1779,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1749
1779
  // After the camera and everything a billboard may sit on have moved.
1750
1780
  if (facing.size > 0) {
1751
1781
  camera.getWorldQuaternion(cameraTurn);
1752
- facing.forEach(faceCamera);
1782
+ faceCameras();
1753
1783
  }
1754
1784
  edged.forEach(refreshEdges);
1755
1785
  // Reduced motion holds sparkles and stars where they are.
@@ -1825,6 +1855,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1825
1855
  contactShadows: () => contactDescription,
1826
1856
  collisions: bodyHost.drainCollisions,
1827
1857
  contactForces: bodyHost.drainContactForces,
1858
+ physicsReached: bodyHost.drainReached,
1828
1859
  xr: () => xrDescription,
1829
1860
  measured: () => measured,
1830
1861
  labelLayout: (key) => {
@@ -1859,7 +1890,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1859
1890
  viewsMoving() ||
1860
1891
  shaking() ||
1861
1892
  sparkling.size > 0 ||
1862
- bodyHost.needsFrame())),
1893
+ bodyHost.needsFrame())) ||
1894
+ bodyHost.owesSteps(),
1863
1895
  drawn: () => {
1864
1896
  dirty = false;
1865
1897
  },
@@ -32,6 +32,8 @@ export type DescriptionError =
32
32
  | { readonly _tag: 'UnknownOverlayNode'; readonly key: string; readonly node: string }
33
33
  | { readonly _tag: 'UnknownOverlayView'; readonly key: string; readonly view: string }
34
34
  | { readonly _tag: 'UnknownMeasuredNode'; readonly key: string }
35
+ /** Two assets the scene asks for share an id. */
36
+ | { readonly _tag: 'DuplicateAssetId'; readonly id: string }
35
37
  /** Two joints share a key. */
36
38
  | { readonly _tag: 'DuplicateJointKey'; readonly key: string }
37
39
  /** A joint names a node that is not in the scene. */
@@ -104,6 +106,12 @@ export const indexScene = (
104
106
  if (duplicateView !== undefined) {
105
107
  return { _tag: 'Invalid', error: { _tag: 'DuplicateViewKey', key: duplicateView } };
106
108
  }
109
+ const duplicateAsset = firstDuplicateInstance(
110
+ description.assets.map((asset) => ({ key: asset.id })),
111
+ );
112
+ if (duplicateAsset !== undefined) {
113
+ return { _tag: 'Invalid', error: { _tag: 'DuplicateAssetId', id: duplicateAsset } };
114
+ }
107
115
  const duplicateOverlay = firstDuplicateInstance(description.overlays);
108
116
  if (duplicateOverlay !== undefined) {
109
117
  return { _tag: 'Invalid', error: { _tag: 'DuplicateOverlayKey', key: duplicateOverlay } };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A timeline of physics steps, as data (ADR-014): inputs stamped with the step they apply
3
+ * at, and how far a world on `physics({ until })` may go. Peers (or a replay) that give
4
+ * the same inputs at the same steps to the same world get the same world, however their
5
+ * frames fell, because every change lands while the world waits at its step.
6
+ *
7
+ * The Model keeps a `Timeline`, `schedule`s inputs as they are agreed (a lockstep turn's,
8
+ * a recording's), moves the `frontier` as far as is known, `reach`es each step the world
9
+ * reports (`PhysicsReached`), and applies what `due` hands back. Its scene's physics is
10
+ * `physics({ until: Timeline.until(timeline) })`: the next step with inputs, or the
11
+ * frontier. Use it as a namespace: `Timeline.make()`.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /** An input, and the step it applies at. */
17
+ export type Scheduled<I> = { readonly step: number; readonly input: I };
18
+
19
+ export type Timeline<I> = {
20
+ /** Every input, in the order they apply: by step, then as scheduled. */
21
+ readonly inputs: ReadonlyArray<Scheduled<I>>;
22
+ /** How many of `inputs` have been handed out by `due`. */
23
+ readonly applied: number;
24
+ /** The step the world last said it reached; `null` before it says any. */
25
+ readonly reached: number | null;
26
+ /** How far the world may go before more inputs could arrive. */
27
+ readonly frontier: number;
28
+ };
29
+
30
+ /** An empty timeline, the world allowed as far as `frontier`. */
31
+ export const make = <I>(frontier = 0): Timeline<I> => ({
32
+ inputs: [],
33
+ applied: 0,
34
+ reached: null,
35
+ frontier,
36
+ });
37
+
38
+ /**
39
+ * `input` at `step`, after any already there. The step must be one the world has not
40
+ * passed: not before `until`. A lockstep session stamps a turn's inputs with that turn's
41
+ * step, which is the frontier until the turn applies.
42
+ */
43
+ export const schedule = <I>(timeline: Timeline<I>, step: number, input: I): Timeline<I> => {
44
+ const at = timeline.inputs.findIndex((scheduled) => scheduled.step > step);
45
+ const index = at < 0 ? timeline.inputs.length : at;
46
+ return {
47
+ ...timeline,
48
+ inputs: [...timeline.inputs.slice(0, index), { step, input }, ...timeline.inputs.slice(index)],
49
+ };
50
+ };
51
+
52
+ /** The world may now go as far as `frontier` (a timeline's frontier never goes back). */
53
+ export const extend = <I>(timeline: Timeline<I>, frontier: number): Timeline<I> =>
54
+ frontier <= timeline.frontier ? timeline : { ...timeline, frontier };
55
+
56
+ /** The world said it reached `step`. */
57
+ export const reach = <I>(timeline: Timeline<I>, step: number): Timeline<I> =>
58
+ timeline.reached === step ? timeline : { ...timeline, reached: step };
59
+
60
+ /**
61
+ * The inputs for the step the world is at, to apply now, and the timeline without them.
62
+ * Applied while the world waits there, they land at that step.
63
+ */
64
+ export const due = <I>(
65
+ timeline: Timeline<I>,
66
+ ): { readonly timeline: Timeline<I>; readonly inputs: ReadonlyArray<I> } => {
67
+ const { reached } = timeline;
68
+ if (reached === null) return { timeline, inputs: [] };
69
+ let applied = timeline.applied;
70
+ while ((timeline.inputs[applied]?.step ?? Infinity) <= reached) applied += 1;
71
+ if (applied === timeline.applied) return { timeline, inputs: [] };
72
+ return {
73
+ timeline: { ...timeline, applied },
74
+ inputs: timeline.inputs.slice(timeline.applied, applied).map((scheduled) => scheduled.input),
75
+ };
76
+ };
77
+
78
+ /**
79
+ * Where the world should stop next: at the next step with inputs not yet applied, or at
80
+ * the frontier. Give it as `physics({ until })`.
81
+ */
82
+ export const until = <I>(timeline: Timeline<I>): number =>
83
+ Math.min(timeline.inputs[timeline.applied]?.step ?? Infinity, timeline.frontier);
84
+
85
+ /**
86
+ * The same inputs from the start, for a new world (a newcomer to a session, a replay):
87
+ * nothing applied or reached yet, the frontier where it was.
88
+ */
89
+ export const restart = <I>(timeline: Timeline<I>): Timeline<I> => ({
90
+ ...timeline,
91
+ applied: 0,
92
+ reached: null,
93
+ });