@opsydyn/effect-three 0.3.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 (143) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +15 -1
  3. package/dist/{index-8cf8t6by.js → index-re3rrfwd.js} +2935 -104
  4. package/dist/index-re3rrfwd.js.map +56 -0
  5. package/dist/index-ttwy0znv.js +145 -0
  6. package/dist/index-ttwy0znv.js.map +12 -0
  7. package/dist/index.js +557 -147
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/bodies.d.ts +20 -1
  10. package/dist/kernel/bodies.d.ts.map +1 -1
  11. package/dist/kernel/boxSelect.d.ts +22 -0
  12. package/dist/kernel/boxSelect.d.ts.map +1 -0
  13. package/dist/kernel/build.d.ts +165 -7
  14. package/dist/kernel/build.d.ts.map +1 -1
  15. package/dist/kernel/cameraShake.d.ts.map +1 -1
  16. package/dist/kernel/describeScene.d.ts +50 -0
  17. package/dist/kernel/describeScene.d.ts.map +1 -0
  18. package/dist/kernel/description.d.ts +223 -1
  19. package/dist/kernel/description.d.ts.map +1 -1
  20. package/dist/kernel/diff.d.ts +15 -0
  21. package/dist/kernel/diff.d.ts.map +1 -1
  22. package/dist/kernel/equality.d.ts +11 -1
  23. package/dist/kernel/equality.d.ts.map +1 -1
  24. package/dist/kernel/gizmo.d.ts +69 -0
  25. package/dist/kernel/gizmo.d.ts.map +1 -0
  26. package/dist/kernel/history.d.ts +61 -0
  27. package/dist/kernel/history.d.ts.map +1 -0
  28. package/dist/kernel/index.d.ts +19 -6
  29. package/dist/kernel/index.d.ts.map +1 -1
  30. package/dist/kernel/index.js +124 -3
  31. package/dist/kernel/index.js.map +1 -1
  32. package/dist/kernel/inspect.d.ts +85 -0
  33. package/dist/kernel/inspect.d.ts.map +1 -0
  34. package/dist/kernel/lineGeometry.d.ts +25 -0
  35. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  36. package/dist/kernel/lockstep.d.ts +114 -0
  37. package/dist/kernel/lockstep.d.ts.map +1 -0
  38. package/dist/kernel/loop.d.ts.map +1 -1
  39. package/dist/kernel/materials.d.ts.map +1 -1
  40. package/dist/kernel/paintOrder.d.ts +53 -0
  41. package/dist/kernel/paintOrder.d.ts.map +1 -0
  42. package/dist/kernel/physics.d.ts +39 -0
  43. package/dist/kernel/physics.d.ts.map +1 -1
  44. package/dist/kernel/pivot.d.ts +54 -0
  45. package/dist/kernel/pivot.d.ts.map +1 -0
  46. package/dist/kernel/pointBuffers.d.ts +17 -0
  47. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  48. package/dist/kernel/pointer.d.ts +31 -1
  49. package/dist/kernel/pointer.d.ts.map +1 -1
  50. package/dist/kernel/preview.d.ts +32 -0
  51. package/dist/kernel/preview.d.ts.map +1 -0
  52. package/dist/kernel/renderKit.d.ts +21 -1
  53. package/dist/kernel/renderKit.d.ts.map +1 -1
  54. package/dist/kernel/renderer.d.ts +6 -0
  55. package/dist/kernel/renderer.d.ts.map +1 -1
  56. package/dist/kernel/sceneChanges.d.ts +87 -0
  57. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  58. package/dist/kernel/sceneGraph.d.ts +9 -1
  59. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  60. package/dist/kernel/sceneIndex.d.ts +5 -0
  61. package/dist/kernel/sceneIndex.d.ts.map +1 -1
  62. package/dist/kernel/seeded.d.ts +9 -0
  63. package/dist/kernel/seeded.d.ts.map +1 -0
  64. package/dist/kernel/shots.d.ts +26 -0
  65. package/dist/kernel/shots.d.ts.map +1 -0
  66. package/dist/kernel/tape.d.ts +38 -0
  67. package/dist/kernel/tape.d.ts.map +1 -0
  68. package/dist/kernel/timeline.d.ts +60 -0
  69. package/dist/kernel/timeline.d.ts.map +1 -0
  70. package/dist/kernel/trail.d.ts +31 -0
  71. package/dist/kernel/trail.d.ts.map +1 -0
  72. package/dist/kernel/viewportGizmo.d.ts +25 -0
  73. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  74. package/dist/runtime/Inspector.d.ts +20 -0
  75. package/dist/runtime/Inspector.d.ts.map +1 -0
  76. package/dist/runtime/SceneFile.d.ts +462 -0
  77. package/dist/runtime/SceneFile.d.ts.map +1 -0
  78. package/dist/runtime/SceneSchema.d.ts +404 -0
  79. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  80. package/dist/runtime/errors.d.ts +9 -0
  81. package/dist/runtime/errors.d.ts.map +1 -1
  82. package/dist/runtime/index.d.ts +3 -1
  83. package/dist/runtime/index.d.ts.map +1 -1
  84. package/dist/webgl/index.d.ts.map +1 -1
  85. package/dist/webgl/index.js +228 -3
  86. package/dist/webgl/index.js.map +6 -4
  87. package/dist/webgl/line.d.ts +4 -0
  88. package/dist/webgl/line.d.ts.map +1 -0
  89. package/dist/webgl/points.d.ts +4 -0
  90. package/dist/webgl/points.d.ts.map +1 -0
  91. package/dist/webgpu/index.d.ts.map +1 -1
  92. package/dist/webgpu/index.js +206 -26
  93. package/dist/webgpu/index.js.map +6 -4
  94. package/dist/webgpu/line.d.ts +4 -0
  95. package/dist/webgpu/line.d.ts.map +1 -0
  96. package/dist/webgpu/points.d.ts +4 -0
  97. package/dist/webgpu/points.d.ts.map +1 -0
  98. package/package.json +4 -2
  99. package/src/kernel/bodies.ts +299 -17
  100. package/src/kernel/boxSelect.ts +73 -0
  101. package/src/kernel/build.ts +374 -1
  102. package/src/kernel/cameraShake.ts +3 -13
  103. package/src/kernel/describeScene.ts +224 -0
  104. package/src/kernel/description.ts +243 -0
  105. package/src/kernel/diff.ts +39 -0
  106. package/src/kernel/equality.ts +123 -0
  107. package/src/kernel/gizmo.ts +478 -0
  108. package/src/kernel/history.ts +118 -0
  109. package/src/kernel/index.ts +110 -1
  110. package/src/kernel/inspect.ts +214 -0
  111. package/src/kernel/lineGeometry.ts +75 -0
  112. package/src/kernel/lockstep.ts +237 -0
  113. package/src/kernel/loop.ts +7 -0
  114. package/src/kernel/materials.ts +3 -0
  115. package/src/kernel/paintOrder.ts +274 -0
  116. package/src/kernel/physics.ts +86 -1
  117. package/src/kernel/pivot.ts +418 -0
  118. package/src/kernel/pointBuffers.ts +115 -0
  119. package/src/kernel/pointer.ts +98 -17
  120. package/src/kernel/preview.ts +467 -0
  121. package/src/kernel/renderKit.ts +23 -1
  122. package/src/kernel/renderer.ts +6 -0
  123. package/src/kernel/sceneChanges.ts +265 -0
  124. package/src/kernel/sceneGraph.ts +354 -11
  125. package/src/kernel/sceneIndex.ts +8 -0
  126. package/src/kernel/seeded.ts +18 -0
  127. package/src/kernel/shots.ts +167 -0
  128. package/src/kernel/tape.ts +124 -0
  129. package/src/kernel/timeline.ts +93 -0
  130. package/src/kernel/trail.ts +170 -0
  131. package/src/kernel/viewportGizmo.ts +194 -0
  132. package/src/runtime/Inspector.ts +43 -0
  133. package/src/runtime/SceneFile.ts +46 -0
  134. package/src/runtime/SceneSchema.ts +319 -0
  135. package/src/runtime/errors.ts +6 -0
  136. package/src/runtime/index.ts +3 -1
  137. package/src/webgl/index.ts +4 -0
  138. package/src/webgl/line.ts +66 -0
  139. package/src/webgl/points.ts +205 -0
  140. package/src/webgpu/index.ts +4 -0
  141. package/src/webgpu/line.ts +67 -0
  142. package/src/webgpu/points.ts +193 -0
  143. package/dist/index-8cf8t6by.js.map +0 -41
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Setting up a shot by name (roadmap, "Beyond parity" 3): a vocabulary an assistant (or
3
+ * a person) can use instead of numbers. `shotCamera` frames a box from a named angle, as
4
+ * a photographer would ask for it, through `fitCamera`; `moodStaging` turns a lighting
5
+ * mood into lights, image-based lighting and a sky. Both return descriptions to keep in
6
+ * the Model, so a shot is undoable and replayable like any other change.
7
+ *
8
+ * @module
9
+ */
10
+ import { Vector3 } from 'three';
11
+
12
+ import {
13
+ directionalLight,
14
+ environment,
15
+ hemisphereLight,
16
+ sky,
17
+ sunPositionFromAngles,
18
+ } from './build.ts';
19
+ import type { Camera, Environment, SceneNode, Sky, Vec3 } from './description.ts';
20
+ import { type Box, fitCamera } from './orbit.ts';
21
+
22
+ /** Where the camera looks from, round the subject. */
23
+ export type ShotAngle = 'front' | 'back' | 'left' | 'right' | 'top' | 'three-quarter' | 'low';
24
+
25
+ export const shotAngles: ReadonlyArray<ShotAngle> = [
26
+ 'front',
27
+ 'back',
28
+ 'left',
29
+ 'right',
30
+ 'top',
31
+ 'three-quarter',
32
+ 'low',
33
+ ];
34
+
35
+ /**
36
+ * From the subject towards the camera, for each angle: in front is +z, as three's
37
+ * cameras look down −z; the top view stays a hair off the pole, as `OrbitControls` does.
38
+ */
39
+ const directionOf: Record<ShotAngle, Vec3> = {
40
+ front: [0, 0.15, 1],
41
+ back: [0, 0.15, -1],
42
+ left: [-1, 0.15, 0],
43
+ right: [1, 0.15, 0],
44
+ top: [0, 1, 1e-4],
45
+ 'three-quarter': [1, 0.7, 1],
46
+ low: [0.35, -0.02, 1],
47
+ };
48
+
49
+ /**
50
+ * The camera looking at `subject` from `angle`, as close as it can be with the box
51
+ * still inside the frame (`margin` 1.3 leaves room round it). `area` is the canvas's
52
+ * size, or its shape: only the ratio matters to a perspective camera.
53
+ */
54
+ export const shotCamera = <C extends Camera>(
55
+ camera: C,
56
+ subject: Box,
57
+ angle: ShotAngle,
58
+ area: { readonly width: number; readonly height: number } = { width: 16, height: 10 },
59
+ margin = 1.3,
60
+ ): C => {
61
+ const centre = new Vector3(...subject.min).add(new Vector3(...subject.max)).multiplyScalar(0.5);
62
+ const from = new Vector3(...directionOf[angle]).normalize().add(centre);
63
+ const aimed = {
64
+ ...camera,
65
+ position: [from.x, from.y, from.z],
66
+ lookAt: [centre.x, centre.y, centre.z],
67
+ } as C;
68
+ // SAFETY: `fitCamera` returns the camera it was given with only its position, aim and
69
+ // zoom changed, so the kind of camera is the one passed in.
70
+ return fitCamera(aimed, subject, area, margin) as C;
71
+ };
72
+
73
+ /** A lighting mood, from the even light of a studio to a clear night. */
74
+ export type Mood = 'studio' | 'daylight' | 'sunset' | 'night';
75
+
76
+ export const moods: ReadonlyArray<Mood> = ['studio', 'daylight', 'sunset', 'night'];
77
+
78
+ /** What a mood stages: lights (keyed under the prefix), image-based light and a sky. */
79
+ export type Staging = {
80
+ readonly lights: ReadonlyArray<SceneNode>;
81
+ readonly environment: Environment | null;
82
+ readonly sky: Sky | null;
83
+ };
84
+
85
+ /** The staging for `mood`, its lights keyed `${prefix}:…`. */
86
+ export const moodStaging = (mood: Mood, prefix = 'mood'): Staging => {
87
+ switch (mood) {
88
+ case 'studio':
89
+ return {
90
+ lights: [
91
+ hemisphereLight({
92
+ key: `${prefix}:fill`,
93
+ color: 0xffffff,
94
+ groundColor: 0x6f6a62,
95
+ intensity: 0.8,
96
+ }),
97
+ directionalLight({
98
+ key: `${prefix}:key`,
99
+ position: [4, 7, 3],
100
+ intensity: 2,
101
+ castShadow: true,
102
+ }),
103
+ ],
104
+ environment: environment({ environmentIntensity: 0.35 }),
105
+ sky: null,
106
+ };
107
+ case 'daylight':
108
+ return {
109
+ lights: [
110
+ hemisphereLight({
111
+ key: `${prefix}:sky`,
112
+ color: 0xcfe6ff,
113
+ groundColor: 0x8a7a60,
114
+ intensity: 1,
115
+ }),
116
+ directionalLight({
117
+ key: `${prefix}:sun`,
118
+ position: [6, 9, 4],
119
+ intensity: 3,
120
+ castShadow: true,
121
+ }),
122
+ ],
123
+ environment: environment({ environmentIntensity: 0.25 }),
124
+ sky: sky({ sunPosition: sunPositionFromAngles(0.85, 0.35) }),
125
+ };
126
+ case 'sunset':
127
+ return {
128
+ lights: [
129
+ hemisphereLight({
130
+ key: `${prefix}:sky`,
131
+ color: 0xffb27a,
132
+ groundColor: 0x3a2a40,
133
+ intensity: 0.6,
134
+ }),
135
+ directionalLight({
136
+ key: `${prefix}:sun`,
137
+ color: 0xff9a5a,
138
+ position: [-8, 2.5, 3],
139
+ intensity: 2.5,
140
+ castShadow: true,
141
+ }),
142
+ ],
143
+ environment: null,
144
+ sky: sky({ sunPosition: sunPositionFromAngles(0.52, 0.25), turbidity: 8, rayleigh: 3 }),
145
+ };
146
+ case 'night':
147
+ return {
148
+ lights: [
149
+ hemisphereLight({
150
+ key: `${prefix}:sky`,
151
+ color: 0x3a4a80,
152
+ groundColor: 0x101018,
153
+ intensity: 0.35,
154
+ }),
155
+ directionalLight({
156
+ key: `${prefix}:moon`,
157
+ color: 0xaabfff,
158
+ position: [-3, 8, -4],
159
+ intensity: 0.8,
160
+ castShadow: true,
161
+ }),
162
+ ],
163
+ environment: null,
164
+ sky: null,
165
+ };
166
+ }
167
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A measuring tape as data (roadmap, "Beyond parity" 5): two ends placed on the scene
3
+ * by clicks, the distance between them, and a dashed preview from the first end to the
4
+ * pointer while the second is being placed. Neither three.js nor Drei has one; it is
5
+ * built the way the gizmos are: `placeTapeEnd` and `pointTape` return the next tape for
6
+ * the Model to keep, `tapeNodes` describes it, and `tapeLength` measures it.
7
+ *
8
+ * A first click places one end, a second the other; a third starts a new tape there.
9
+ * With `snap`, each end is rounded to that step on every axis.
10
+ *
11
+ * @module
12
+ */
13
+ import { basic, group, line, mesh, sphere } from './build.ts';
14
+ import type { Rgb, SceneNode, Vec3 } from './description.ts';
15
+
16
+ export type Tape = {
17
+ /** The first end, once placed. */
18
+ readonly from: Vec3 | null;
19
+ /** The second end, once placed: the tape is then measured. */
20
+ readonly to: Vec3 | null;
21
+ /** Where the pointer is on the scene while the second end is being placed. */
22
+ readonly pointer: Vec3 | null;
23
+ /** Rounds each end to this step on every axis, or not when `null`. */
24
+ readonly snap: number | null;
25
+ };
26
+
27
+ /** An empty tape, snapping to `snap` when given. */
28
+ export const tape = (options: { readonly snap?: number | null } = {}): Tape => ({
29
+ from: null,
30
+ to: null,
31
+ pointer: null,
32
+ snap: options.snap ?? null,
33
+ });
34
+
35
+ const snapped = (state: Tape, point: Vec3): Vec3 => {
36
+ const step = state.snap;
37
+ if (step === null) return point;
38
+ return [
39
+ Math.round(point[0] / step) * step,
40
+ Math.round(point[1] / step) * step,
41
+ Math.round(point[2] / step) * step,
42
+ ];
43
+ };
44
+
45
+ /** A click at `point`: the first end, the second, or (when both are placed) a new tape. */
46
+ export const placeTapeEnd = (state: Tape, point: Vec3): Tape => {
47
+ const at = snapped(state, point);
48
+ if (state.from !== null && state.to === null) return { ...state, to: at, pointer: null };
49
+ return { ...state, from: at, to: null, pointer: null };
50
+ };
51
+
52
+ /** The pointer over the scene at `point` (or off it, `null`), for the preview. */
53
+ export const pointTape = (state: Tape, point: Vec3 | null): Tape =>
54
+ state.from !== null && state.to === null
55
+ ? { ...state, pointer: point === null ? null : snapped(state, point) }
56
+ : state;
57
+
58
+ /** An empty tape, keeping its snap. */
59
+ export const clearTape = (state: Tape): Tape => ({ ...tape({ snap: state.snap }) });
60
+
61
+ /** Where the tape's far end is: the second end, or the pointer while it is placed. */
62
+ const farEnd = (state: Tape): Vec3 | null => state.to ?? state.pointer;
63
+
64
+ const distance = (a: Vec3, b: Vec3): number => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
65
+
66
+ /** The tape's length: between its ends, or to the pointer while the second is placed. */
67
+ export const tapeLength = (state: Tape): number | undefined => {
68
+ const end = farEnd(state);
69
+ return state.from === null || end === null ? undefined : distance(state.from, end);
70
+ };
71
+
72
+ /** The middle of the tape, for a label there. */
73
+ export const tapeMiddle = (state: Tape): Vec3 | undefined => {
74
+ const end = farEnd(state);
75
+ if (state.from === null || end === null) return undefined;
76
+ return [(state.from[0] + end[0]) / 2, (state.from[1] + end[1]) / 2, (state.from[2] + end[2]) / 2];
77
+ };
78
+
79
+ export type TapeLook = {
80
+ readonly color: Rgb;
81
+ /** The line's width in pixels. */
82
+ readonly width: number;
83
+ /** The end balls' radius in scene units. */
84
+ readonly endRadius: number;
85
+ };
86
+
87
+ const endBall = (key: string, at: Vec3, look: TapeLook): SceneNode =>
88
+ mesh({
89
+ key,
90
+ position: at,
91
+ geometry: sphere({ radius: look.endRadius, widthSegments: 16, heightSegments: 8 }),
92
+ material: basic({ color: look.color }),
93
+ });
94
+
95
+ /**
96
+ * The tape under `key`: a ball at each placed end and a line between them, dashed while
97
+ * it runs to the pointer. Nothing in it is picked, so clicks reach the scene beneath.
98
+ */
99
+ export const tapeNodes = (key: string, state: Tape, look: Partial<TapeLook> = {}): SceneNode => {
100
+ const shown: TapeLook = {
101
+ color: look.color ?? 0xffcc33,
102
+ width: look.width ?? 3,
103
+ endRadius: look.endRadius ?? 0.03,
104
+ };
105
+ const end = farEnd(state);
106
+ const ends = [state.from, state.to].flatMap((at, index) =>
107
+ at === null ? [] : [endBall(`${key}:end:${index}`, at, shown)],
108
+ );
109
+ const span =
110
+ state.from === null || end === null
111
+ ? []
112
+ : [
113
+ line({
114
+ key: `${key}:line`,
115
+ points: [state.from, end],
116
+ color: shown.color,
117
+ width: shown.width,
118
+ dashed: state.to === null,
119
+ dashSize: 0.08,
120
+ gapSize: 0.05,
121
+ }),
122
+ ];
123
+ return group({ key, children: [...span, ...ends] });
124
+ };
@@ -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
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Trails, as Drei's `Trail` (`bf6f4ad`, `src/core/Trail.tsx`): a ribbon behind a moving
3
+ * node through the places it has been. Drei records the target once a frame into
4
+ * `length × 10` points, shifting in `decay` new ones each time it moves at least
5
+ * `stride`, every `interval` frames, and draws them with `meshline`. Here the same rules
6
+ * run on a clock of 60 steps a second of scene time rather than per frame, so a trail is
7
+ * as long at 30 frames a second as at 120; and the ribbon is a plain mesh turned to face
8
+ * the camera each frame, with vertex colours, so either renderer draws it. Its width is
9
+ * Drei's: a tenth of `width`, in scene units, `taper` and `fade` narrowing and fading it
10
+ * towards the tail (Drei takes an `attenuation` function).
11
+ *
12
+ * @module
13
+ */
14
+ import {
15
+ BufferAttribute,
16
+ BufferGeometry,
17
+ Color,
18
+ DoubleSide,
19
+ Mesh,
20
+ MeshBasicMaterial,
21
+ Vector3,
22
+ } from 'three';
23
+
24
+ import type { Trail } from './description.ts';
25
+
26
+ /** Drei records a point a frame; this records one a step of this many a second. */
27
+ const stepsPerSecond = 60;
28
+
29
+ export type TrailRibbon = {
30
+ /** The ribbon, in the space the positions are in; never picked. */
31
+ readonly object: Mesh;
32
+ /** Records the node at `position` for every step up to scene time `now`. */
33
+ readonly record: (position: Vector3, now: number) => void;
34
+ /** Turns the ribbon to face a camera at `eye`. */
35
+ readonly face: (eye: Vector3) => void;
36
+ /** Whether every recorded point is where the node is: the trail has caught up. */
37
+ readonly settled: () => boolean;
38
+ /** Applies a new look; a new length starts the trail again at `position`. */
39
+ readonly restyle: (trail: Trail, position: Vector3, now: number) => void;
40
+ readonly dispose: () => void;
41
+ };
42
+
43
+ /** How wide the ribbon is `along` the way from tail (0) to head (1). */
44
+ const widthAt = (trail: Trail, along: number): number => {
45
+ const full = trail.width * 0.1;
46
+ if (trail.taper === 'linear') return full * along;
47
+ if (trail.taper === 'quadratic') return full * along * along;
48
+ return full;
49
+ };
50
+
51
+ /** A ribbon of `trail.length × 10` points, all at `start`. Allocation-free once made. */
52
+ export const createTrailRibbon = (first: Trail, start: Vector3, now: number): TrailRibbon => {
53
+ let trail = first;
54
+ let count = 0;
55
+ let points = new Float32Array(0);
56
+ const geometry = new BufferGeometry();
57
+ const material = new MeshBasicMaterial({
58
+ vertexColors: true,
59
+ transparent: true,
60
+ side: DoubleSide,
61
+ depthWrite: false,
62
+ });
63
+ const object = new Mesh(geometry, material);
64
+ object.frustumCulled = false;
65
+ object.raycast = () => undefined;
66
+ // Not a node: marked so tools walking the scene can tell it apart.
67
+ object.name = 'effect-three:trail';
68
+ const last = new Vector3();
69
+ const here = new Vector3();
70
+ const ahead = new Vector3();
71
+ const behind = new Vector3();
72
+ const side = new Vector3();
73
+ const toEye = new Vector3();
74
+ const tint = new Color();
75
+ let clock = now;
76
+ let phase = 0;
77
+
78
+ /** Sizes the buffers for the trail's length and fills every point with `at`. */
79
+ const reset = (at: Vector3, time: number): void => {
80
+ count = Math.max(2, Math.round(trail.length * 10));
81
+ points = new Float32Array(count * 3);
82
+ for (let point = 0; point < count; point++) at.toArray(points, point * 3);
83
+ const index: Array<number> = [];
84
+ for (let point = 0; point < count - 1; point++) {
85
+ const a = point * 2;
86
+ index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
87
+ }
88
+ geometry.setIndex(index);
89
+ geometry.setAttribute('position', new BufferAttribute(new Float32Array(count * 6), 3));
90
+ geometry.setAttribute('color', new BufferAttribute(new Float32Array(count * 8), 4));
91
+ paint();
92
+ last.copy(at);
93
+ clock = time;
94
+ phase = 0;
95
+ };
96
+
97
+ /** Colours each pair of vertices: the trail's colour, fading to the tail with `fade`. */
98
+ const paint = (): void => {
99
+ const colors = geometry.getAttribute('color');
100
+ tint.setHex(trail.color);
101
+ for (let point = 0; point < count; point++) {
102
+ const alpha = trail.fade ? point / (count - 1) : 1;
103
+ colors.setXYZW(point * 2, tint.r, tint.g, tint.b, alpha);
104
+ colors.setXYZW(point * 2 + 1, tint.r, tint.g, tint.b, alpha);
105
+ }
106
+ colors.needsUpdate = true;
107
+ };
108
+
109
+ /** Drei's `shiftLeft` and push: the oldest point drops, `at` joins as the newest. */
110
+ const push = (at: Vector3): void => {
111
+ points.copyWithin(0, 3);
112
+ at.toArray(points, (count - 1) * 3);
113
+ };
114
+
115
+ reset(start, now);
116
+
117
+ return {
118
+ object,
119
+ record: (position, time) => {
120
+ // At most a trail's worth of steps: an idle scene catches up at once.
121
+ const due = Math.min(count, Math.floor((time - clock) * stepsPerSecond));
122
+ if (due <= 0) return;
123
+ clock += due / stepsPerSecond;
124
+ for (let step = 0; step < due; step++) {
125
+ if (phase === 0) {
126
+ for (let shift = 0; shift < trail.decay; shift++) {
127
+ if (position.distanceTo(last) < trail.stride) continue;
128
+ push(position);
129
+ }
130
+ last.copy(position);
131
+ }
132
+ phase = (phase + 1) % Math.max(1, Math.round(trail.interval));
133
+ }
134
+ },
135
+ face: (eye) => {
136
+ const position = geometry.getAttribute('position');
137
+ for (let point = 0; point < count; point++) {
138
+ here.fromArray(points, point * 3);
139
+ ahead.fromArray(points, Math.min(point + 1, count - 1) * 3);
140
+ behind.fromArray(points, Math.max(point - 1, 0) * 3);
141
+ // Across the ribbon: square to its direction here and to the view of it.
142
+ side.subVectors(ahead, behind).cross(toEye.subVectors(eye, here));
143
+ const length = side.length();
144
+ if (length > 1e-9) side.multiplyScalar(widthAt(trail, point / (count - 1)) / 2 / length);
145
+ else side.set(0, 0, 0);
146
+ position.setXYZ(point * 2, here.x + side.x, here.y + side.y, here.z + side.z);
147
+ position.setXYZ(point * 2 + 1, here.x - side.x, here.y - side.y, here.z - side.z);
148
+ }
149
+ position.needsUpdate = true;
150
+ },
151
+ settled: () => {
152
+ const end = (count - 1) * 3;
153
+ for (let point = 0; point < end; point += 3) {
154
+ if (points[point] !== points[end] || points[point + 1] !== points[end + 1]) return false;
155
+ if (points[point + 2] !== points[end + 2]) return false;
156
+ }
157
+ return true;
158
+ },
159
+ restyle: (next, position, time) => {
160
+ const resized = Math.round(next.length * 10) !== Math.round(trail.length * 10);
161
+ trail = next;
162
+ if (resized) reset(position, time);
163
+ else paint();
164
+ },
165
+ dispose: () => {
166
+ geometry.dispose();
167
+ material.dispose();
168
+ },
169
+ };
170
+ };