@expofp/renderer 3.41.1 → 3.42.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,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.41.1",
3
+ "version": "3.42.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.41.1"
45
+ "@expofp/geometry": "3.42.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@react-three/test-renderer": "^9.1.0",