@opsydyn/effect-three 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +12 -0
  3. package/dist/{index-8cf8t6by.js → index-h3vf50fn.js} +2113 -83
  4. package/dist/index-h3vf50fn.js.map +53 -0
  5. package/dist/index-ttwy0znv.js +145 -0
  6. package/dist/index-ttwy0znv.js.map +12 -0
  7. package/dist/index.js +488 -147
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/boxSelect.d.ts +22 -0
  10. package/dist/kernel/boxSelect.d.ts.map +1 -0
  11. package/dist/kernel/build.d.ts +142 -6
  12. package/dist/kernel/build.d.ts.map +1 -1
  13. package/dist/kernel/cameraShake.d.ts.map +1 -1
  14. package/dist/kernel/describeScene.d.ts +50 -0
  15. package/dist/kernel/describeScene.d.ts.map +1 -0
  16. package/dist/kernel/description.d.ts +155 -1
  17. package/dist/kernel/description.d.ts.map +1 -1
  18. package/dist/kernel/diff.d.ts +15 -0
  19. package/dist/kernel/diff.d.ts.map +1 -1
  20. package/dist/kernel/equality.d.ts +9 -1
  21. package/dist/kernel/equality.d.ts.map +1 -1
  22. package/dist/kernel/gizmo.d.ts +69 -0
  23. package/dist/kernel/gizmo.d.ts.map +1 -0
  24. package/dist/kernel/history.d.ts +61 -0
  25. package/dist/kernel/history.d.ts.map +1 -0
  26. package/dist/kernel/index.d.ts +13 -2
  27. package/dist/kernel/index.d.ts.map +1 -1
  28. package/dist/kernel/index.js +106 -3
  29. package/dist/kernel/index.js.map +1 -1
  30. package/dist/kernel/inspect.d.ts +85 -0
  31. package/dist/kernel/inspect.d.ts.map +1 -0
  32. package/dist/kernel/lineGeometry.d.ts +25 -0
  33. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  34. package/dist/kernel/lockstep.d.ts +114 -0
  35. package/dist/kernel/lockstep.d.ts.map +1 -0
  36. package/dist/kernel/loop.d.ts.map +1 -1
  37. package/dist/kernel/materials.d.ts.map +1 -1
  38. package/dist/kernel/pivot.d.ts +54 -0
  39. package/dist/kernel/pivot.d.ts.map +1 -0
  40. package/dist/kernel/pointBuffers.d.ts +17 -0
  41. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  42. package/dist/kernel/pointer.d.ts +12 -0
  43. package/dist/kernel/pointer.d.ts.map +1 -1
  44. package/dist/kernel/renderKit.d.ts +21 -1
  45. package/dist/kernel/renderKit.d.ts.map +1 -1
  46. package/dist/kernel/renderer.d.ts +6 -0
  47. package/dist/kernel/renderer.d.ts.map +1 -1
  48. package/dist/kernel/sceneChanges.d.ts +87 -0
  49. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  50. package/dist/kernel/sceneGraph.d.ts +6 -0
  51. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  52. package/dist/kernel/seeded.d.ts +9 -0
  53. package/dist/kernel/seeded.d.ts.map +1 -0
  54. package/dist/kernel/shots.d.ts +26 -0
  55. package/dist/kernel/shots.d.ts.map +1 -0
  56. package/dist/kernel/tape.d.ts +38 -0
  57. package/dist/kernel/tape.d.ts.map +1 -0
  58. package/dist/kernel/trail.d.ts +31 -0
  59. package/dist/kernel/trail.d.ts.map +1 -0
  60. package/dist/kernel/viewportGizmo.d.ts +25 -0
  61. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  62. package/dist/runtime/Inspector.d.ts +20 -0
  63. package/dist/runtime/Inspector.d.ts.map +1 -0
  64. package/dist/runtime/SceneFile.d.ts +448 -0
  65. package/dist/runtime/SceneFile.d.ts.map +1 -0
  66. package/dist/runtime/SceneSchema.d.ts +318 -0
  67. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  68. package/dist/runtime/errors.d.ts +9 -0
  69. package/dist/runtime/errors.d.ts.map +1 -1
  70. package/dist/runtime/index.d.ts +3 -1
  71. package/dist/runtime/index.d.ts.map +1 -1
  72. package/dist/webgl/index.d.ts.map +1 -1
  73. package/dist/webgl/index.js +228 -3
  74. package/dist/webgl/index.js.map +6 -4
  75. package/dist/webgl/line.d.ts +4 -0
  76. package/dist/webgl/line.d.ts.map +1 -0
  77. package/dist/webgl/points.d.ts +4 -0
  78. package/dist/webgl/points.d.ts.map +1 -0
  79. package/dist/webgpu/index.d.ts.map +1 -1
  80. package/dist/webgpu/index.js +206 -26
  81. package/dist/webgpu/index.js.map +6 -4
  82. package/dist/webgpu/line.d.ts +4 -0
  83. package/dist/webgpu/line.d.ts.map +1 -0
  84. package/dist/webgpu/points.d.ts +4 -0
  85. package/dist/webgpu/points.d.ts.map +1 -0
  86. package/package.json +2 -1
  87. package/src/kernel/boxSelect.ts +73 -0
  88. package/src/kernel/build.ts +332 -0
  89. package/src/kernel/cameraShake.ts +3 -13
  90. package/src/kernel/describeScene.ts +224 -0
  91. package/src/kernel/description.ts +175 -0
  92. package/src/kernel/diff.ts +39 -0
  93. package/src/kernel/equality.ts +101 -0
  94. package/src/kernel/gizmo.ts +478 -0
  95. package/src/kernel/history.ts +118 -0
  96. package/src/kernel/index.ts +97 -0
  97. package/src/kernel/inspect.ts +214 -0
  98. package/src/kernel/lineGeometry.ts +75 -0
  99. package/src/kernel/lockstep.ts +237 -0
  100. package/src/kernel/loop.ts +5 -0
  101. package/src/kernel/materials.ts +3 -0
  102. package/src/kernel/pivot.ts +418 -0
  103. package/src/kernel/pointBuffers.ts +115 -0
  104. package/src/kernel/pointer.ts +55 -12
  105. package/src/kernel/renderKit.ts +23 -1
  106. package/src/kernel/renderer.ts +6 -0
  107. package/src/kernel/sceneChanges.ts +265 -0
  108. package/src/kernel/sceneGraph.ts +319 -8
  109. package/src/kernel/seeded.ts +18 -0
  110. package/src/kernel/shots.ts +167 -0
  111. package/src/kernel/tape.ts +124 -0
  112. package/src/kernel/trail.ts +170 -0
  113. package/src/kernel/viewportGizmo.ts +194 -0
  114. package/src/runtime/Inspector.ts +43 -0
  115. package/src/runtime/SceneFile.ts +46 -0
  116. package/src/runtime/SceneSchema.ts +247 -0
  117. package/src/runtime/errors.ts +6 -0
  118. package/src/runtime/index.ts +3 -1
  119. package/src/webgl/index.ts +4 -0
  120. package/src/webgl/line.ts +66 -0
  121. package/src/webgl/points.ts +205 -0
  122. package/src/webgpu/index.ts +4 -0
  123. package/src/webgpu/line.ts +67 -0
  124. package/src/webgpu/points.ts +193 -0
  125. package/dist/index-8cf8t6by.js.map +0 -41
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Pivot controls as data, after Drei's `PivotControls` (`bf6f4ad`,
3
+ * `src/web/pivotControls/`): every handle at once, turned with the object and sized in
4
+ * scene units — an arrow along each axis, a square sliding in each plane, a quarter arc
5
+ * turning round each axis, and a sphere scaling along each. Drei's listen to the pointer
6
+ * and move a group themselves; here `pivotNodes` describes the handles from the Model, a
7
+ * press on one (`pressPivot`, with the point the press hit) and the drag rays after it
8
+ * (`dragPivot`) return the next placement, and the Model keeps both.
9
+ *
10
+ * The maths is Drei's: an arrow and a sphere follow the point on their axis nearest the
11
+ * ray (`calculateOffset`); a square and an arc follow where the ray's line meets their
12
+ * plane, a square by its offset along the plane's two axes, an arc by the angle turned
13
+ * about the object (`calculateAngle`); a sphere scales by 2 to the power of a fifth of
14
+ * the distance dragged, in handle sizes. Snapping is Drei's shift: ten degrees, and
15
+ * scales to a tenth.
16
+ *
17
+ * Placements are in the object's parent's space, which is taken to be the world, as for
18
+ * `gizmoNodes`.
19
+ *
20
+ * @module
21
+ */
22
+ import { Euler, Quaternion, Vector3 } from 'three';
23
+
24
+ import { basic, cone, cylinder, fixed, group, mesh, plane, sphere } from './build.ts';
25
+ import type { SceneNode, Vec3 } from './description.ts';
26
+ import type { Placement } from './gizmo.ts';
27
+ import type { Ray } from './pointer.ts';
28
+
29
+ type Axis = 'x' | 'y' | 'z';
30
+
31
+ /** A handle: what it does, and the axis it is on (a square's and an arc's normal). */
32
+ export type PivotHandle = `${'arrow' | 'slider' | 'rotator' | 'sphere'}-${Axis}`;
33
+
34
+ /** A drag in progress: the handle, the placement it began at and the frame it moves in. */
35
+ export type PivotGrab = {
36
+ readonly handle: PivotHandle;
37
+ readonly start: Placement;
38
+ /** Where the press hit the handle. */
39
+ readonly point: Vec3;
40
+ /** The handle's directions in the world: its axis, or its plane's two and normal. */
41
+ readonly e1: Vec3;
42
+ readonly e2: Vec3;
43
+ readonly normal: Vec3;
44
+ };
45
+
46
+ export type Pivot = {
47
+ /** The handles' size in scene units: an arrow's length (Drei's `scale`, 1). */
48
+ readonly scale: number;
49
+ /** Whether the object hides the handles behind it (Drei's `depthTest`, true). */
50
+ readonly depthTest: boolean;
51
+ /** Leave out kinds of handle, as Drei's `disable*`. */
52
+ readonly disableAxes: boolean;
53
+ readonly disableSliders: boolean;
54
+ readonly disableRotations: boolean;
55
+ readonly disableScaling: boolean;
56
+ /** The handle under the pointer, drawn in Drei's hovered yellow. */
57
+ readonly hovered: PivotHandle | null;
58
+ readonly grab: PivotGrab | null;
59
+ };
60
+
61
+ /** Pivot controls with Drei's defaults: size 1, depth tested, every handle shown. */
62
+ export const pivot = (options: Partial<Omit<Pivot, 'hovered' | 'grab'>> = {}): Pivot => ({
63
+ scale: options.scale ?? 1,
64
+ depthTest: options.depthTest ?? true,
65
+ disableAxes: options.disableAxes ?? false,
66
+ disableSliders: options.disableSliders ?? false,
67
+ disableRotations: options.disableRotations ?? false,
68
+ disableScaling: options.disableScaling ?? false,
69
+ hovered: null,
70
+ grab: null,
71
+ });
72
+
73
+ const unit: Record<Axis, Vector3> = {
74
+ x: new Vector3(1, 0, 0),
75
+ y: new Vector3(0, 1, 0),
76
+ z: new Vector3(0, 0, 1),
77
+ };
78
+
79
+ /** Each plane's two directions, by its normal, in Drei's order. */
80
+ const planeOf: Record<Axis, readonly [Axis, Axis]> = {
81
+ z: ['x', 'y'],
82
+ y: ['z', 'x'],
83
+ x: ['y', 'z'],
84
+ };
85
+
86
+ const turnOf = (rotation: Vec3): Quaternion =>
87
+ new Quaternion().setFromEuler(new Euler(rotation[0], rotation[1], rotation[2]));
88
+
89
+ const toVec3 = (vector: Vector3): Vec3 => [vector.x, vector.y, vector.z];
90
+
91
+ const kindOf = (handle: PivotHandle) =>
92
+ handle.split('-')[0] as 'arrow' | 'slider' | 'rotator' | 'sphere';
93
+ const axisOf = (handle: PivotHandle): Axis => handle.split('-')[1] as Axis;
94
+
95
+ /** The handle's frame in the world: turned with the object, as Drei's group is. */
96
+ const frameOf = (handle: PivotHandle, placement: Placement) => {
97
+ const turn = turnOf(placement.rotation);
98
+ const axis = axisOf(handle);
99
+ const [first, second] = planeOf[axis];
100
+ const kind = kindOf(handle);
101
+ const flat = kind === 'slider' || kind === 'rotator';
102
+ const e1 = (flat ? unit[first] : unit[axis]).clone().applyQuaternion(turn);
103
+ const e2 = (flat ? unit[second] : unit[axis]).clone().applyQuaternion(turn);
104
+ return { e1, e2, normal: e1.clone().cross(e2).normalize() };
105
+ };
106
+
107
+ /** A press on `handle` at `point`, with the object at `placement`: takes hold. */
108
+ export const pressPivot = (
109
+ state: Pivot,
110
+ placement: Placement,
111
+ handle: PivotHandle,
112
+ point: Vec3,
113
+ ): Pivot => {
114
+ const { e1, e2, normal } = frameOf(handle, placement);
115
+ return {
116
+ ...state,
117
+ grab: {
118
+ handle,
119
+ start: placement,
120
+ point,
121
+ e1: toVec3(e1),
122
+ e2: toVec3(e2),
123
+ normal: toVec3(kindOf(handle) === 'arrow' || kindOf(handle) === 'sphere' ? e1 : normal),
124
+ },
125
+ };
126
+ };
127
+
128
+ export const releasePivot = (state: Pivot): Pivot => ({ ...state, grab: null });
129
+
130
+ export const hoverPivot = (state: Pivot, handle: PivotHandle | null): Pivot =>
131
+ state.hovered === handle ? state : { ...state, hovered: handle };
132
+
133
+ /**
134
+ * Drei's `calculateOffset`: how far along `direction` from `point` the line nearest the
135
+ * ray is, solving for the closest points of the two lines.
136
+ */
137
+ const offsetAlong = (point: Vector3, direction: Vector3, ray: Ray): number => {
138
+ const origin = new Vector3(...ray.origin);
139
+ const toward = new Vector3(...ray.direction);
140
+ const e1 = direction.dot(direction);
141
+ const e2 = direction.dot(point) - direction.dot(origin);
142
+ const e3 = direction.dot(toward);
143
+ if (e3 === 0) return -e2 / e1;
144
+ const v1 = toward
145
+ .clone()
146
+ .multiplyScalar(e1 / e3)
147
+ .sub(direction);
148
+ const v2 = toward
149
+ .clone()
150
+ .multiplyScalar(e2 / e3)
151
+ .add(origin)
152
+ .sub(point);
153
+ return -v1.dot(v2) / v1.dot(v1);
154
+ };
155
+
156
+ /** Where the ray's line meets the plane through `at` with `normal`, if it does. */
157
+ const meetPlane = (ray: Ray, at: Vector3, normal: Vector3): Vector3 | undefined => {
158
+ const origin = new Vector3(...ray.origin);
159
+ const toward = new Vector3(...ray.direction);
160
+ const facing = normal.dot(toward);
161
+ if (facing === 0) return undefined;
162
+ return origin.addScaledVector(toward, normal.dot(at.clone().sub(origin)) / facing);
163
+ };
164
+
165
+ /** Drei's `calculateAngle`: from the press to `to`, round `at`, in the plane of e1 and e2. */
166
+ const angleBetween = (from: Vector3, to: Vector3, at: Vector3, e1: Vector3, e2: Vector3) => {
167
+ const a = from.clone().sub(at);
168
+ const b = to.clone().sub(at);
169
+ return Math.atan2(b.dot(e2), b.dot(e1)) - Math.atan2(a.dot(e2), a.dot(e1));
170
+ };
171
+
172
+ const vectorsOf = (grab: PivotGrab) => ({
173
+ point: new Vector3(...grab.point),
174
+ e1: new Vector3(...grab.e1),
175
+ e2: new Vector3(...grab.e2),
176
+ normal: new Vector3(...grab.normal),
177
+ at: new Vector3(...grab.start.position),
178
+ });
179
+
180
+ const moved = (start: Placement, by: Vector3): Placement => ({
181
+ ...start,
182
+ position: toVec3(new Vector3(...start.position).add(by)),
183
+ });
184
+
185
+ const arrowDrag = (grab: PivotGrab, ray: Ray): Placement => {
186
+ const { point, e1 } = vectorsOf(grab);
187
+ return moved(grab.start, e1.clone().multiplyScalar(offsetAlong(point, e1, ray)));
188
+ };
189
+
190
+ const sliderDrag = (grab: PivotGrab, ray: Ray): Placement => {
191
+ const { point, e1, e2, normal, at } = vectorsOf(grab);
192
+ const meet = meetPlane(ray, at, normal);
193
+ if (meet === undefined) return grab.start;
194
+ const offset = meet.sub(point);
195
+ return moved(
196
+ grab.start,
197
+ e1.multiplyScalar(offset.dot(e1)).add(e2.multiplyScalar(offset.dot(e2))),
198
+ );
199
+ };
200
+
201
+ const tenDegrees = Math.PI / 18;
202
+
203
+ const rotatorDrag = (grab: PivotGrab, ray: Ray, snap: boolean): Placement => {
204
+ const { point, e1, e2, normal, at } = vectorsOf(grab);
205
+ const meet = meetPlane(ray, at, normal);
206
+ if (meet === undefined) return grab.start;
207
+ const turned = angleBetween(point, meet, at, e1, e2);
208
+ const angle = snap ? Math.round(turned / tenDegrees) * tenDegrees : turned;
209
+ const turn = new Quaternion()
210
+ .setFromAxisAngle(normal, angle)
211
+ .multiply(turnOf(grab.start.rotation));
212
+ const euler = new Euler().setFromQuaternion(turn.normalize());
213
+ return { ...grab.start, rotation: [euler.x, euler.y, euler.z] };
214
+ };
215
+
216
+ const sphereDrag = (state: Pivot, grab: PivotGrab, ray: Ray, snap: boolean): Placement => {
217
+ const { point, e1 } = vectorsOf(grab);
218
+ const grown = 2 ** ((offsetAlong(point, e1, ray) / state.scale) * 0.2);
219
+ // Drei never lets a scale fall below 1e-5, which would break the object's matrix.
220
+ const factor = Math.max(snap ? Math.round(grown * 10) / 10 : grown, 1e-5);
221
+ const index = { x: 0, y: 1, z: 2 }[axisOf(grab.handle)];
222
+ const scale: Vec3 = [
223
+ grab.start.scale[0] * (index === 0 ? factor : 1),
224
+ grab.start.scale[1] * (index === 1 ? factor : 1),
225
+ grab.start.scale[2] * (index === 2 ? factor : 1),
226
+ ];
227
+ return { ...grab.start, scale };
228
+ };
229
+
230
+ /**
231
+ * The placement a drag ray gives, or `undefined` while nothing is held. `snap` rounds
232
+ * turns to ten degrees and scales to tenths, as Drei's shift does.
233
+ */
234
+ export const dragPivot = (
235
+ state: Pivot,
236
+ ray: Ray,
237
+ options: { readonly snap?: boolean } = {},
238
+ ): Placement | undefined => {
239
+ const grab = state.grab;
240
+ if (grab === null) return undefined;
241
+ const snap = options.snap ?? false;
242
+ const kind = kindOf(grab.handle);
243
+ if (kind === 'arrow') return arrowDrag(grab, ray);
244
+ if (kind === 'slider') return sliderDrag(grab, ray);
245
+ if (kind === 'rotator') return rotatorDrag(grab, ray, snap);
246
+ return sphereDrag(state, grab, ray, snap);
247
+ };
248
+
249
+ // DRAWING
250
+
251
+ /** Drei's `axisColors` and `hoveredColor`. */
252
+ const colours: Record<Axis, number> = { x: 0xff2060, y: 0x20df80, z: 0x2080ff };
253
+ const hoveredColour = 0xffff40;
254
+
255
+ const handles = ['arrow', 'slider', 'rotator', 'sphere'] as const;
256
+
257
+ /** The handle a hit names, if it is one of this pivot's. */
258
+ export const pivotHandleOf = (key: string, hit: string): PivotHandle | undefined => {
259
+ const [owner, name] = hit.split(':');
260
+ if (owner !== key) return undefined;
261
+ return handles
262
+ .flatMap((kind) => (['x', 'y', 'z'] as const).map((axis): PivotHandle => `${kind}-${axis}`))
263
+ .find((handle) => handle === name);
264
+ };
265
+
266
+ const lookOf = (state: Pivot, handle: PivotHandle) =>
267
+ basic({
268
+ color:
269
+ state.grab?.handle === handle || state.hovered === handle
270
+ ? hoveredColour
271
+ : colours[axisOf(handle)],
272
+ depthTest: state.depthTest,
273
+ side: 'double',
274
+ });
275
+
276
+ /**
277
+ * What a hit shape is drawn with: nothing to see, never hiding anything, still picked
278
+ * (Drei's arrows carry an invisible cylinder the same way).
279
+ */
280
+ const unseen = basic({ opacity: 0, transparent: true, depthTest: false });
281
+
282
+ /** Turns +y onto each axis, for the arrows, stems and spheres, which are drawn along y. */
283
+ const pointAlong: Record<Axis, Vec3> = {
284
+ x: [0, 0, -Math.PI / 2],
285
+ y: [0, 0, 0],
286
+ z: [Math.PI / 2, 0, 0],
287
+ };
288
+
289
+ const along = (axis: Axis, distance: number): Vec3 => {
290
+ const { x, y, z } = unit[axis];
291
+ return [x * distance, y * distance, z * distance];
292
+ };
293
+
294
+ /** Drei's `AxisArrow`: a stem of 4/5 of the size and a cone of 1/5, 1/20 wide. */
295
+ const arrowNodes = (key: string, state: Pivot, axis: Axis): ReadonlyArray<SceneNode> => {
296
+ const handle: PivotHandle = `arrow-${axis}`;
297
+ const size = state.scale;
298
+ const coneLength = size / 5;
299
+ const stem = size - coneLength;
300
+ const material = lookOf(state, handle);
301
+ return [
302
+ mesh({
303
+ key: `${key}:${handle}:stem`,
304
+ position: along(axis, stem / 2),
305
+ rotation: fixed(pointAlong[axis]),
306
+ geometry: cylinder({ radiusTop: size / 80, radiusBottom: size / 80, height: stem }),
307
+ material,
308
+ }),
309
+ // Drei's hit cylinder: 1.4 cone widths round, the arrow's whole length.
310
+ mesh({
311
+ key: `${key}:${handle}:hit`,
312
+ position: along(axis, size / 2),
313
+ rotation: fixed(pointAlong[axis]),
314
+ geometry: cylinder({ radiusTop: size * 0.07, radiusBottom: size * 0.07, height: size }),
315
+ material: unseen,
316
+ interactive: true,
317
+ }),
318
+ mesh({
319
+ key: `${key}:${handle}:cone`,
320
+ position: along(axis, stem + coneLength / 2),
321
+ rotation: fixed(pointAlong[axis]),
322
+ geometry: cone({ radius: size / 20, height: coneLength, radialSegments: 24 }),
323
+ material,
324
+ interactive: true,
325
+ }),
326
+ ];
327
+ };
328
+
329
+ /** A rotation onto the plane of an axis's slider and arc: x onto its first, y its second. */
330
+ const ontoPlane: Record<Axis, Vec3> = {
331
+ z: [0, 0, 0],
332
+ y: [-Math.PI / 2, 0, -Math.PI / 2],
333
+ x: [Math.PI / 2, Math.PI / 2, 0],
334
+ };
335
+
336
+ /** Drei's `PlaneSlider`: a square 0.225 of the size, its middle 1.7/7 out along both. */
337
+ const sliderNode = (key: string, state: Pivot, axis: Axis): SceneNode => {
338
+ const handle: PivotHandle = `slider-${axis}`;
339
+ const out = (state.scale / 7) * 1.7;
340
+ const side = state.scale * 0.225;
341
+ return group({
342
+ key: `${key}:${handle}`,
343
+ rotation: fixed(ontoPlane[axis]),
344
+ children: [
345
+ mesh({
346
+ key: `${key}:${handle}:square`,
347
+ position: [out, out, 0],
348
+ geometry: plane({ width: side, height: side }),
349
+ material: lookOf(state, handle),
350
+ interactive: true,
351
+ }),
352
+ ],
353
+ });
354
+ };
355
+
356
+ const arcSegments = 8;
357
+
358
+ /** Drei's `AxisRotator`: a quarter circle of radius 0.65 of the size, from its first to its second. */
359
+ const rotatorNode = (key: string, state: Pivot, axis: Axis): SceneNode => {
360
+ const handle: PivotHandle = `rotator-${axis}`;
361
+ const radius = state.scale * 0.65;
362
+ const step = Math.PI / 2 / arcSegments;
363
+ const chord = 2 * radius * Math.sin(step / 2);
364
+ // A cylinder lies along y: each turned to the arc's tangent at its middle. The thin one
365
+ // shows; a thick, unseen one round it is what the pointer finds, as Drei's line is
366
+ // picked a few pixels wide.
367
+ const segment = (index: number, part: 'line' | 'hit'): SceneNode => {
368
+ const middle = step * (index + 0.5);
369
+ const thickness = part === 'line' ? radius / 50 : state.scale * 0.03;
370
+ return mesh({
371
+ key: `${key}:${handle}:${part}:${index}`,
372
+ position: [Math.cos(middle) * radius, Math.sin(middle) * radius, 0],
373
+ rotation: fixed([0, 0, middle]),
374
+ geometry: cylinder({ radiusTop: thickness, radiusBottom: thickness, height: chord }),
375
+ material: part === 'line' ? lookOf(state, handle) : unseen,
376
+ interactive: part === 'hit',
377
+ });
378
+ };
379
+ const indices = Array.from({ length: arcSegments }, (_, index) => index);
380
+ return group({
381
+ key: `${key}:${handle}`,
382
+ rotation: fixed(ontoPlane[axis]),
383
+ children: [
384
+ ...indices.map((index) => segment(index, 'line')),
385
+ ...indices.map((index) => segment(index, 'hit')),
386
+ ],
387
+ });
388
+ };
389
+
390
+ /** Drei's `ScalingSphere`: a ball 1/22.5 of the size, 1.2 sizes out along its axis. */
391
+ const sphereNode = (key: string, state: Pivot, axis: Axis): SceneNode =>
392
+ mesh({
393
+ key: `${key}:sphere-${axis}`,
394
+ position: along(axis, state.scale * 1.2),
395
+ geometry: sphere({ radius: state.scale / 22.5, widthSegments: 12, heightSegments: 12 }),
396
+ material: lookOf(state, `sphere-${axis}`),
397
+ interactive: true,
398
+ });
399
+
400
+ const axesOf: ReadonlyArray<Axis> = ['x', 'y', 'z'];
401
+
402
+ /**
403
+ * The handles for `placement`: a group at the object, turned with it (and not scaled),
404
+ * its handles keyed `${key}:arrow-x`, `${key}:slider-z`, `${key}:rotator-y`,
405
+ * `${key}:sphere-x` and so on, each pickable.
406
+ */
407
+ export const pivotNodes = (key: string, state: Pivot, placement: Placement): SceneNode =>
408
+ group({
409
+ key,
410
+ position: placement.position,
411
+ rotation: fixed(placement.rotation),
412
+ children: [
413
+ ...(state.disableAxes ? [] : axesOf.flatMap((axis) => arrowNodes(key, state, axis))),
414
+ ...(state.disableSliders ? [] : axesOf.map((axis) => sliderNode(key, state, axis))),
415
+ ...(state.disableRotations ? [] : axesOf.map((axis) => rotatorNode(key, state, axis))),
416
+ ...(state.disableScaling ? [] : axesOf.map((axis) => sphereNode(key, state, axis))),
417
+ ],
418
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The buffers a render kit draws points from, made from their look: given points as
3
+ * they are, Drei's sparkles and stars scattered as Drei scatters them (`Sparkles.tsx`,
4
+ * `Stars.tsx` at `bf6f4ad`) but from a seed, so the same look gives the same buffers.
5
+ *
6
+ * @module
7
+ */
8
+ import { Color, Spherical, Vector3 } from 'three';
9
+
10
+ import type { PointLook } from './description.ts';
11
+ import { seededRandom } from './seeded.ts';
12
+
13
+ /** Per-point attributes: three numbers a point for positions, colours and noise. */
14
+ export type PointBuffers = {
15
+ readonly count: number;
16
+ readonly positions: Float32Array;
17
+ readonly colors: Float32Array;
18
+ readonly sizes: Float32Array;
19
+ /** Sparkles only: each mote's drift speed, opacity and noise. */
20
+ readonly speeds: Float32Array;
21
+ readonly opacities: Float32Array;
22
+ readonly noise: Float32Array;
23
+ };
24
+
25
+ const linear = new Color();
26
+
27
+ const dots = (look: Extract<PointLook, { readonly _tag: 'Dots' }>): PointBuffers => {
28
+ const count = look.positions.length;
29
+ const colors = new Float32Array(count * 3);
30
+ for (let point = 0; point < count; point++) {
31
+ linear.setHex(look.colors?.[point] ?? look.color).toArray(colors, point * 3);
32
+ }
33
+ return {
34
+ count,
35
+ positions: Float32Array.from(look.positions.flat()),
36
+ colors,
37
+ sizes: Float32Array.from({ length: count }, (_, point) => look.sizes?.[point] ?? 1),
38
+ speeds: new Float32Array(0),
39
+ opacities: new Float32Array(0),
40
+ noise: new Float32Array(0),
41
+ };
42
+ };
43
+
44
+ /** Drei's `Sparkles`: positions spread through the box, random sizes unless given. */
45
+ const sparkles = (look: Extract<PointLook, { readonly _tag: 'Sparkles' }>): PointBuffers => {
46
+ const random = seededRandom(look.seed);
47
+ // three's `MathUtils.randFloatSpread`, as Drei spreads them, from the seed.
48
+ const spread = (range: number) => range * (0.5 - random());
49
+ const { count } = look;
50
+ const positions = new Float32Array(count * 3);
51
+ for (let point = 0; point < count; point++) {
52
+ positions[point * 3] = spread(look.scale[0]);
53
+ positions[point * 3 + 1] = spread(look.scale[1]);
54
+ positions[point * 3 + 2] = spread(look.scale[2]);
55
+ }
56
+ const colors = new Float32Array(count * 3);
57
+ linear.setHex(look.color);
58
+ for (let point = 0; point < count; point++) linear.toArray(colors, point * 3);
59
+ const noise = new Float32Array(count * 3);
60
+ for (let point = 0; point < count; point++) noise.set(look.noise, point * 3);
61
+ return {
62
+ count,
63
+ positions,
64
+ colors,
65
+ sizes: Float32Array.from({ length: count }, () => look.size ?? random()),
66
+ speeds: new Float32Array(count).fill(look.speed),
67
+ opacities: new Float32Array(count).fill(look.opacity),
68
+ noise,
69
+ };
70
+ };
71
+
72
+ /** Drei's `Stars`: a shell of stars, nearer ones later, hues round the colour wheel. */
73
+ const stars = (look: Extract<PointLook, { readonly _tag: 'Stars' }>): PointBuffers => {
74
+ const random = seededRandom(look.seed);
75
+ const { count } = look;
76
+ const positions = new Float32Array(count * 3);
77
+ const colors = new Float32Array(count * 3);
78
+ const sizes = Float32Array.from({ length: count }, () => (0.5 + 0.5 * random()) * look.factor);
79
+ const place = new Vector3();
80
+ const spherical = new Spherical();
81
+ const increment = look.depth / count;
82
+ let radius = look.radius + look.depth;
83
+ for (let star = 0; star < count; star++) {
84
+ radius -= increment * random();
85
+ place.setFromSpherical(
86
+ spherical.set(radius, Math.acos(1 - random() * 2), random() * 2 * Math.PI),
87
+ );
88
+ place.toArray(positions, star * 3);
89
+ linear.setHSL(star / count, look.saturation, 0.9).toArray(colors, star * 3);
90
+ }
91
+ return {
92
+ count,
93
+ positions,
94
+ colors,
95
+ sizes,
96
+ speeds: new Float32Array(0),
97
+ opacities: new Float32Array(0),
98
+ noise: new Float32Array(0),
99
+ };
100
+ };
101
+
102
+ /** The buffers for a look. */
103
+ export const pointBuffers = (look: PointLook): PointBuffers => {
104
+ switch (look._tag) {
105
+ case 'Dots':
106
+ return dots(look);
107
+ case 'Sparkles':
108
+ return sparkles(look);
109
+ case 'Stars':
110
+ return stars(look);
111
+ }
112
+ };
113
+
114
+ /** Whether a look moves on its own: sparkles drift and stars twinkle in scene time. */
115
+ export const animatedLook = (look: PointLook): boolean => look._tag !== 'Dots';
@@ -209,6 +209,18 @@ export type SceneEvent =
209
209
  }
