@expofp/renderer 3.42.0 → 3.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Renderer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { StrictMode, Suspense } from 'react';
2
+ import { StrictMode, Suspense, useEffect, useRef, } from 'react';
3
3
  import { Controls } from './controls';
4
4
  import { CanvasWithDebug, DebugOverlay } from './debug';
5
5
  import { PointerMissed } from './events/PointerMissed';
@@ -27,6 +27,27 @@ export function Renderer(props) {
27
27
  const { debug, scene, memoryLimit, minZoom, maxZoom, minPitch, maxPitch, bounds, smoothTime, controls, lighting, visibleRect, onPointerMissed, gl, requestFrame, onFrame, children, ...canvasProps } = props;
28
28
  const cameraProps = { minZoom, maxZoom, minPitch, maxPitch, bounds, smoothTime };
29
29
  const external = gl !== undefined;
30
+ // The context this mount created, released here rather than left to R3F. `<Canvas>`'s teardown
31
+ // (`unmountComponentAtNode`) defers `forceContextLoss()` behind a 500 ms `setTimeout`, inside a
32
+ // `try` that disconnects events first — so a host that unmounts the renderer holds a full
33
+ // viewport of GPU memory for half a second after it thought the surface was gone, and holds it
34
+ // forever if anything ahead of the loss in that block throws. An SDK `destroy()` that returns
35
+ // while the plan still owns a WebGL2 context is the same bug on a timer, and a plan swap pays it
36
+ // as a real transient: the incoming plan's context is created while the outgoing one still
37
+ // holds its own. R3F's deferred pass still runs afterwards and is unharmed — its remaining work
38
+ // (`renderLists.dispose()`, `dispose(scene)`) is no-ops on a lost context.
39
+ //
40
+ // Safe as a plain unmount effect only because `<StrictMode>` starts *below* here, wrapping the
41
+ // canvas rather than this component: nothing simulates a remount at this level, so the cleanup
42
+ // runs when the renderer is actually going away. External mode never fills the ref — the host
43
+ // owns that context, and `ExternalCanvas` neuters `forceContextLoss` for the same reason.
44
+ const rendererRef = useRef(undefined);
45
+ useEffect(() => {
46
+ return () => {
47
+ rendererRef.current?.forceContextLoss();
48
+ rendererRef.current = undefined;
49
+ };
50
+ }, []);
30
51
  // The one tree, whichever canvas it lands on. The mount decides nothing about it; the tree
31
52
  // leaves out what external mode has no object for — the controls here, the camera inside Scene.
32
53
  const tree = (_jsx(DebugOverlay, { enabled: debug, children: _jsxs(Scene, { sceneDef: scene, external: external, memoryLimit: memoryLimit, lighting: lighting, visibleRect: visibleRect, ...cameraProps, children: [!external && _jsx(Controls, { config: controls }), _jsx(PointerMissed, { onPointerMissed: onPointerMissed }), children] }) }));
@@ -59,7 +80,11 @@ export function Renderer(props) {
59
80
  // a compile synchronous: 17 programs cost 64 ms of main thread on a large scene, in
60
81
  // blocks of up to 12 ms, and they land in the frames where the layers are still mounting.
61
82
  // Debug keeps the check, because a broken shader has to report itself somewhere.
83
+ //
84
+ // The root state is also this mount's one handle on the renderer, which the unmount
85
+ // effect above needs to release its context.
62
86
  onCreated: (state) => {
63
87
  state.gl.debug.checkShaderErrors = debug ?? false;
88
+ rendererRef.current = state.gl;
64
89
  }, frameloop: debug ? 'always' : 'demand', resize: { debounce: 50 }, children: tree }) }) }));
65
90
  }
@@ -1,4 +1,4 @@
1
- import { type ThreeEvent } from '@react-three/fiber';
1
+ import { type RootState, type ThreeEvent } from '@react-three/fiber';
2
2
  import { type Intersection, type Object3D, type Ray, type Raycaster } from 'three';
3
3
  import { type PlanPoint, type Space } from '../space/Space';
4
4
  import { type LayerDef, type RenderableDef } from '../types/defs';
@@ -100,6 +100,23 @@ export declare function resolveHit(hit: Intersection): RenderableDef | undefined
100
100
  * @returns The nearest geometry under the pointer, or — where there is none — the floor beneath it.
101
101
  */
102
102
  export declare function missedPoint(raycaster: Raycaster, scene: Object3D, space: Space): PlanPoint;
