@expofp/renderer 3.43.1 → 3.44.1

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.
@@ -1,6 +1,7 @@
1
1
  import { useThree } from '@react-three/fiber';
2
2
  import { EventManager, Pan, Rotate, Tap } from 'mjolnir.js';
3
3
  import { useEffect } from 'react';
4
+ import { hotState } from '../utils/hot-state';
4
5
  /**
5
6
  * Subscribes to one of the canvas's gestures for as long as the component is mounted.
6
7
  *
@@ -89,5 +90,9 @@ export function canvasPoint(event) {
89
90
  /**
90
91
  * The live recognizers by the element they listen on, so the handlers sharing a canvas share one.
91
92
  * Weak, so a canvas that goes away takes its entry with it.
93
+ *
94
+ * Kept across hot updates, because the ref count is the whole point of sharing: a fresh map would
95
+ * attach a second recognizer to a canvas that already has one, and every pan and pinch would be
96
+ * handled twice (see {@link hotState}).
92
97
  */
93
- const managers = new WeakMap();
98
+ const managers = hotState(import.meta.hot, 'managers', () => new WeakMap());
@@ -1,13 +1,39 @@
1
+ /**
2
+ * Picking: which def the pointer is on, and where on the plan.
3
+ *
4
+ * Two hooks are the way in — {@link usePointerProps} makes a layer's group interactive, and
5
+ * {@link usePickFilter} makes the canvas pick the way the renderer draws. Nothing outside this module
6
+ * calls anything else here; the rest is exported so the sibling spec can drive each piece on its own
7
+ * rather than through a mounted canvas.
8
+ */
1
9
  import { type RootState, type ThreeEvent } from '@react-three/fiber';
2
10
  import { type Intersection, type Object3D, type Ray, type Raycaster } from 'three';
3
11
  import { type PlanPoint, type Space } from '../space/Space';
4
12
  import { type LayerDef, type RenderableDef } from '../types/defs';
5
13
  import { type PointerHandlerName } from '../types/events';
6
14
  /**
7
- * Traces one mesh's intersection back to the def that drew it, or `undefined` when it cannot —
8
- * a slot released between the raycast and the dispatch, or a mesh whose picking is not implemented.
15
+ * What picking asks of a mesh's owner, in the owner's own terms — instances and defs, never
16
+ * intersections. Pulling the instance out of an intersection is this module's work, because the
17
+ * intersection is three's; a container only answers about what it allocated.
18
+ *
19
+ * One interface rather than two registrations, because both answers have one owner and arrive at one
20
+ * moment. `SlotContainer` satisfies it as it stands, so a container registers itself.
9
21
  */
10
- export type HitResolver = (hit: Intersection) => RenderableDef | undefined;
22
+ export interface HitSource {
23
+ /**
24
+ * The def a mesh instance belongs to, or `undefined` when none does — an instance released between
25
+ * the raycast and the dispatch, or a mesh whose picking is not implemented.
26
+ * @param instance - The instance the mesh reported.
27
+ * @returns The def.
28
+ */
29
+ defAt(instance: number | undefined): RenderableDef | undefined;
30
+ /**
31
+ * Where a def sits in this mesh's draw order, counted from the one drawn first.
32
+ * @param def - The def to place.
33
+ * @returns Its place in the draw order.
34
+ */
35
+ paintOrderOf(def: RenderableDef): number;
36
+ }
11
37
  /** The picking props {@link usePointerProps} builds for a layer's `<group>`. */