210
210
  /** A drag, wheel or pinch on the canvas, when the scene claims gestures. */
211
211
  | { readonly _tag: 'Gesture'; readonly gesture: Gesture }
212
+ /**
213
+ * A gesture that moved has ended: released, or the pointer left. Only after a `Drag`
214
+ * or `Pinch`, with the press's button, modifiers, mesh and view: the moment to finish
215
+ * what the gesture drew, such as a selection box.
216
+ */
217
+ | {
218
+ readonly _tag: 'GestureEnd';
219
+ readonly button: number;
220
+ readonly modifiers: Modifiers;
221
+ readonly key?: string;
222
+ readonly view?: string;
223
+ }
212
224
  /**
213
225
  * A gesture is about to begin on a camera that has turned by itself (`autoRotate`):
214
226
  * where it got to, so the Model can keep it (`autoRotated(camera, detail)`) and the
@@ -405,14 +417,17 @@ export const createPicker = (graph: SceneGraph): Picker => {
405
417
  const rayOrigin = new Vector3();
406
418
  const rayDirection = new Vector3();
407
419
 
408
- /** The nearest keyed hit along the raycaster's ray, reporting `view` when there is one. */
409
- const nearestHit = (view: string | undefined): Hit | undefined => {
420
+ /**
421
+ * The nearest keyed hit in `on` (the scene's graph or its HUD's) along the raycaster's
422
+ * ray, reporting `view` when there is one.
423
+ */
424
+ const nearestHit = (on: SceneGraph, view: string | undefined): Hit | undefined => {
410
425
  // Recursive, so an interactive group makes every mesh inside it pickable.
411
- for (const intersection of raycaster.intersectObjects(graph.interactiveObjects(), true)) {
426
+ for (const intersection of raycaster.intersectObjects(on.interactiveObjects(), true)) {
412
427
  // Hidden things are not hit: a level of detail not showing, as three's `LOD.raycast`.
413
428
  if (!shown(intersection.object)) continue;
414
- const owner = keyedOwner(graph, intersection.object);
415
- const key = owner === undefined ? undefined : graph.keyOf(owner);
429
+ const owner = keyedOwner(on, intersection.object);
430
+ const key = owner === undefined ? undefined : on.keyOf(owner);
416
431
  if (owner !== undefined && key !== undefined) {
417
432
  normal.set(0, 0, 0);
418
433
  if (intersection.face) {
@@ -421,13 +436,13 @@ export const createPicker = (graph: SceneGraph): Picker => {
421
436
  const instance =
422
437
  intersection.instanceId === undefined
423
438
  ? undefined
424
- : graph.instanceKeyOf(intersection.object, intersection.instanceId);
439
+ : on.instanceKeyOf(intersection.object, intersection.instanceId);
425
440
  return {
426
441
  key,
427
442
  ...(instance === undefined ? {} : { instance }),
428
443
  ...partOf(owner, intersection.object),
429
444
  ...(view === undefined ? {} : { view }),
430
- path: pathOf(graph, owner),
445
+ path: pathOf(on, owner),
431
446
  point: toVec3(intersection.point),
432
447
  distance: intersection.distance,
433
448
  normal: toVec3(normal),
@@ -439,6 +454,17 @@ export const createPicker = (graph: SceneGraph): Picker => {
439
454
  return undefined;
440
455
  };
441
456
 
457
+ /** A hit on the HUD's interactive meshes, which are over everything else. */
458
+ const hudHit = (x: number, y: number): Hit | undefined => {
459
+ const hud = graph.hud();
460
+ if (hud === undefined || hud.graph.interactiveObjects().length === 0) return undefined;
461
+ hud.graph.scene.updateMatrixWorld();
462
+ hud.graph.camera.updateMatrixWorld();
463
+ ndc.set(x, y);
464
+ raycaster.setFromCamera(ndc, hud.graph.camera);
465
+ return nearestHit(hud.graph, undefined);
466
+ };
467
+
442
468
  const currentRay = (): Ray => ({
443
469
  origin: toVec3(raycaster.ray.origin),
444
470
  direction: toVec3(raycaster.ray.direction),
@@ -472,10 +498,12 @@ export const createPicker = (graph: SceneGraph): Picker => {
472
498
  holdCameras: graph.holdCameras,
473
499
  turned: graph.turned,
474
500
  pick: (x, y) => {
501
+ const hud = hudHit(x, y);
502
+ if (hud !== undefined) return hud;
475
503
  if (graph.interactiveObjects().length === 0) return undefined;
476
504
  const view = aim(x, y);
477
505
  if (view === false) return undefined;
478
- return nearestHit(view);
506
+ return nearestHit(graph, view);
479
507
  },
480
508
  pickRay: (ray) => {
481
509
  if (graph.interactiveObjects().length === 0) return undefined;
@@ -484,7 +512,7 @@ export const createPicker = (graph: SceneGraph): Picker => {
484
512
  rayOrigin.fromArray(ray.origin),
485
513
  rayDirection.fromArray(ray.direction).normalize(),
486
514
  );
487
- return nearestHit(undefined);
515
+ return nearestHit(graph, undefined);
488
516
  },
489
517
  viewport: graph.viewport,
490
518
  };
@@ -532,6 +560,8 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
532
560
  const pressedPointers = new Map<number, Point>();
533
561
  let primary: number | undefined;
534
562
  let gesturePress: GesturePress | undefined;
563
+ // Whether the gesture press has moved: only then does its release end a gesture.
564
+ let gestureMoved = false;
535
565
  let hovered: string | undefined;
536
566
  // The latest press, kept after release: double-click and context-menu events follow it.
537
567
  let press: Press | undefined;
@@ -624,6 +654,7 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
624
654
  pressedPointers.clear();
625
655
  primary = id;
626
656
  pressedPointers.set(id, { x: event.x, y: event.y });
657
+ gestureMoved = false;
627
658
  const region = picker.region(event.x, event.y);
628
659
  reportTurned(region, events);
629
660
  // Cameras turning by themselves hold still while it is pressed, as `OrbitControls`.
@@ -641,6 +672,7 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
641
672
  const from = pressedPointers.get(id);
642
673
  if (from === undefined || gesturePress === undefined) return;
643
674
  pressedPointers.set(id, to);
675
+ gestureMoved = true;
644
676
  const { view, width, height, x, y } = gesturePress;
645
677
  const region = { ...(view === undefined ? {} : { view }), width, height, x, y };
646
678
  const moved = pixels(from, to);
@@ -680,11 +712,22 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
680
712
  });
681
713
  };
682
714
 
683
- const endGestures = (): void => {
715
+ const endGestures = (events: Array<SceneEvent>): void => {
684
716
  if (gesturePress !== undefined) picker.holdCameras(false);
717
+ if (gesturePress !== undefined && gestureMoved) {
718
+ const { button, modifiers, key, view } = gesturePress;
719
+ events.push({
720
+ _tag: 'GestureEnd',
721
+ button,
722
+ modifiers,
723
+ ...(key === undefined ? {} : { key }),
724
+ ...(view === undefined ? {} : { view }),
725
+ });
726
+ }
685
727
  pressedPointers.clear();
686
728
  primary = undefined;
687
729
  gesturePress = undefined;
730
+ gestureMoved = false;
688
731
  };
689
732
 
690
733
  /**
@@ -714,7 +757,7 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
714
757
  if (isSecondary(event, id)) return secondary(event, id);
715
758
  const events: Array<SceneEvent> = [];
716
759
  if (event._tag === 'PointerLeave') {
717
- endGestures();
760
+ endGestures(events);
718
761
  // A cancelled or abandoned press still ends its drag, at the last known position.
719
762
  if (position !== undefined) endDrag(captured(), position.x, position.y, events);
720
763
  dragged = false;
@@ -748,7 +791,7 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
748
791
  release(hit, event, events);
749
792
  // Any release ends capture: a mouse's buttons share one pointer.
750
793
  endDrag(drag, event.x, event.y, events);
751
- endGestures();
794
+ endGestures(events);
752
795
  break;
753
796
  case 'DoubleClick':
754
797
  if (hit !== undefined && pressed(hit)) {