@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
@@ -48,6 +48,10 @@ export {
48
48
  xrMesh,
49
49
  xrMeshGeometryId,
50
50
  physics,
51
+ modelAsset,
52
+ textureAsset,
53
+ environmentAsset,
54
+ fontAsset,
51
55
  cuboidCollider,
52
56
  ballCollider,
53
57
  capsuleCollider,
@@ -149,6 +153,7 @@ export {
149
153
  overlaysEqual,
150
154
  xrEquals,
151
155
  physicsEquals,
156
+ assetsEqual,
152
157
  skyEquals,
153
158
  viewsEqual,
154
159
  vec3Equals,
@@ -207,8 +212,12 @@ export {
207
212
  type PhysicsJoint,
208
213
  type PhysicsWorld,
209
214
  type Quat,
215
+ type Received,
216
+ type RestoredWorld,
217
+ snapshotBytes,
218
+ snapshotText,
210
219
  } from './physics.ts';
211
- export type { BodyCollision, BodyContactForce } from './bodies.ts';
220
+ export type { BodyCollision, BodyContactForce, PhysicsReached } from './bodies.ts';
212
221
  export type {
213
222
  ContactShadowPasses,
214
223
  HostedLine,
@@ -255,6 +264,7 @@ export {
255
264
  type Picker,
256
265
  type PlaneEquation,
257
266
  pointOnPlane,
267
+ type PointerDevice,
258
268
  type PointerInput,
259
269
  type PointerTracker,
260
270
  type Ray,
@@ -282,6 +292,7 @@ export {
282
292
  type SceneGraph,
283
293
  type SceneGraphOptions,
284
294
  } from './sceneGraph.ts';
295
+ export { type PreviewOptions, type PreviewResult, previewSvg } from './preview.ts';
285
296
  export {
286
297
  type DescriptionError,
287
298
  emptyIndex,
@@ -310,6 +321,7 @@ export {
310
321
  export { type BoxCorner, boxSelect, type CanvasSize, type Selectable } from './boxSelect.ts';
311
322
  export * as History from './history.ts';
312
323
  export * as Lockstep from './lockstep.ts';
324
+ export * as Timeline from './timeline.ts';
313
325
  export {
314
326
  describeScene,
315
327
  formatSummary,
@@ -203,6 +203,8 @@ export const createSceneLoop = (options: SceneLoopOptions): SceneLoop => {
203
203
  for (const event of tracker.frame()) emit(event);
204
204
  for (const collision of graph.collisions()) emit({ _tag: 'Collision', ...collision });
205
205
  for (const force of graph.contactForces()) emit({ _tag: 'ContactForce', ...force });
206
+ // After the collisions of the steps that led there, so a Model has them all first.
207
+ for (const reached of graph.physicsReached()) emit({ _tag: 'PhysicsReached', ...reached });
206
208
  // Settings change only through reconcile, which replaces the value: compare by reference.
207
209
  const settings = graph.renderSettings();
208
210
  if (settings !== configured) {
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The order to paint a still in, far to near from one eye, exactly: a binary space
3
+ * partition (Fuchs, Kedem and Naylor, 1980) of what is drawn, where sorting by depth
4
+ * would paint a floor over what stands on it. It partitions objects rather than faces: an
5
+ * object none of whose faces can hide another (any convex solid, its back faces culled)
6
+ * is painted as it is, and is told which side of a plane it lies by its bounding sphere
7
+ * before its faces are looked at, so a scene of many objects stays quick. Splitting
8
+ * planes are chosen to cut few objects and to share them out evenly.
9
+ *
10
+ * @module
11
+ */
12
+ import { Sphere, Vector3 } from 'three';
13
+
14
+ /** A flat convex polygon in the world, and what it is painted as. */
15
+ export type Facet<T> = {
16
+ readonly _tag: 'Facet';
17
+ readonly points: ReadonlyArray<Vector3>;
18
+ readonly item: T;
19
+ };
20
+
21
+ /** Something without area (a line, a point) at a place in the world. */
22
+ export type Mark<T> = { readonly _tag: 'Mark'; readonly at: Vector3; readonly item: T };
23
+
24
+ /** What one object draws, and whether its facets may be painted in any order. */
25
+ export type Unit<T> = {
26
+ readonly facets: ReadonlyArray<Facet<T>>;
27
+ readonly marks: ReadonlyArray<Mark<T>>;
28
+ readonly bounds: Sphere;
29
+ readonly selfOrdered: boolean;
30
+ };
31
+
32
+ /** A plane, `normal · p = constant`. */
33
+ export type Plane = { readonly normal: Vector3; readonly constant: number };
34
+
35
+ /** How far from a plane, in world units a unit from the origin, a point counts as on it. */
36
+ const onPlane = 1e-6;
37
+
38
+ /** How many facets are weighed as each partition's plane. */
39
+ const candidates = 8;
40
+
41
+ /** How much worse a cut object is than an object on the fuller side. */
42
+ const splitCost = 8;
43
+
44
+ const planeOf = (points: ReadonlyArray<Vector3>): Plane | undefined => {
45
+ const [a, b, c] = points;
46
+ if (a === undefined || b === undefined || c === undefined) return undefined;
47
+ const normal = new Vector3().subVectors(b, a).cross(new Vector3().subVectors(c, a));
48
+ if (normal.lengthSq() === 0) return undefined;
49
+ normal.normalize();
50
+ return { normal, constant: normal.dot(a) };
51
+ };
52
+
53
+ const side = (plane: Plane, point: Vector3): number => plane.normal.dot(point) - plane.constant;
54
+
55
+ const toleranceAt = (points: ReadonlyArray<Vector3>): number =>
56
+ onPlane * Math.max(1, ...points.map((point) => point.length()));
57
+
58
+ type Parts<T> = {
59
+ readonly front?: Facet<T>;
60
+ readonly back?: Facet<T>;
61
+ readonly on?: Facet<T>;
62
+ };
63
+
64
+ /** A facet split by a plane: what lies in front of it, behind it, and in it. */
65
+ export const split = <T>(plane: Plane, facet: Facet<T>): Parts<T> => {
66
+ const sides = facet.points.map((point) => side(plane, point));
67
+ const tolerance = toleranceAt(facet.points);
68
+ if (sides.every((s) => Math.abs(s) <= tolerance)) return { on: facet };
69
+ if (sides.every((s) => s >= -tolerance)) return { front: facet };
70
+ if (sides.every((s) => s <= tolerance)) return { back: facet };
71
+ const front: Array<Vector3> = [];
72
+ const back: Array<Vector3> = [];
73
+ facet.points.forEach((point, i) => {
74
+ const next = facet.points[(i + 1) % facet.points.length] ?? point;
75
+ const here = sides[i] ?? 0;
76
+ const there = sides[(i + 1) % sides.length] ?? 0;
77
+ if (here >= -tolerance) front.push(point);
78
+ if (here <= tolerance) back.push(point);
79
+ if ((here > tolerance && there < -tolerance) || (here < -tolerance && there > tolerance)) {
80
+ const crossing = point.clone().lerp(next, here / (here - there));
81
+ front.push(crossing);
82
+ back.push(crossing.clone());
83
+ }
84
+ });
85
+ return {
86
+ ...(front.length >= 3 ? { front: { ...facet, points: front } } : {}),
87
+ ...(back.length >= 3 ? { back: { ...facet, points: back } } : {}),
88
+ };
89
+ };
90
+
91
+ /** Whether the eye is in front of `facet` and all of `facets` lie on or behind its plane. */
92
+ function hidesNone<T>(facet: Facet<T>, facets: ReadonlyArray<Facet<T>>, eye: Vector3): boolean {
93
+ const plane = planeOf(facet.points);
94
+ if (plane === undefined) return true;
95
+ const tolerance = toleranceAt(facet.points);
96
+ return (
97
+ side(plane, eye) > tolerance &&
98
+ facets.every(
99
+ (other) => other === facet || other.points.every((point) => side(plane, point) <= tolerance),
100
+ )
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Gathers what one object draws. Its facets may be painted in any order when the eye is
106
+ * in front of each and every other lies on or behind each one's plane: then none comes
107
+ * between the eye and another.
108
+ */
109
+ export const unitOf = <T>(
110
+ facets: ReadonlyArray<Facet<T>>,
111
+ marks: ReadonlyArray<Mark<T>>,
112
+ eye: Vector3,
113
+ ): Unit<T> => {
114
+ const points = [...facets.flatMap((facet) => facet.points), ...marks.map((mark) => mark.at)];
115
+ const bounds = new Sphere().setFromPoints(points);
116
+ const selfOrdered = facets.every((facet) => hidesNone(facet, facets, eye));
117
+ return { facets, marks, bounds, selfOrdered };
118
+ };
119
+
120
+ type Partition<T> = {
121
+ readonly front: ReadonlyArray<Unit<T>>;
122
+ readonly back: ReadonlyArray<Unit<T>>;
123
+ readonly on: ReadonlyArray<Facet<T>>;
124
+ };
125
+
126
+ const sizeOf = <T>(unit: Unit<T>): number => unit.facets.length + unit.marks.length;
127
+
128
+ /** Which side of `plane` a unit lies, by its bounds: `0` when they cross it. */
129
+ const sideOf = <T>(plane: Plane, unit: Unit<T>): number => {
130
+ const distance = side(plane, unit.bounds.center);
131
+ const reach = unit.bounds.radius * (1 + onPlane) + onPlane;
132
+ return distance > reach ? 1 : distance < -reach ? -1 : 0;
133
+ };
134
+
135
+ /** Units cut in two by `plane`, and how many facets and marks fall either side. */
136
+ const weigh = <T>(plane: Plane, units: ReadonlyArray<Unit<T>>): number => {
137
+ let cut = 0;
138
+ let front = 0;
139
+ let back = 0;
140
+ for (const unit of units) {
141
+ const whole = sideOf(plane, unit);
142
+ if (whole > 0) front += sizeOf(unit);
143
+ else if (whole < 0) back += sizeOf(unit);
144
+ else {
145
+ const parts = unit.facets.map((facet) => split(plane, facet));
146
+ const ahead = parts.filter((part) => part.front !== undefined).length;
147
+ const behind = parts.filter((part) => part.back !== undefined).length;
148
+ if (ahead > 0 && behind > 0) cut++;
149
+ front += ahead;
150
+ back += behind;
151
+ }
152
+ }
153
+ return splitCost * cut + Math.abs(front - back);
154
+ };
155
+
156
+ const partition = <T>(plane: Plane, units: ReadonlyArray<Unit<T>>): Partition<T> => {
157
+ const front: Array<Unit<T>> = [];
158
+ const back: Array<Unit<T>> = [];
159
+ const on: Array<Facet<T>> = [];
160
+ for (const unit of units) {
161
+ const whole = sideOf(plane, unit);
162
+ if (whole > 0) front.push(unit);
163
+ else if (whole < 0) back.push(unit);
164
+ else {
165
+ const ahead: Array<Facet<T>> = [];
166
+ const behind: Array<Facet<T>> = [];
167
+ for (const facet of unit.facets) {
168
+ const parts = split(plane, facet);
169
+ if (parts.front !== undefined) ahead.push(parts.front);
170
+ if (parts.back !== undefined) behind.push(parts.back);
171
+ if (parts.on !== undefined) on.push(parts.on);
172
+ }
173
+ const marksAhead = unit.marks.filter((mark) => side(plane, mark.at) >= 0);
174
+ const marksBehind = unit.marks.filter((mark) => side(plane, mark.at) < 0);
175
+ // A part of an object is bounded by the whole, and keeps its order.
176
+ if (ahead.length + marksAhead.length > 0)
177
+ front.push({ ...unit, facets: ahead, marks: marksAhead });
178
+ if (behind.length + marksBehind.length > 0)
179
+ back.push({ ...unit, facets: behind, marks: marksBehind });
180
+ }
181
+ }
182
+ return { front, back, on };
183
+ };
184
+
185
+ /** The plane of one of a few facets spread over the units: the one that weighs least. */
186
+ const planeFor = <T>(units: ReadonlyArray<Unit<T>>): Plane | undefined => {
187
+ const withFacets = units.filter((unit) => unit.facets.length > 0);
188
+ const count = Math.min(candidates, withFacets.length);
189
+ let best: Plane | undefined;
190
+ let lightest = Number.POSITIVE_INFINITY;
191
+ for (let i = 0; i < count; i++) {
192
+ const unit = withFacets[Math.floor((i * withFacets.length) / count)];
193
+ const facet = unit?.facets[Math.floor((i * (unit?.facets.length ?? 0)) / count)];
194
+ const plane = facet === undefined ? undefined : planeOf(facet.points);
195
+ if (plane === undefined) continue;
196
+ const weight = weigh(plane, units);
197
+ if (weight < lightest) {
198
+ lightest = weight;
199
+ best = plane;
200
+ }
201
+ }
202
+ if (best !== undefined) return best;
203
+ // Every one weighed has no area: any facet that has one.
204
+ for (const unit of withFacets) {
205
+ for (const facet of unit.facets) {
206
+ const plane = planeOf(facet.points);
207
+ if (plane !== undefined) return plane;
208
+ }
209
+ }
210
+ return undefined;
211
+ };
212
+
213
+ const distanceFrom =
214
+ (eye: Vector3) =>
215
+ <T>(mark: Mark<T>): number =>
216
+ mark.at.distanceToSquared(eye);
217
+
218
+ /** Each facet of a unit as a unit of its own, and each mark: for a unit that is not self-ordered. */
219
+ const apart = <T>(unit: Unit<T>): ReadonlyArray<Unit<T>> => [
220
+ ...unit.facets.map((facet) => ({
221
+ facets: [facet],
222
+ marks: [],
223
+ bounds: new Sphere().setFromPoints([...facet.points]),
224
+ selfOrdered: true,
225
+ })),
226
+ ...unit.marks.map((mark) => ({
227
+ facets: [],
228
+ marks: [mark],
229
+ bounds: new Sphere(mark.at.clone(), 0),
230
+ selfOrdered: true,
231
+ })),
232
+ ];
233
+
234
+ /** Appends what `units` draw to `into`, farthest from `eye` first. */
235
+ const paint = <T>(
236
+ units: ReadonlyArray<Unit<T>>,
237
+ eye: Vector3,
238
+ into: Array<Facet<T> | Mark<T>>,
239
+ ): void => {
240
+ const [only] = units;
241
+ if (only !== undefined && units.length === 1) {
242
+ if (only.selfOrdered && only.marks.length === 0) {
243
+ into.push(...only.facets);
244
+ return;
245
+ }
246
+ if (only.facets.length === 0) {
247
+ into.push(...only.marks.toSorted((a, b) => distanceFrom(eye)(b) - distanceFrom(eye)(a)));
248
+ return;
249
+ }
250
+ paint(apart(only), eye, into);
251
+ return;
252
+ }
253
+ const plane = planeFor(units);
254
+ if (plane === undefined) {
255
+ const marks = units.flatMap((unit) => unit.marks);
256
+ into.push(...marks.toSorted((a, b) => distanceFrom(eye)(b) - distanceFrom(eye)(a)));
257
+ return;
258
+ }
259
+ const { front, back, on } = partition(plane, units);
260
+ const facing = side(plane, eye) >= 0;
261
+ paint(facing ? back : front, eye, into);
262
+ into.push(...on);
263
+ paint(facing ? front : back, eye, into);
264
+ };
265
+
266
+ /** What `units` draw, in the order to paint it seen from `eye`: farthest first. */
267
+ export const paintOrder = <T>(
268
+ units: ReadonlyArray<Unit<T>>,
269
+ eye: Vector3,
270
+ ): ReadonlyArray<Facet<T> | Mark<T>> => {
271
+ const into: Array<Facet<T> | Mark<T>> = [];
272
+ paint(units, eye, into);
273
+ return into;
274
+ };
@@ -145,10 +145,95 @@ export type PhysicsWorld = {
145
145
  /** Whether any dynamic body is still moving (not asleep). */
146
146
  readonly awake: () => boolean;
147
147
  readonly dispose: () => void;
148
+ /**
149
+ * For a world that does what it is asked elsewhere (in a worker, ADR-017): the
150
+ * collisions that have come back since the last call, and whether anything it was asked
151
+ * is still to come back. Until it has, `pose`, `contactForces` and `debugLines` are as
152
+ * of what has, and `step` returns no collisions. The scene graph takes it every frame,
153
+ * and reports a world on `until` as there only once nothing is pending, so its checksum
154
+ * is of the engine's own poses. Absent for a world that does what it is asked at once.
155
+ */
156
+ readonly received?: () => Received;
157
+ /**
158
+ * The world as it is between steps, for `PhysicsEngine.restoreWorld` to make again:
159
+ * every body, collider and joint, and whatever the engine carries from step to step.
160
+ * A world working elsewhere gives `undefined` until it has the snapshot it asked for
161
+ * (`received` is pending meanwhile). Absent for an engine that cannot.
162
+ */
163
+ readonly snapshot?: () => Uint8Array | undefined;
164
+ };
165
+
166
+ /** What a world stepping elsewhere has sent back (`PhysicsWorld.received`). */
167
+ export type Received = {
168
+ readonly collisions: ReadonlyArray<Collision>;
169
+ readonly pending: boolean;
170
+ };
171
+
172
+ /**
173
+ * A world made from a snapshot, and what it holds already: the ids of its bodies and of
174
+ * its joints, which the scene graph takes on rather than adding again.
175
+ */
176
+ export type RestoredWorld = {
177
+ readonly world: PhysicsWorld;
178
+ readonly bodies: ReadonlyArray<string>;
179
+ readonly joints: ReadonlyArray<string>;
148
180
  };
149
181
 
150
182
  /** Makes physics worlds. Provide one to the scene graph (in Foldkit, the environment). */
151
- export type PhysicsEngine = { readonly createWorld: (physics: Physics) => PhysicsWorld };
183
+ export type PhysicsEngine = {
184
+ readonly createWorld: (physics: Physics) => PhysicsWorld;
185
+ /**
186
+ * Makes a world from a snapshot one of this engine's worlds took (`snapshot`), as it
187
+ * was then, for `physics({ from })`. Absent for an engine that cannot.
188
+ */
189
+ readonly restoreWorld?: (physics: Physics, snapshot: Uint8Array) => RestoredWorld;
190
+ };
191
+
192
+ /** `count` characters from `first` on. */
193
+ const run = (first: string, count: number): ReadonlyArray<string> =>
194
+ Array.from({ length: count }, (_, i) => String.fromCharCode(first.charCodeAt(0) + i));
195
+
196
+ /** Base64's digits, in order. */
197
+ const digits: ReadonlyArray<string> = [...run('A', 26), ...run('a', 26), ...run('0', 10), '+', '/'];
198
+ const digitValues = new Map(digits.map((char, i) => [char, i]));
199
+ const digit = (value: number): string => digits[value] ?? '';
200
+
201
+ /** A snapshot as text (base64), as `PhysicsReached` carries it and a message can. */
202
+ export const snapshotText = (bytes: Uint8Array): string => {
203
+ const out: Array<string> = [];
204
+ for (let at = 0; at < bytes.length; at += 3) {
205
+ const a = bytes[at] ?? 0;
206
+ const b = bytes[at + 1] ?? 0;
207
+ const c = bytes[at + 2] ?? 0;
208
+ const left = bytes.length - at;
209
+ out.push(
210
+ digit(a >> 2),
211
+ digit(((a & 3) << 4) | (b >> 4)),
212
+ left > 1 ? digit(((b & 15) << 2) | (c >> 6)) : '=',
213
+ left > 2 ? digit(c & 63) : '=',
214
+ );
215
+ }
216
+ return out.join('');
217
+ };
218
+
219
+ /** The bytes of a snapshot written by `snapshotText`. */
220
+ export const snapshotBytes = (text: string): Uint8Array => {
221
+ const digits = text.replace(/=+$/, '');
222
+ const bytes = new Uint8Array(Math.floor((digits.length * 3) / 4));
223
+ let bits = 0;
224
+ let held = 0;
225
+ let at = 0;
226
+ for (const char of digits) {
227
+ bits = (bits << 6) | (digitValues.get(char) ?? 0);
228
+ held += 6;
229
+ if (held >= 8) {
230
+ held -= 8;
231
+ bytes[at] = (bits >> held) & 255;
232
+ at += 1;
233
+ }
234
+ }
235
+ return bytes;
236
+ };
152
237
 
153
238
  const toVec3 = (vector: Vector3): Vec3 => [vector.x, vector.y, vector.z];
154
239
 
@@ -26,10 +26,15 @@ export type Modifiers = {
26
26
  readonly alt: boolean;
27
27
  };
28
28
 
29
+ /** What pressed: `PointerEvent.pointerType`. A finger is less steady than a mouse or pen. */
30
+ export type PointerDevice = 'mouse' | 'pen' | 'touch';
31
+
29
32
  /**
30
33
  * Pointer input in normalised device coordinates. `button` follows `MouseEvent.button`.
31
34
  * `pointer` is the input's `PointerEvent.pointerId` (0 when absent): with two pointers
32
- * pressed (two fingers), the second only takes part in pinches.
35
+ * pressed (two fingers), the second only takes part in pinches. A press's `device` is
36
+ * what pressed (a mouse when absent); it decides how far a press may travel and still be
37
+ * a click on nothing.
33
38
  */
34
39
  export type PointerInput =
35
40
  | {
@@ -45,6 +50,7 @@ export type PointerInput =
45
50
  readonly button: number;
46
51
  readonly pointer?: number;
47
52
  readonly modifiers?: Modifiers;
53
+ readonly device?: PointerDevice;
48
54
  }
49
55
  | {
50
56
  readonly _tag: 'PointerUp';
@@ -285,6 +291,19 @@ export type SceneEvent =
285
291
  readonly maxForceDirection: Vec3;
286
292
  readonly maxForceMagnitude: number;
287
293
  }
294
+ /**
295
+ * A world on `physics({ until })` reached that step and waits there: its run, its
296
+ * step count, and a checksum of every body's pose, which peers running the same inputs
297
+ * share (ADR-014). It comes after the collisions of the steps that led there. At
298
+ * `physics({ snapshotAt })` it carries a snapshot of the world, to start another `from`.
299
+ */
300
+ | {
301
+ readonly _tag: 'PhysicsReached';
302
+ readonly run: number;
303
+ readonly step: number;
304
+ readonly checksum: string;
305
+ readonly snapshot?: string;
306
+ }
288
307
  /**
289
308
  * A measured label's laid-out size changed (`scene({ measure })`, after its glyphs are
290
309
  * laid out): in its own units, before its scale, for sizing what goes behind it, as
@@ -530,14 +549,24 @@ export type PointerTracker = {
530
549
 
531
550
  const none: ReadonlyArray<SceneEvent> = [];
532
551
 
533
- /** A click moving further than this, in CSS pixels, is a drag and never a miss. */
534
- const missTolerance = 2;
552
+ /**
553
+ * How far, in CSS pixels, a press on nothing may travel and still be a miss rather than a
554
+ * drag. React Three Fiber allows 2 (`events.ts`, `delta <= 2`, at pinned `d74622e`), but
555
+ * measures it on the browser's `click`, which a browser only synthesises from a tap that
556
+ * kept within its own, larger touch slop. This kernel works from pointer down and up, so
557
+ * it gives a finger its own allowance: a steady tap wanders a few pixels, a drag far more.
558
+ */
559
+ const missTolerances = { mouse: 2, pen: 2, touch: 10 } as const satisfies Record<
560
+ PointerDevice,
561
+ number
562
+ >;
535
563
 
536
564
  type Press = {
537
565
  readonly key: string | undefined;
538
566
  readonly button: number;
539
567
  readonly x: number;
540
568
  readonly y: number;
569
+ readonly device: PointerDevice;
541
570
  };
542
571
 
543
572
  /** The press a gesture began with: its button, modifiers, mesh and region. */
@@ -599,7 +628,10 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
599
628
  }
600
629
  return;
601
630
  }
602
- if (from?.button === event.button && travel(from, event.x, event.y) <= missTolerance) {
631
+ if (
632
+ from?.button === event.button &&
633
+ travel(from, event.x, event.y) <= missTolerances[from.device]
634
+ ) {
603
635
  events.push({ _tag: 'PointerMissed', button: event.button });
604
636
  }
605
637
  };
@@ -781,7 +813,13 @@ export const createPointerTracker = (picker: Picker): PointerTracker => {
781
813
  moveGesture(id, event, events);
782
814
  break;
783
815
  case 'PointerDown':
784
- press = { key: hit?.key, button: event.button, x: event.x, y: event.y };
816
+ press = {
817
+ key: hit?.key,
818
+ button: event.button,
819
+ x: event.x,
820
+ y: event.y,
821
+ device: event.device ?? 'mouse',
822
+ };
785
823
  pressing = true;
786
824
  dragged = false;
787
825
  if (hit !== undefined) events.push({ _tag: 'PointerDown', button: event.button, ...hit });