@expofp/renderer 3.44.0 → 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.
- package/dist/controls/gestures.js +6 -1
- package/dist/events/pointer.d.ts +76 -17
- package/dist/events/pointer.js +168 -23
- package/dist/objects/containers/SlotContainer.d.ts +37 -16
- package/dist/objects/containers/SlotContainer.js +44 -19
- package/dist/objects/containers/hooks.js +5 -5
- package/dist/objects/shapes/shape-container.d.ts +2 -9
- package/dist/objects/shapes/shape-container.js +0 -9
- package/dist/objects/text/text-container.d.ts +5 -4
- package/dist/objects/text/text-container.js +8 -7
- package/dist/space/Scene.js +3 -4
- package/dist/space/clipping.d.ts +11 -14
- package/dist/space/clipping.js +25 -4
- package/dist/update/slot-writers.js +18 -14
- package/dist/update/store.js +12 -6
- package/dist/utils/hot-state.d.ts +41 -0
- package/dist/utils/hot-state.js +37 -0
- package/package.json +2 -2
|
@@ -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());
|
package/dist/events/pointer.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
111
|
-
*
|
|
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
|
*
|
package/dist/events/pointer.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
92
|
-
|
|
123
|
+
export function registerHitSource(mesh, source) {
|
|
124
|
+
sources.set(mesh, source);
|
|
93
125
|
return () => {
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
140
|
-
*
|
|
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 +
|
|
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
|
-
*
|
|
209
|
-
*
|
|
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
|
|
356
|
+
const sources = hotState(import.meta.hot, 'sources', () => new WeakMap());
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
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
|
|
88
|
-
* — back into the def that drew it. Slots come from a mesh's free list, so a released slot
|
|
89
|
-
* handed to a different def later; freeing by slot keeps both directions from outliving the
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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(
|
|
200
|
+
defAt(instance: number | undefined): RenderableDef | undefined;
|
|
196
201
|
/**
|
|
197
|
-
*
|
|
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
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* @param
|
|
203
|
-
* @returns
|
|
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
|
-
|
|
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
|
|
16
|
-
* — back into the def that drew it. Slots come from a mesh's free list, so a released slot
|
|
17
|
-
* handed to a different def later; freeing by slot keeps both directions from outliving the
|
|
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
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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(
|
|
166
|
-
return
|
|
170
|
+
defAt(instance) {
|
|
171
|
+
return instance === undefined ? undefined : this.defBySlot.get(instance);
|
|
167
172
|
}
|
|
168
173
|
/**
|
|
169
|
-
*
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* @param
|
|
175
|
-
* @returns
|
|
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
|
-
|
|
178
|
-
return this.
|
|
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 =
|
|
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 {
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// one mesh kind from another.
|
|
58
|
-
useEffect(() =>
|
|
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
|
|
2
|
+
import { type BufferGeometry } from 'three';
|
|
3
3
|
import { type BatchedMeshImpl } from '../../core/batch/BatchedMesh';
|
|
4
|
-
import { type
|
|
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
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
110
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
128
|
+
for (const def of this.orderedDefs()) {
|
|
128
129
|
const slot = this.slotOf(def);
|
|
129
130
|
if (slot === undefined)
|
|
130
131
|
continue;
|
package/dist/space/Scene.js
CHANGED
|
@@ -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 {
|
|
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]);
|
package/dist/space/clipping.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
57
|
-
*
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
68
|
+
export declare function depthSeparation(scale: number): number;
|
|
72
69
|
//# sourceMappingURL=clipping.d.ts.map
|
package/dist/space/clipping.js
CHANGED
|
@@ -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 =
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
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());
|
package/dist/update/store.js
CHANGED
|
@@ -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.44.
|
|
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.44.
|
|
45
|
+
"@expofp/geometry": "3.44.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@react-three/test-renderer": "^9.1.0",
|