12
38
  export interface PointerGroupProps {
13
39
  /** Refuses the raycast traversal into the subtree while the layer is off screen. */
@@ -45,6 +71,17 @@ export interface PointerGroupProps {
45
71
  * @returns Props to spread onto the layer's `<group>`.
46
72
  */
47
73
  export declare function usePointerProps(layer: LayerDef, visible: boolean): PointerGroupProps;
74
+ /**
75
+ * Makes the canvas pick the way the renderer draws, for as long as the component is mounted.
76
+ *
77
+ * R3F's `events.filter` is the one hook into picking that sees the whole hit list, which is why both
78
+ * corrections live there: {@link occludeHits} drops what hidden geometry stands in front of, and
79
+ * {@link orderCoplanarHits} settles what the depth buffer never ordered. Both are the renderer's own
80
+ * reading of its scene, so the whole arrangement is here rather than at whichever component mounts
81
+ * it — a caller wires picking by calling this, and nothing outside this module has to know that there
82
+ * are two halves or which order they run in.
83
+ */
84
+ export declare function usePickFilter(): void;
48
85
  /**
49
86
  * Hands one of R3F's intersection events to a layer's handler, as the narrow event the public API
50
87
  * describes: the def that was hit, and where the pointer sits on the plan plane.
@@ -63,21 +100,17 @@ export declare function usePointerProps(layer: LayerDef, visible: boolean): Poin
63
100
  */
64
101
  export declare function dispatchPointer(layer: LayerDef, name: PointerHandlerName, event: ThreeEvent<MouseEvent>, space: Space): void;
65
102
  /**
66
- * Registers how a mesh's intersections map back to defs, for as long as the mesh is mounted.
67
- *
68
- * Each container knows two things no one else does: which field of an intersection identifies its
69
- * slot (`batchId` for a batched mesh, `instanceId` for an instanced one, the pack position for a line
70
- * batch), and which def holds that slot. It supplies both here as one closure, so the dispatch path
71
- * stays free of per-mesh-type knowledge.
72
- * @param mesh - The mesh whose hits the resolver speaks for.
73
- * @param resolve - Maps one of its intersections to a def.
74
- * @returns An unregister function, safe to call after another resolver replaced this one.
103
+ * Registers what speaks for a mesh's intersections, for as long as the mesh is mounted — so the
104
+ * dispatch and ordering paths stay free of per-mesh-type knowledge.
105
+ * @param mesh - The mesh whose hits the source speaks for.
106
+ * @param source - What answers for them.
107
+ * @returns An unregister function, safe to call after another source replaced this one.
75
108
  */
76
- export declare function registerHitResolver(mesh: Object3D, resolve: HitResolver): () => void;
109
+ export declare function registerHitSource(mesh: Object3D, source: HitSource): () => void;
77
110
  /**
78
111
  * The def an intersection hit.
79
112
  *
80
- * A mesh with no registered resolver reports nothing, which is how a primitive whose picking is not
113
+ * A mesh with no registered source reports nothing, which is how a primitive whose picking is not
81
114
  * implemented behaves as if its layer were not interactive: the hit is dropped and no handler runs.
82
115
  * @param hit - The intersection to trace.
83
116
  * @returns The def, or `undefined` when the hit cannot be traced to one.
@@ -102,13 +135,14 @@ export declare function resolveHit(hit: Intersection): RenderableDef | undefined
102
135
  export declare function missedPoint(raycaster: Raycaster, scene: Object3D, space: Space): PlanPoint;
103
136
  /**
104
137
  * Drops the hits that geometry with no handlers hides, so a booth behind a wall or a model, opaque
105
- * or translucent, is not picked. R3F's `events.filter`, which runs on its nearest-first hits.
138
+ * or translucent, is not picked. The first half of R3F's `events.filter`, which runs on its
139
+ * nearest-first hits; {@link orderCoplanarHits} is the second, over what survives.
106
140
  *
107
141
  * R3F raycasts only interactive layers, so it cannot see what stands in front of them. This casts
108
142
  * the same ray at the whole scene and cuts the hits at the nearest object no interactive layer
109
143
  * holds. A wall and a logo on a booth's roof differ only in distance: the logo lies on the roof, and
110
- * the wall stands apart from it. So the cut keeps a hit up to {@link ELEVATION_PRECISION} behind
111
- * the occluder, the least separation at which the depth buffer draws two surfaces in order. Nearer
144
+ * the wall stands apart from it. So the cut keeps a hit up to {@link depthSeparation} behind the
145
+ * occluder, the least separation at which the depth buffer draws two surfaces in order. Nearer
112
146
  * than that, the occluder is drawn on the hit, not in front of it. A click with every hit dropped is
113
147
  * a miss, as a click on the occluder was before.
114
148
  * @param hits - R3F's hits, nearest first.
@@ -117,6 +151,31 @@ export declare function missedPoint(raycaster: Raycaster, scene: Object3D, space
117
151
  * @returns The hits in front of the nearest occluder.
118
152
  */
119
153
  export declare function occludeHits(hits: Intersection[], state: RootState, space: Space): Intersection[];
154
+ /**
155
+ * Reorders the hits the depth buffer never separated, so the one drawn last is picked first. R3F's
156
+ * `events.filter`, after {@link occludeHits} has cut away what is hidden.
157
+ *
158
+ * Three sorts its intersections by distance alone, and two coplanar polygons are not at two
159
+ * distances — they are at one distance the ray-triangle math resolves a little differently for each
160
+ * of them. Which one R3F dispatches first is that float noise, so a click on the overlap selects
161
+ * one shape on one frame and its neighbor on the next. Sorting stably does not help: the distances
162
+ * genuinely differ rather than tying, and the order three produced them in is a mesh's free list,
163
+ * which `SlotContainer` keeps deliberately apart from paint order.
164
+ *
165
+ * So the tie is settled by what the user is already looking at. Hits within
166
+ * {@link depthSeparation} of the nearest of their run are one depth, because that is the separation
167
+ * below which the depth buffer stops ordering surfaces and draw order alone decides what is on top
168
+ * — the same rule {@link occludeHits} cuts by. Within a run they are ordered by where they draw,
169
+ * last first, along the three keys three's own painter sort reads in the same order: the layer
170
+ * group they hang under, the batch's place in that layer, then the instance's place in the batch.
171
+ *
172
+ * A run is measured from its own first hit rather than from the hit before it, so a stack of
173
+ * surfaces each just inside the separation is still cut into runs instead of chaining into one.
174
+ * @param hits - R3F's hits, nearest first.
175
+ * @param space - The scene's space, whose scale turns the plan-unit separation into a world one.
176
+ * @returns The hits in pick order.
177
+ */
178
+ export declare function orderCoplanarHits(hits: Intersection[], space: Space): Intersection[];
120
179
  /**
121
180
  * Where a ray crosses the plan plane — the floor under the pointer, whatever is drawn above it.
122
181
  *
@@ -1,5 +1,16 @@
1
- import { ELEVATION_PRECISION } from '../space/clipping';
1
+ /**
2
+ * Picking: which def the pointer is on, and where on the plan.
3
+ *
4
+ * Two hooks are the way in — {@link usePointerProps} makes a layer's group interactive, and
5
+ * {@link usePickFilter} makes the canvas pick the way the renderer draws. Nothing outside this module
6
+ * calls anything else here; the rest is exported so the sibling spec can drive each piece on its own
7
+ * rather than through a mounted canvas.
8
+ */
9
+ import { useThree } from '@react-three/fiber';
10
+ import { useEffect } from 'react';
11
+ import { depthSeparation } from '../space/clipping';
2
12
  import { useSpace } from '../space/Space';
13
+ import { hotState } from '../utils/hot-state';
3
14
  /**
4
15
  * The props that make a layer's group interactive: one dispatching handler per handler the def
5
16
  * declares, and the raycast gate that un-picks the layer while it is off screen.
@@ -41,6 +52,31 @@ export function usePointerProps(layer, visible) {
41
52
  onPointerOut: handlesPointerOut ? handler('onPointerOut') : undefined,
42
53
  };
43
54
  }
55
+ /**
56
+ * Makes the canvas pick the way the renderer draws, for as long as the component is mounted.
57
+ *
58
+ * R3F's `events.filter` is the one hook into picking that sees the whole hit list, which is why both
59
+ * corrections live there: {@link occludeHits} drops what hidden geometry stands in front of, and
60
+ * {@link orderCoplanarHits} settles what the depth buffer never ordered. Both are the renderer's own
61
+ * reading of its scene, so the whole arrangement is here rather than at whichever component mounts
62
+ * it — a caller wires picking by calling this, and nothing outside this module has to know that there
63
+ * are two halves or which order they run in.
64
+ */
65
+ export function usePickFilter() {
66
+ const setEvents = useThree((state) => state.setEvents);
67
+ // Picking is reported on the plan, and R3F casts its rays in world coordinates.
68
+ const space = useSpace();
69
+ useEffect(() => {
70
+ setEvents({
71
+ filter: (hits, state) => {
72
+ // Cut first, then order: dropping what is hidden cannot move the survivors along the ray,
73
+ // and ordering hits that were about to be dropped is work for nothing.
74
+ const visible = occludeHits(hits, state, space);
75
+ return orderCoplanarHits(visible, space);
76
+ },
77
+ });
78
+ }, [setEvents, space]);
79
+ }
44
80
  /**
45
81
  * Hands one of R3F's intersection events to a layer's handler, as the narrow event the public API
46
82
  * describes: the def that was hit, and where the pointer sits on the plan plane.
@@ -78,33 +114,29 @@ export function dispatchPointer(layer, name, event, space) {
78
114
  });
79
115
  }
80
116
  /**
81
- * Registers how a mesh's intersections map back to defs, for as long as the mesh is mounted.
82
- *
83
- * Each container knows two things no one else does: which field of an intersection identifies its
84
- * slot (`batchId` for a batched mesh, `instanceId` for an instanced one, the pack position for a line
85
- * batch), and which def holds that slot. It supplies both here as one closure, so the dispatch path
86
- * stays free of per-mesh-type knowledge.
87
- * @param mesh - The mesh whose hits the resolver speaks for.
88
- * @param resolve - Maps one of its intersections to a def.
89
- * @returns An unregister function, safe to call after another resolver replaced this one.
117
+ * Registers what speaks for a mesh's intersections, for as long as the mesh is mounted — so the
118
+ * dispatch and ordering paths stay free of per-mesh-type knowledge.
119
+ * @param mesh - The mesh whose hits the source speaks for.
120
+ * @param source - What answers for them.
121
+ * @returns An unregister function, safe to call after another source replaced this one.
90
122
  */
91
- export function registerHitResolver(mesh, resolve) {
92
- resolvers.set(mesh, resolve);
123
+ export function registerHitSource(mesh, source) {
124
+ sources.set(mesh, source);
93
125
  return () => {
94
- if (resolvers.get(mesh) === resolve)
95
- resolvers.delete(mesh);
126
+ if (sources.get(mesh) === source)
127
+ sources.delete(mesh);
96
128
  };
97
129
  }
98
130
  /**
99
131
  * The def an intersection hit.
100
132
  *
101
- * A mesh with no registered resolver reports nothing, which is how a primitive whose picking is not
133
+ * A mesh with no registered source reports nothing, which is how a primitive whose picking is not
102
134
  * implemented behaves as if its layer were not interactive: the hit is dropped and no handler runs.
103
135
  * @param hit - The intersection to trace.
104
136
  * @returns The def, or `undefined` when the hit cannot be traced to one.
105
137
  */
106
138
  export function resolveHit(hit) {
107
- return resolvers.get(hit.object)?.(hit);
139
+ return sources.get(hit.object)?.defAt(hit.instanceId);
108
140
  }
109
141
  /**
110
142
  * Where a missed click landed, in plan units.
@@ -131,13 +163,14 @@ export function missedPoint(raycaster, scene, space) {
131
163
  }
132
164
  /**
133
165
  * Drops the hits that geometry with no handlers hides, so a booth behind a wall or a model, opaque
134
- * or translucent, is not picked. R3F's `events.filter`, which runs on its nearest-first hits.
166
+ * or translucent, is not picked. The first half of R3F's `events.filter`, which runs on its
167
+ * nearest-first hits; {@link orderCoplanarHits} is the second, over what survives.
135
168
  *
136
169
  * R3F raycasts only interactive layers, so it cannot see what stands in front of them. This casts
137
170
  * the same ray at the whole scene and cuts the hits at the nearest object no interactive layer
138
171
  * holds. A wall and a logo on a booth's roof differ only in distance: the logo lies on the roof, and
139
- * the wall stands apart from it. So the cut keeps a hit up to {@link ELEVATION_PRECISION} behind
140
- * the occluder, the least separation at which the depth buffer draws two surfaces in order. Nearer
172
+ * the wall stands apart from it. So the cut keeps a hit up to {@link depthSeparation} behind the
173
+ * occluder, the least separation at which the depth buffer draws two surfaces in order. Nearer
141
174
  * than that, the occluder is drawn on the hit, not in front of it. A click with every hit dropped is
142
175
  * a miss, as a click on the occluder was before.
143
176
  * @param hits - R3F's hits, nearest first.
@@ -155,9 +188,62 @@ export function occludeHits(hits, state, space) {
155
188
  if (!occluder)
156
189
  return hits;
157
190
  // Along the ray, a pitched view only lengthens the gap between two surfaces, never shortens it.
158
- const cut = occluder.distance + ELEVATION_PRECISION * space.scale;
191
+ const cut = occluder.distance + depthSeparation(space.scale);
159
192
  return hits.filter((hit) => hit.distance <= cut);
160
193
  }
194
+ /**
195
+ * Reorders the hits the depth buffer never separated, so the one drawn last is picked first. R3F's
196
+ * `events.filter`, after {@link occludeHits} has cut away what is hidden.
197
+ *
198
+ * Three sorts its intersections by distance alone, and two coplanar polygons are not at two
199
+ * distances — they are at one distance the ray-triangle math resolves a little differently for each
200
+ * of them. Which one R3F dispatches first is that float noise, so a click on the overlap selects
201
+ * one shape on one frame and its neighbor on the next. Sorting stably does not help: the distances
202
+ * genuinely differ rather than tying, and the order three produced them in is a mesh's free list,
203
+ * which `SlotContainer` keeps deliberately apart from paint order.
204
+ *
205
+ * So the tie is settled by what the user is already looking at. Hits within
206
+ * {@link depthSeparation} of the nearest of their run are one depth, because that is the separation
207
+ * below which the depth buffer stops ordering surfaces and draw order alone decides what is on top
208
+ * — the same rule {@link occludeHits} cuts by. Within a run they are ordered by where they draw,
209
+ * last first, along the three keys three's own painter sort reads in the same order: the layer
210
+ * group they hang under, the batch's place in that layer, then the instance's place in the batch.
211
+ *
212
+ * A run is measured from its own first hit rather than from the hit before it, so a stack of
213
+ * surfaces each just inside the separation is still cut into runs instead of chaining into one.
214
+ * @param hits - R3F's hits, nearest first.
215
+ * @param space - The scene's space, whose scale turns the plan-unit separation into a world one.
216
+ * @returns The hits in pick order.
217
+ */
218
+ export function orderCoplanarHits(hits, space) {
219
+ if (hits.length < 2)
220
+ return hits;
221
+ const separation = depthSeparation(space.scale);
222
+ const ordered = [];
223
+ for (let start = 0; start < hits.length;) {
224
+ let end = start + 1;
225
+ while (end < hits.length) {
226
+ const gap = hits[end].distance - hits[start].distance;
227
+ // A step *back* toward the camera ends the run rather than extending it. R3F sorts by event
228
+ // priority first and distance second, so a second root's hits follow the first root's as their
229
+ // own nearest-first list — two scenes, whose hits must not be ordered against each other
230
+ // however near the numbers land.
231
+ if (gap < 0 || gap > separation)
232
+ break;
233
+ end++;
234
+ }
235
+ const run = hits.slice(start, end);
236
+ // Reversed, so the hit drawn last comes first. `sort` is stable, so hits nothing here orders —
237
+ // a primitive whose picking is not implemented, two triangles of one instance — keep the
238
+ // nearest-first order three gave them rather than being shuffled by a comparator with nothing
239
+ // to say about them.
240
+ if (run.length > 1)
241
+ run.sort((a, b) => compareDrawOrder(b, a));
242
+ ordered.push(...run);
243
+ start = end;
244
+ }
245
+ return ordered;
246
+ }
161
247
  /**
162
248
  * Where a ray crosses the plan plane — the floor under the pointer, whatever is drawn above it.
163
249
  *
@@ -185,6 +271,61 @@ export function planePoint(ray, space) {
185
271
  function isDrag(event) {
186
272
  return event.delta * event.delta > DRAG_SLOP_SQ_PX;
187
273
  }
274
+ /**
275
+ * Compares two hits by where they draw, earliest first.
276
+ *
277
+ * The three keys are three's own, read in three's order: a mesh inherits the `renderOrder` of its
278
+ * nearest ancestor group as its group order, which outranks its own `renderOrder`, which outranks
279
+ * anything within the mesh. Here that reads as the layer, then the batch, then the instance.
280
+ *
281
+ * **The layer is a key of its own because the two numberings are not one scale.** A layer's
282
+ * `<group>` carries its place in the whole tree's draw order, subdivided inside `[0, 2)`, while a
283
+ * batch mesh carries its kind's index among its layer's batches — `0`, `1`, `2`. So a batch is never
284
+ * comparable to a batch of another layer, and the mesh numbers alone would put the second batch of
285
+ * an early layer above the first batch of a late one. Collapsing them would mean giving every batch
286
+ * its layer's place plus a fraction, which is exactly the mantissa `Layer` keeps free for nesting.
287
+ *
288
+ * Three's own comparator is unreachable: `painterSortStable` is private to its WebGL renderer, and
289
+ * `renderer.renderLists` holds only the per-object answer — the instance key, the one this bug is
290
+ * about, exists nowhere in it.
291
+ *
292
+ * Two hits on one mesh share both object keys, and two hits on different meshes are nearly always
293
+ * separated by one of them, since a layer's place is unique in the tree and a batch's is unique in its
294
+ * layer. So the branches are exclusive in practice, which is also what keeps one mesh's places from
295
+ * ever being compared against another's — those count from zero independently and mean nothing
296
+ * against each other. Where two meshes do tie on both — the background quad draws level with the
297
+ * first layer, and three settles that by `material.id` — this reports nothing and the stable sort
298
+ * leaves them nearest-first.
299
+ * @param a - The first hit.
300
+ * @param b - The second hit.
301
+ * @returns Negative when `a` draws first, positive when `b` does, zero when nothing orders them.
302
+ */
303
+ function compareDrawOrder(a, b) {
304
+ if (a.object !== b.object) {
305
+ return (groupOrderOf(a.object) - groupOrderOf(b.object) || a.object.renderOrder - b.object.renderOrder);
306
+ }
307
+ const source = sources.get(a.object);
308
+ if (!source)
309
+ return 0;
310
+ const first = source.defAt(a.instanceId);
311
+ const second = source.defAt(b.instanceId);
312
+ // A hit traced to no def reports the first place rather than the last: nothing is known about
313
+ // where it drew, and that must not outrank a def whose place is known.
314
+ return (first ? source.paintOrderOf(first) : 0) - (second ? source.paintOrderOf(second) : 0);
315
+ }
316
+ /**
317
+ * The group order three would give an object: the `renderOrder` of its nearest ancestor group,
318
+ * which for anything a layer holds is that layer's place in the tree's draw order.
319
+ * @param object - The object to place.
320
+ * @returns The group order, or `0` for an object under no group at all.
321
+ */
322
+ function groupOrderOf(object) {
323
+ for (let node = object.parent; node; node = node.parent) {
324
+ if (node.isGroup)
325
+ return node.renderOrder;
326
+ }
327
+ return 0;
328
+ }
188
329
  /**
189
330
  * Whether an object sits in an interactive layer: it, or an ancestor, carries R3F handlers.
190
331
  * @param object - The object hit.
@@ -205,7 +346,11 @@ function isInteractive(object, interaction) {
205
346
  */
206
347
  const DRAG_SLOP_SQ_PX = 15;
207
348
  /**
208
- * Mesh-to-def resolution for every mounted container, keyed weakly by mesh identity — so nothing has
209
- * to be cleaned up per renderer, and two renderers holding the same defs never collide.
349
+ * What speaks for each mounted container's mesh, keyed weakly by mesh identity — so nothing has to
350
+ * be cleaned up per renderer, and two renderers holding the same defs never collide.
351
+ *
352
+ * Kept across hot updates, because a container registers from a mount effect that a re-render does
353
+ * not re-run: a fresh map would leave every mesh in the scene unpickable until a full reload, which
354
+ * is what editing anything in this module's import graph used to do (see {@link hotState}).
210
355
  */
211
- const resolvers = new WeakMap();
356
+ const sources = hotState(import.meta.hot, 'sources', () => new WeakMap());
@@ -1,4 +1,4 @@
1
- import { type Intersection, type Object3D } from 'three';
1
+ import { type Object3D } from 'three';
2
2
  import { type UniformSchemaShader } from '../../core/InstanceUniforms';
3
3
  import { type UniformValue } from '../../core/SquareDataTexture';
4
4
  import { type RenderableDef } from '../../types/defs';
@@ -84,10 +84,10 @@ export interface SlotContainerOptions {
84
84
  * What lives here is everything that does not depend on which def type is drawn:
85
85
  *
86
86
  * - **Slot allocation**, in both directions. `slotOf` is what a paint-order pass over the layer's
87
- * defs looks each def up with, and {@link defAt} is what turns a pointer hit — which names a slot
88
- * — back into the def that drew it. Slots come from a mesh's free list, so a released slot may be
89
- * handed to a different def later; freeing by slot keeps both directions from outliving the claim
90
- * they describe.
87
+ * defs looks each def up with, and {@link defAt} is what turns a pointer hit — which names a mesh
88
+ * instance — back into the def that drew it. Slots come from a mesh's free list, so a released slot
89
+ * may be handed to a different def later; freeing by slot keeps both directions from outliving the
90
+ * claim they describe.
91
91
  * - **Paint order**, explicitly. Instance ids come from a mesh's free list, so they do not track
92
92
  * sibling order — a container that let slot order stand in for paint order would reorder itself
93
93
  * after any claim/release churn, and again under StrictMode's remount, which re-claims in reverse.
@@ -188,21 +188,32 @@ export declare abstract class SlotContainer<R, M extends SlotMesh = SlotMesh, T
188
188
  */
189
189
  release(slot: number): void;
190
190
  /**
191
- * The def holding a slot — the reverse of {@link claim}.
192
- * @param slot - The slot to look up, or `undefined` for a hit that named none.
193
- * @returns The def, or `undefined` when the slot is free.
191
+ * The def a mesh instance belongs to — the reverse of {@link claim}, and what picking asks, since
192
+ * an intersection names an instance and nothing else.
193
+ *
194
+ * The instance *is* the slot for every container but the text batch, whose defs own one member per
195
+ * line; that one overrides this to resolve the member's group first, which is why the parameter is
196
+ * the mesh's number rather than the container's.
197
+ * @param instance - The instance the mesh reported, or `undefined` for a hit that named none.
198
+ * @returns The def, or `undefined` when no claim holds that instance.
194
199
  */
195
- defAt(slot: number | undefined): RenderableDef | undefined;
200
+ defAt(instance: number | undefined): RenderableDef | undefined;
196
201
  /**
197
- * The def an intersection with this container's mesh hit.
202
+ * Where a def sits in this container's draw order, counted from the one drawn first.
203
+ *
204
+ * The public read of an assignment the order pass makes privately. Picking uses it to break a tie
205
+ * the depth buffer never resolved: two surfaces closer together than `depthSeparation` are drawn
206
+ * in list order rather than depth order, so list order is also what decides which of them the
207
+ * pointer is on (see `orderCoplanarHits`). Per def rather than per slot, because a slot is a mesh's
208
+ * own numbering out of a free list and a def is what the layer ordered.
198
209
  *
199
- * Which field of an intersection names a slot is the container's own knowledge — `instanceId` for
200
- * an instanced mesh, `batchId` for a batched one, the member id for a text batch, whose defs own
201
- * several — so it is answered here rather than at each registration site.
202
- * @param hit - The intersection to trace.
203
- * @returns The def, or `undefined` when the hit cannot be traced to one.
210
+ * A def this container never ordered reports the first place rather than the last: nothing is known
211
+ * about where it drew, and the one thing that must not happen is for that to outrank a def whose
212
+ * place is known.
213
+ * @param def - The def to place.
214
+ * @returns Its place in the draw order.
204
215
  */
205
- defFromHit(hit: Intersection): RenderableDef | undefined;
216
+ paintOrderOf(def: RenderableDef): number;
206
217
  /**
207
218
  * Registers what to do when a member turns out to belong in a sibling container; `undefined`
208
219
  * clears it.
@@ -263,6 +274,16 @@ export declare abstract class SlotContainer<R, M extends SlotMesh = SlotMesh, T
263
274
  * @param layerDimmed - Whether the layer is dimmed, inherited by defs without a `dim` of their own.
264
275
  */
265
276
  setPaintOrder(defs: readonly T[], layerDimmed: boolean): void;
277
+ /**
278
+ * The layer's defs in the order they draw, each labeled with its place in it.
279
+ *
280
+ * Every order pass starts here, the overriding ones included, so the labels exist whatever a
281
+ * container then does with the order — a sortable draw list for most of them, a rebuilt member
282
+ * list for a text batch. That is what lets {@link paintOrderOf} answer for any container's hits
283
+ * rather than only the ones whose mesh sorts.
284
+ * @returns The defs, dimmed first.
285
+ */
286
+ protected orderedDefs(): readonly T[];
266
287
  /**
267
288
  * Labels every claimed slot with its place in the layer's draw order, undimmed defs last.
268
289
  *
@@ -12,10 +12,10 @@ import { DIM_SCHEMA, DIM_UNIFORM, dimOrdered, instanceDim, isDimmed, } from '../
12
12
  * What lives here is everything that does not depend on which def type is drawn:
13
13
  *
14
14
  * - **Slot allocation**, in both directions. `slotOf` is what a paint-order pass over the layer's
15
- * defs looks each def up with, and {@link defAt} is what turns a pointer hit — which names a slot
16
- * — back into the def that drew it. Slots come from a mesh's free list, so a released slot may be
17
- * handed to a different def later; freeing by slot keeps both directions from outliving the claim
18
- * they describe.
15
+ * defs looks each def up with, and {@link defAt} is what turns a pointer hit — which names a mesh
16
+ * instance — back into the def that drew it. Slots come from a mesh's free list, so a released slot
17
+ * may be handed to a different def later; freeing by slot keeps both directions from outliving the
18
+ * claim they describe.
19
19
  * - **Paint order**, explicitly. Instance ids come from a mesh's free list, so they do not track
20
20
  * sibling order — a container that let slot order stand in for paint order would reorder itself
21
21
  * after any claim/release churn, and again under StrictMode's remount, which re-claims in reverse.
@@ -158,24 +158,35 @@ export class SlotContainer {
158
158
  this.defBySlot.delete(slot);
159
159
  }
160
160
  /**
161
- * The def holding a slot — the reverse of {@link claim}.
162
- * @param slot - The slot to look up, or `undefined` for a hit that named none.
163
- * @returns The def, or `undefined` when the slot is free.
161
+ * The def a mesh instance belongs to — the reverse of {@link claim}, and what picking asks, since
162
+ * an intersection names an instance and nothing else.
163
+ *
164
+ * The instance *is* the slot for every container but the text batch, whose defs own one member per
165
+ * line; that one overrides this to resolve the member's group first, which is why the parameter is
166
+ * the mesh's number rather than the container's.
167
+ * @param instance - The instance the mesh reported, or `undefined` for a hit that named none.
168
+ * @returns The def, or `undefined` when no claim holds that instance.
164
169
  */
165
- defAt(slot) {
166
- return slot === undefined ? undefined : this.defBySlot.get(slot);
170
+ defAt(instance) {
171
+ return instance === undefined ? undefined : this.defBySlot.get(instance);
167
172
  }
168
173
  /**
169
- * The def an intersection with this container's mesh hit.
174
+ * Where a def sits in this container's draw order, counted from the one drawn first.
175
+ *
176
+ * The public read of an assignment the order pass makes privately. Picking uses it to break a tie
177
+ * the depth buffer never resolved: two surfaces closer together than `depthSeparation` are drawn
178
+ * in list order rather than depth order, so list order is also what decides which of them the
179
+ * pointer is on (see `orderCoplanarHits`). Per def rather than per slot, because a slot is a mesh's
180
+ * own numbering out of a free list and a def is what the layer ordered.
170
181
  *
171
- * Which field of an intersection names a slot is the container's own knowledge — `instanceId` for
172
- * an instanced mesh, `batchId` for a batched one, the member id for a text batch, whose defs own
173
- * several — so it is answered here rather than at each registration site.
174
- * @param hit - The intersection to trace.
175
- * @returns The def, or `undefined` when the hit cannot be traced to one.
182
+ * A def this container never ordered reports the first place rather than the last: nothing is known
183
+ * about where it drew, and the one thing that must not happen is for that to outrank a def whose
184
+ * place is known.
185
+ * @param def - The def to place.
186
+ * @returns Its place in the draw order.
176
187
  */
177
- defFromHit(hit) {
178
- return this.defAt(hit.instanceId);
188
+ paintOrderOf(def) {
189
+ return this.orderByDef.get(def) ?? 0;
179
190
  }
180
191
  /**
181
192
  * Registers what to do when a member turns out to belong in a sibling container; `undefined`
@@ -257,6 +268,21 @@ export class SlotContainer {
257
268
  this.layerDimmed = layerDimmed;
258
269
  this.applyOrder();
259
270
  }
271
+ /**
272
+ * The layer's defs in the order they draw, each labeled with its place in it.
273
+ *
274
+ * Every order pass starts here, the overriding ones included, so the labels exist whatever a
275
+ * container then does with the order — a sortable draw list for most of them, a rebuilt member
276
+ * list for a text batch. That is what lets {@link paintOrderOf} answer for any container's hits
277
+ * rather than only the ones whose mesh sorts.
278
+ * @returns The defs, dimmed first.
279
+ */
280
+ orderedDefs() {
281
+ const ordered = dimOrdered(this.layerDefs, this.layerDimmed);
282
+ for (let order = 0; order < ordered.length; order++)
283
+ this.orderByDef.set(ordered[order], order);
284
+ return ordered;
285
+ }
260
286
  /**
261
287
  * Labels every claimed slot with its place in the layer's draw order, undimmed defs last.
262
288
  *
@@ -264,10 +290,9 @@ export class SlotContainer {
264
290
  * mesh a rebuilt member list instead.
265
291
  */
266
292
  applyOrder() {
267
- const ordered = dimOrdered(this.layerDefs, this.layerDimmed);
293
+ const ordered = this.orderedDefs();
268
294
  for (let order = 0; order < ordered.length; order++) {
269
295
  const def = ordered[order];
270
- this.orderByDef.set(def, order);
271
296
  const slot = this.slotOf(def);
272
297
  if (slot === undefined)
273
298
  continue;
@@ -1,6 +1,6 @@
1
1
  import { useStore } from '@react-three/fiber';
2
2
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
- import { registerHitResolver } from '../../events/pointer';
3
+ import { registerHitSource } from '../../events/pointer';
4
4
  import { registerSlotWriter, subscribeDef, syncNow } from '../../update';
5
5
  import { resolveDim, subscribeDim, useDimChain } from '../layer/layer-context';
6
6
  /**
@@ -52,10 +52,10 @@ export function useContainer(create, defs, onKindMismatch) {
52
52
  invalidate();
53
53
  });
54
54
  }, [container, chain, invalidate]);
55
- // How a hit on this container's mesh becomes a def. From an effect, so the mesh is bound by now;
56
- // the container answers which field of an intersection names its slot, so nothing here has to know
57
- // one mesh kind from another.
58
- useEffect(() => registerHitResolver(container.mesh, (hit) => container.defFromHit(hit)), [container]);
55
+ // What a hit on this container's mesh means: which def drew it, and where that def sits in the
56
+ // draw order. From an effect, so the mesh is bound by now; the container answers both, so nothing
57
+ // here has to know one mesh kind from another.
58
+ useEffect(() => registerHitSource(container.mesh, container), [container]);
59
59
  // Assigned after mount rather than passed to `create`, because the container is built once and
60
60
  // outlives every render, so it cannot close over a fresh callback. A caller with nothing to run on
61
61
  // a mismatch passes no handler, and this then does nothing: no call into the container and no
@@ -1,7 +1,7 @@
1
1
  import { type Shape } from '@expofp/geometry';
2
- import { type BufferGeometry, type Intersection } from 'three';
2
+ import { type BufferGeometry } from 'three';
3
3
  import { type BatchedMeshImpl } from '../../core/batch/BatchedMesh';
4
- import { type RenderableDef, type ShapeDef } from '../../types/defs';
4
+ import { type ShapeDef } from '../../types/defs';
5
5
  import { SlotContainer } from '../containers';
6
6
  /**
7
7
  * Every kind of shape batch, **in the order they draw within a layer** — which is what `Shapes`
@@ -129,13 +129,6 @@ export declare class ShapeContainer extends SlotContainer<BufferGeometry, Batche
129
129
  * @param defs - The shapes this container is about to hold.
130
130
  */
131
131
  reserve(defs: readonly ShapeDef[]): void;
132
- /**
133
- * A batched mesh reports the instance it hit as `batchId`, which is the slot this container
134
- * allocated.
135
- * @param hit - The intersection to trace.
136
- * @returns The def, or `undefined` when the hit cannot be traced to one.
137
- */
138
- defFromHit(hit: Intersection): RenderableDef | undefined;
139
132
  protected addInstance(geometry: BufferGeometry): number;
140
133
  protected removeInstance(slot: number): void;
141
134
  /**
@@ -151,15 +151,6 @@ export class ShapeContainer extends SlotContainer {
151
151
  this.target.vertices = vertices;
152
152
  this.target.indices = indices;
153
153
  }
154
- /**
155
- * A batched mesh reports the instance it hit as `batchId`, which is the slot this container
156
- * allocated.
157
- * @param hit - The intersection to trace.
158
- * @returns The def, or `undefined` when the hit cannot be traced to one.
159
- */
160
- defFromHit(hit) {
161
- return this.defAt(hit.batchId);
162
- }
163
154
  addInstance(geometry) {
164
155
  const geometryId = this.geometryIdFor(geometry);
165
156
  // Full exactly when no slot is free and no room remains to append one; `addInstance` would
@@ -1,4 +1,3 @@
1
- import { type Intersection } from 'three';
2
1
  import { type BatchedTextImpl } from '../../core/text/BatchedText';
3
2
  import { TextImpl } from '../../core/text/Text';
4
3
  import { type RenderableDef, type TextDef } from '../../types/defs';
@@ -77,11 +76,13 @@ export declare class TextContainer extends SlotContainer<number, BatchedTextImpl
77
76
  */
78
77
  setPaintOrder(defs: readonly TextDef[], layerDimmed: boolean): void;
79
78
  /**
80
- * A hit names the batch member it struck, and a def owns one member per line.
81
- * @param hit - The intersection to trace.
79
+ * The batch reports the member it was hit on, and a def owns one member per line — so unlike every
80
+ * other container's, this one's slots are not its mesh's instances, and a member resolves to a def
81
+ * only through the group holding it.
82
+ * @param member - The member the batch reported.
82
83
  * @returns The def, or `undefined` when no claimed group holds that member.
83
84
  */
84
- defFromHit(hit: Intersection): RenderableDef | undefined;
85
+ defAt(member: number | undefined): RenderableDef | undefined;
85
86
  /**
86
87
  * Rebuilds the flat member order from the current defs and hands it to the batch, unless nothing
87
88
  * moved.
@@ -1,7 +1,7 @@
1
1
  import { TextImpl } from '../../core/text/Text';
2
2
  import { sameSequence } from '../../utils/arrays';
3
3
  import { SlotContainer } from '../containers';
4
- import { dimOrdered, isDimmed } from '../materials/effects/color-dim';
4
+ import { isDimmed } from '../materials/effects/color-dim';
5
5
  import { LAYOUT_FONT_SIZE, LINE_HEIGHT } from './text-layout';
6
6
  /**
7
7
  * One text layer's bookkeeping: a group of members per def, and the paint order across them.
@@ -106,13 +106,14 @@ export class TextContainer extends SlotContainer {
106
106
  this.scheduleSync();
107
107
  }
108
108
  /**
109
- * A hit names the batch member it struck, and a def owns one member per line.
110
- * @param hit - The intersection to trace.
109
+ * The batch reports the member it was hit on, and a def owns one member per line — so unlike every
110
+ * other container's, this one's slots are not its mesh's instances, and a member resolves to a def
111
+ * only through the group holding it.
112
+ * @param member - The member the batch reported.
111
113
  * @returns The def, or `undefined` when no claimed group holds that member.
112
114
  */
113
- defFromHit(hit) {
114
- const member = hit.instanceId;
115
- return member === undefined ? undefined : this.defAt(this.groupByMember.get(member));
115
+ defAt(member) {
116
+ return super.defAt(member === undefined ? undefined : this.groupByMember.get(member));
116
117
  }
117
118
  /**
118
119
  * Rebuilds the flat member order from the current defs and hands it to the batch, unless nothing
@@ -124,7 +125,7 @@ export class TextContainer extends SlotContainer {
124
125
  */
125
126
  applyOrder() {
126
127
  const order = [];
127
- for (const def of dimOrdered(this.layerDefs, this.layerDimmed)) {
128
+ for (const def of this.orderedDefs()) {
128
129
  const slot = this.slotOf(def);
129
130
  if (slot === undefined)
130
131
  continue;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useThree } from '@react-three/fiber';
3
3
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { LayoutDriver } from '../events/LayoutDriver';
5
- import { occludeHits } from '../events/pointer';
5
+ import { usePickFilter } from '../events/pointer';
6
6
  import { Background } from '../objects/Background';
7
7
  import { TextureBudget, TextureBudgetContext } from '../objects/images/texture-budget';
8
8
  import { Layer } from '../objects/layer/index';
@@ -84,8 +84,9 @@ function SceneGraph(props) {
84
84
  const { viewbox, staticTransform, rootLayer } = sceneDef;
85
85
  const { width, height, dpr } = useCanvasSize();
86
86
  const invalidate = useThree((state) => state.invalidate);
87
- const setEvents = useThree((state) => state.setEvents);
88
87
  const space = useSpace();
88
+ // Here rather than on either canvas, so the renderer's own and a host's both pick alike.
89
+ usePickFilter();
89
90
  const [scheduler] = useState(createMountScheduler);
90
91
  // One budget for the scene's lifetime; the limit is synced rather than the budget rebuilt, so an
91
92
  // atlas's registration survives a `memoryLimit` change.
@@ -119,8 +120,6 @@ function SceneGraph(props) {
119
120
  if (!memoryLimit)
120
121
  logger.debug('no memory limit set, image atlases upload at full size');
121
122
  }, [budget, memoryLimit]);
122
- // Here rather than on either mount, so the renderer's own canvas and a host's both pick through it.
123
- useEffect(() => setEvents({ filter: (hits, state) => occludeHits(hits, state, space) }), [setEvents, space]);
124
123
  useEffect(() => {
125
124
  printScene(sceneDef, logger.debug);
126
125
  }, [sceneDef]);
@@ -53,20 +53,17 @@ export declare function nearPlane(distance: number, scale: number): number | und
53
53
  */
54
54
  export declare function farPlane(maxDistance: number): number;
55
55
  /**
56
- * Minimum z-axis separation between overlapping surfaces, in **plan** units. Picking reads it too:
57
- * geometry closer than this to a hit is drawn on it, so it does not occlude it (see `occludeHits`).
56
+ * {@link ELEVATION_PRECISION} in the world units everything downstream of the scene's space works
57
+ * in: the least separation at which two surfaces still draw in a defined order.
58
58
  *
59
- * **For plan data providers:** shapes sharing an x/y footprint — an elevated platform over a floor
60
- * area, layered decorations at one position — must be separated by at least this much on z to be
61
- * ordered correctly. No minimum horizontal separation is required: screen-pixel density and depth
62
- * precision both scale with camera distance and with the scene's scale, so any separation visible on
63
- * screen already has depth margin, at any viewport size, zoom, scale or camera rotation.
64
- *
65
- * Deliberately the renderer's own constant rather than `@expofp/geometry`'s `ELEVATION_TOLERANCE`,
66
- * which happens to hold the same number. They answer different questions — geometry's is "are these
67
- * two elevations the same point?", this one is "must these two elevations still draw in the right
68
- * order?" — and importing it would let a tuning of the geometry predicate silently re-tune the depth
69
- * buffer.
59
+ * Every reader of the constant needs it this way round, which is why the conversion lives here
60
+ * rather than at each of them. The near plane sizes the depth buffer against it, `occludeHits` keeps
61
+ * a hit that near behind an occluder because the occluder is drawn *on* it rather than in front of
62
+ * it, and `orderCoplanarHits` treats two hits that near as one depth the buffer never separated — so
63
+ * a tuning of the constant moves picking and drawing together, which is the only way the two can
64
+ * agree.
65
+ * @param scale - World units per plan unit, from the scene's {@link Space}.
66
+ * @returns The separation in world units.
70
67
  */
71
- export declare const ELEVATION_PRECISION = 0.1;
68
+ export declare function depthSeparation(scale: number): number;
72
69
  //# sourceMappingURL=clipping.d.ts.map
@@ -58,7 +58,7 @@ export function nearPlane(distance, scale) {
58
58
  return undefined;
59
59
  // Pitching the camera foreshortens an elevation along the view axis, so the separation the depth
60
60
  // buffer actually sees is the plan one times the cosine.
61
- const precision = ELEVATION_PRECISION * scale * MIN_PITCH_COSINE;
61
+ const precision = depthSeparation(scale) * MIN_PITCH_COSINE;
62
62
  const near = (distance * distance) / (DEPTH_BUFFER_LEVELS * precision);
63
63
  return Math.min(Math.max(near, distance * (1 - HEADROOM_RATIO)), distance * (1 - MIN_HEADROOM_RATIO));
64
64
  }
@@ -72,8 +72,26 @@ export function farPlane(maxDistance) {
72
72
  return Math.max(maxDistance, 0.1) * 2;
73
73
  }
74
74
  /**
75
- * Minimum z-axis separation between overlapping surfaces, in **plan** units. Picking reads it too:
76
- * geometry closer than this to a hit is drawn on it, so it does not occlude it (see `occludeHits`).
75
+ * {@link ELEVATION_PRECISION} in the world units everything downstream of the scene's space works
76
+ * in: the least separation at which two surfaces still draw in a defined order.
77
+ *
78
+ * Every reader of the constant needs it this way round, which is why the conversion lives here
79
+ * rather than at each of them. The near plane sizes the depth buffer against it, `occludeHits` keeps
80
+ * a hit that near behind an occluder because the occluder is drawn *on* it rather than in front of
81
+ * it, and `orderCoplanarHits` treats two hits that near as one depth the buffer never separated — so
82
+ * a tuning of the constant moves picking and drawing together, which is the only way the two can
83
+ * agree.
84
+ * @param scale - World units per plan unit, from the scene's {@link Space}.
85
+ * @returns The separation in world units.
86
+ */
87
+ export function depthSeparation(scale) {
88
+ return ELEVATION_PRECISION * scale;
89
+ }
90
+ /**
91
+ * Minimum z-axis separation between overlapping surfaces, in **plan** units. Picking reads it too,
92
+ * through {@link depthSeparation}: geometry closer than this to a hit is drawn on it rather than in
93
+ * front of it, so it does not occlude it, and two hits this close are one depth the buffer never
94
+ * ordered, so the pointer takes whichever of them draws last.
77
95
  *
78
96
  * **For plan data providers:** shapes sharing an x/y footprint — an elevated platform over a floor
79
97
  * area, layered decorations at one position — must be separated by at least this much on z to be
@@ -86,8 +104,11 @@ export function farPlane(maxDistance) {
86
104
  * two elevations the same point?", this one is "must these two elevations still draw in the right
87
105
  * order?" — and importing it would let a tuning of the geometry predicate silently re-tune the depth
88
106
  * buffer.
107
+ *
108
+ * Unexported: every reader wants it scaled, so {@link depthSeparation} is the way out of this module
109
+ * and this stays the one place the plan-unit figure is written down.
89
110
  */
90
- export const ELEVATION_PRECISION = 0.1;
111
+ const ELEVATION_PRECISION = 0.1;
91
112
  /**
92
113
  * Fraction of the camera's distance kept clear of the near plane at close and default zoom, so
93
114
  * depth-buffer levels are not spent on the empty space between the camera and the plan.
@@ -1,19 +1,6 @@
1
1
  import { isLayerDef } from '../types/guards';
2
+ import { hotState } from '../utils/hot-state';
2
3
  import { update } from './store';
3
- /**
4
- * The write closures mounted instance components register for their defs — the single write path
5
- * from a def's fields into its GPU slots, and the frame that draws what they wrote.
6
- *
7
- * Two callers run them: the instance's own effect (after `update(def)`), and the camera re-layout
8
- * frame loop — the latter mutating defs and re-syncing them without going through React at all.
9
- * Sharing one closure is what keeps the two paths from disagreeing.
10
- *
11
- * A **set** per def, because one def can be drawn by more than one mesh: a model merged into a part
12
- * per source material has an instance in each, and an image whose source is split into tiles has one
13
- * per placement. Holding a single writer let the last instance to mount overwrite its siblings, so
14
- * every later write reached exactly one of a def's slots — a two-part model faded half of itself.
15
- */
16
- const slotWriters = new WeakMap();
17
4
  /**
18
5
  * Registers a def's sync closure, paired with the frame request that shows what it writes.
19
6
  *
@@ -70,3 +57,20 @@ export function writeChanged(defs) {
70
57
  syncNow(def);
71
58
  }
72
59
  }
60
+ /**
61
+ * The write closures mounted instance components register for their defs — the single write path
62
+ * from a def's fields into its GPU slots, and the frame that draws what they wrote.
63
+ *
64
+ * Two callers run them: the instance's own effect (after `update(def)`), and the camera re-layout
65
+ * frame loop — the latter mutating defs and re-syncing them without going through React at all.
66
+ * Sharing one closure is what keeps the two paths from disagreeing.
67
+ *
68
+ * A **set** per def, because one def can be drawn by more than one mesh: a model merged into a part
69
+ * per source material has an instance in each, and an image whose source is split into tiles has one
70
+ * per placement. Holding a single writer let the last instance to mount overwrite its siblings, so
71
+ * every later write reached exactly one of a def's slots — a two-part model faded half of itself.
72
+ *
73
+ * Kept across hot updates, because an instance registers from a mount effect that a re-render does
74
+ * not re-run: a fresh map would make every later `update(def)` a silent no-op (see {@link hotState}).
75
+ */
76
+ const slotWriters = hotState(import.meta.hot, 'slotWriters', () => new WeakMap());
@@ -1,9 +1,4 @@
1
- /**
2
- * Per-def subscription state, created on first subscribe and dropped when the last subscriber
3
- * leaves. Keyed weakly by def identity: a def the renderer never mounted has no entry, which is
4
- * exactly why {@link update} on it is a no-op rather than an error.
5
- */
6
- const subscriptions = new WeakMap();
1
+ import { hotState } from '../utils/hot-state';
7
2
  /**
8
3
  * Marks defs as changed after their fields, geometry, or `children` were mutated, so the renderer
9
4
  * mirrors the mutation into GPU state.
@@ -96,3 +91,14 @@ function notify(def) {
96
91
  for (const listener of entry.listeners)
97
92
  listener();
98
93
  }
94
+ /**
95
+ * Per-def subscription state, created on first subscribe and dropped when the last subscriber
96
+ * leaves. Keyed weakly by def identity: a def the renderer never mounted has no entry, which is
97
+ * exactly why {@link update} on it is a no-op rather than an error.
98
+ *
99
+ * Kept across hot updates, because a component subscribes once and `useSyncExternalStore` re-runs
100
+ * that only when the subscribe function changes identity: a fresh map would leave every mounted
101
+ * subscriber listening to entries no `update` can reach, and that "never mounted" no-op would start
102
+ * swallowing real changes (see {@link hotState}).
103
+ */
104
+ const subscriptions = hotState(import.meta.hot, 'subscriptions', () => new WeakMap());
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Module-level state that survives a hot update.
3
+ *
4
+ * A registry held in a module-level `const` is lost the moment Vite re-evaluates that module, and the
5
+ * renderer has several that hold live wiring rather than a cache: which def a mesh's hits resolve to,
6
+ * which closures `update(def)` reaches, which recognizer a canvas's gestures share. Each is filled
7
+ * once, from a mount effect, so React Refresh — which re-renders without re-running an effect whose
8
+ * dependencies did not change — never refills it. The module comes back empty, every lookup through
9
+ * it misses, and the feature goes quiet with no error anywhere: picking stops resolving defs, updates
10
+ * stop reaching slots.
11
+ */
12
+ /** The part of Vite's `import.meta.hot` this needs, taken structurally so nothing imports its type. */
13
+ interface HotModule {
14
+ /**
15
+ * Carried over to the next instance of the same module by every hot update.
16
+ *
17
+ * Optional because a host may offer a hot context without one: vitest defines `import.meta.hot` for
18
+ * its own watch-mode reload and leaves `data` unset, so reading through it throws at import time
19
+ * and takes the importing module down with it.
20
+ */
21
+ data?: Record<string, unknown>;
22
+ }
23
+ /**
24
+ * The value a module keeps across its own hot updates, built on first use.
25
+ *
26
+ * Each module passes its own `import.meta.hot`, which is `undefined` in a production build — Vite
27
+ * replaces the expression statically, so the branch and this whole module fall away and the call is
28
+ * nothing but the builder. That is also what makes this safe to reach for: it changes development
29
+ * only, and the value a module gets is the same value either way.
30
+ * @param hot - The calling module's `import.meta.hot`.
31
+ * @param key - Names the value within the module, so one module may keep several.
32
+ * @param build - Builds the value the first time the module is evaluated.
33
+ * @returns The value, built now or carried over from the instance before.
34
+ * @example
35
+ * ```ts
36
+ * const sources = hotState(import.meta.hot, 'sources', () => new WeakMap<Object3D, HitSource>());
37
+ * ```
38
+ */
39
+ export declare function hotState<T>(hot: HotModule | undefined, key: string, build: () => T): T;
40
+ export {};
41
+ //# sourceMappingURL=hot-state.d.ts.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Module-level state that survives a hot update.
3
+ *
4
+ * A registry held in a module-level `const` is lost the moment Vite re-evaluates that module, and the
5
+ * renderer has several that hold live wiring rather than a cache: which def a mesh's hits resolve to,
6
+ * which closures `update(def)` reaches, which recognizer a canvas's gestures share. Each is filled
7
+ * once, from a mount effect, so React Refresh — which re-renders without re-running an effect whose
8
+ * dependencies did not change — never refills it. The module comes back empty, every lookup through
9
+ * it misses, and the feature goes quiet with no error anywhere: picking stops resolving defs, updates
10
+ * stop reaching slots.
11
+ */
12
+ /**
13
+ * The value a module keeps across its own hot updates, built on first use.
14
+ *
15
+ * Each module passes its own `import.meta.hot`, which is `undefined` in a production build — Vite
16
+ * replaces the expression statically, so the branch and this whole module fall away and the call is
17
+ * nothing but the builder. That is also what makes this safe to reach for: it changes development
18
+ * only, and the value a module gets is the same value either way.
19
+ * @param hot - The calling module's `import.meta.hot`.
20
+ * @param key - Names the value within the module, so one module may keep several.
21
+ * @param build - Builds the value the first time the module is evaluated.
22
+ * @returns The value, built now or carried over from the instance before.
23
+ * @example
24
+ * ```ts
25
+ * const sources = hotState(import.meta.hot, 'sources', () => new WeakMap<Object3D, HitSource>());
26
+ * ```
27
+ */
28
+ export function hotState(hot, key, build) {
29
+ const data = hot?.data;
30
+ if (!data)
31
+ return build();
32
+ // `in` rather than a comparison against undefined, so a value that is legitimately undefined is
33
+ // kept rather than rebuilt on every update.
34
+ if (!(key in data))
35
+ data[key] = build();
36
+ return data[key];
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/renderer",
3
- "version": "3.43.1",
3
+ "version": "3.44.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/expofp/efp-app.git",
@@ -42,7 +42,7 @@
42
42
  "troika-three-text": "^0.52.5",
43
43
  "tslib": "^2.3.0",
44
44
  "webgl-memory": "^1.1.3",
45
- "@expofp/geometry": "3.43.1"
45
+ "@expofp/geometry": "3.44.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@react-three/test-renderer": "^9.1.0",