103
+ /**
104
+ * 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.
106
+ *
107
+ * R3F raycasts only interactive layers, so it cannot see what stands in front of them. This casts
108
+ * the same ray at the whole scene and cuts the hits at the nearest object no interactive layer
109
+ * 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
112
+ * than that, the occluder is drawn on the hit, not in front of it. A click with every hit dropped is
113
+ * a miss, as a click on the occluder was before.
114
+ * @param hits - R3F's hits, nearest first.
115
+ * @param state - The root's state, holding the ray already aimed at the pointer.
116
+ * @param space - The scene's space, whose scale turns the plan-unit separation into a world one.
117
+ * @returns The hits in front of the nearest occluder.
118
+ */
119
+ export declare function occludeHits(hits: Intersection[], state: RootState, space: Space): Intersection[];
103
120
  /**
104
121
  * Where a ray crosses the plan plane — the floor under the pointer, whatever is drawn above it.
105
122
  *
@@ -1,3 +1,4 @@
1
+ import { ELEVATION_PRECISION } from '../space/clipping';
1
2
  import { useSpace } from '../space/Space';
2
3
  /**
3
4
  * The props that make a layer's group interactive: one dispatching handler per handler the def
@@ -128,6 +129,35 @@ export function missedPoint(raycaster, scene, space) {
128
129
  return space.worldToPlan(nearest.point);
129
130
  return planePoint(raycaster.ray, space);
130
131
  }
132
+ /**
133
+ * 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.
135
+ *
136
+ * R3F raycasts only interactive layers, so it cannot see what stands in front of them. This casts
137
+ * the same ray at the whole scene and cuts the hits at the nearest object no interactive layer
138
+ * 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
141
+ * than that, the occluder is drawn on the hit, not in front of it. A click with every hit dropped is
142
+ * a miss, as a click on the occluder was before.
143
+ * @param hits - R3F's hits, nearest first.
144
+ * @param state - The root's state, holding the ray already aimed at the pointer.
145
+ * @param space - The scene's space, whose scale turns the plan-unit separation into a world one.
146
+ * @returns The hits in front of the nearest occluder.
147
+ */
148
+ export function occludeHits(hits, state, space) {
149
+ if (hits.length === 0)
150
+ return hits;
151
+ const { interaction } = state.internal;
152
+ const occluder = state.raycaster
153
+ .intersectObject(state.scene, true)
154
+ .find((hit) => !isInteractive(hit.object, interaction));
155
+ if (!occluder)
156
+ return hits;
157
+ // 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;
159
+ return hits.filter((hit) => hit.distance <= cut);
160
+ }
131
161
  /**
132
162
  * Where a ray crosses the plan plane — the floor under the pointer, whatever is drawn above it.
133
163
  *
@@ -155,6 +185,19 @@ export function planePoint(ray, space) {
155
185
  function isDrag(event) {
156
186
  return event.delta * event.delta > DRAG_SLOP_SQ_PX;
157
187
  }
188
+ /**
189
+ * Whether an object sits in an interactive layer: it, or an ancestor, carries R3F handlers.
190
+ * @param object - The object hit.
191
+ * @param interaction - R3F's objects with handlers.
192
+ * @returns Whether R3F raycasts it for events.
193
+ */
194
+ function isInteractive(object, interaction) {
195
+ for (let node = object; node; node = node.parent) {
196
+ if (interaction.includes(node))
197
+ return true;
198
+ }
199
+ return false;
200
+ }
158
201
  /**
159
202
  * Squared pointer travel, in px², above which a release is a drag and not a click — so ~3.9 px of
160
203
  * slop. Squared because that is how the imperative renderer's `dragThreshold` compared it, and this
@@ -2,6 +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
6
  import { Background } from '../objects/Background';
6
7
  import { TextureBudget, TextureBudgetContext } from '../objects/images/texture-budget';
7
8
  import { Layer } from '../objects/layer/index';
@@ -83,6 +84,7 @@ function SceneGraph(props) {
83
84
  const { viewbox, staticTransform, rootLayer } = sceneDef;
84
85
  const { width, height, dpr } = useCanvasSize();
85
86
  const invalidate = useThree((state) => state.invalidate);
87
+ const setEvents = useThree((state) => state.setEvents);
86
88
  const space = useSpace();
87
89
  const [scheduler] = useState(createMountScheduler);
88
90
  // One budget for the scene's lifetime; the limit is synced rather than the budget rebuilt, so an
@@ -117,6 +119,8 @@ function SceneGraph(props) {
117
119
  if (!memoryLimit)
118
120
  logger.debug('no memory limit set, image atlases upload at full size');
119
121
  }, [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]);
120
124
  useEffect(() => {
121
125
  printScene(sceneDef, logger.debug);
122
126
  }, [sceneDef]);
@@ -52,4 +52,21 @@ export declare function nearPlane(distance: number, scale: number): number | und
52
52
  * @returns The far plane in world units.
53
53
  */
54
54
  export declare function farPlane(maxDistance: number): number;
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`).
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.
70
+ */
71
+ export declare const ELEVATION_PRECISION = 0.1;
55
72
  //# sourceMappingURL=clipping.d.ts.map
@@ -72,7 +72,8 @@ 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.
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`).
76
77
  *
77
78
  * **For plan data providers:** shapes sharing an x/y footprint — an elevated platform over a floor
78
79
  * area, layered decorations at one position — must be separated by at least this much on z to be
@@ -86,7 +87,7 @@ export function farPlane(maxDistance) {
86
87
  * order?" — and importing it would let a tuning of the geometry predicate silently re-tune the depth
87
88
  * buffer.
88
89
  */
89
- const ELEVATION_PRECISION = 0.1;
90
+ export const ELEVATION_PRECISION = 0.1;
90
91
  /**
91
92
  * Fraction of the camera's distance kept clear of the near plane at close and default zoom, so
92
93
  * depth-buffer levels are not spent on the empty space between the camera and the plan.
@@ -70,8 +70,10 @@ export interface PointerMissedEventData {
70
70
  * defs, arrive in the order their slots were claimed (mount order), not paint order;
71
71
  * `Pointer/Overlap` pins that.
72
72
  *
73
- * **Only interactive layers are hit.** A layer with no handlers is not raycast, so it never shields
74
- * a def behind it: a booth behind a wall still receives the event.
73
+ * **Everything else occludes.** Geometry in a layer with no handlers is never hit itself, but it
74
+ * hides the defs behind it, opaque or translucent: a booth behind a wall or a model receives no
75
+ * event, and a click there is a miss. Geometry coplanar with a def, such as its label, does not
76
+ * hide it.
75
77
  *
76
78
  * **Handlers return nothing.** One that mutates defs calls `update()` afterward, like any other code.
77
79
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/renderer",
3
- "version": "3.42.0",
3
+ "version": "3.43.0",
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.42.0"
45
+ "@expofp/geometry": "3.43.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@react-three/test-renderer": "^9.1.0",