@expofp/renderer 3.44.8 → 3.45.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.
Files changed (33) hide show
  1. package/dist/Renderer.js +7 -1
  2. package/dist/controls/hooks.d.ts +2 -1
  3. package/dist/core/sprites/BatchedSprites.js +2 -34
  4. package/dist/core/text/MemberTexture.js +49 -8
  5. package/dist/events/LayoutDriver.d.ts +5 -0
  6. package/dist/events/LayoutDriver.js +136 -29
  7. package/dist/events/layout-targets.d.ts +45 -3
  8. package/dist/events/layout-targets.js +129 -9
  9. package/dist/events/placement/box.d.ts +59 -0
  10. package/dist/events/placement/box.js +96 -0
  11. package/dist/events/placement/grid.d.ts +161 -0
  12. package/dist/events/placement/grid.js +235 -0
  13. package/dist/events/placement/index.d.ts +2 -0
  14. package/dist/events/placement/index.js +1 -0
  15. package/dist/events/placement/pass.d.ts +70 -0
  16. package/dist/events/placement/pass.js +617 -0
  17. package/dist/objects/images/ImageInstance.js +1 -0
  18. package/dist/objects/images/Images.js +2 -2
  19. package/dist/objects/images/image-container.d.ts +12 -0
  20. package/dist/objects/images/image-container.js +20 -4
  21. package/dist/objects/materials/TextureMaterial.d.ts +11 -8
  22. package/dist/objects/materials/TextureMaterial.js +24 -17
  23. package/dist/objects/text/text-container.d.ts +16 -1
  24. package/dist/objects/text/text-container.js +23 -1
  25. package/dist/space/layout-measure.d.ts +12 -10
  26. package/dist/space/layout-measure.js +18 -16
  27. package/dist/types/defs.d.ts +157 -147
  28. package/dist/types/events.d.ts +5 -1
  29. package/dist/types/guards.d.ts +7 -0
  30. package/dist/types/guards.js +10 -0
  31. package/dist/utils/math.d.ts +18 -1
  32. package/dist/utils/math.js +35 -0
  33. package/package.json +2 -2
package/dist/Renderer.js CHANGED
@@ -63,7 +63,13 @@ export function Renderer(props) {
63
63
  // and its own bootstrap effect must run once per context, not be double-invoked into churn.
64
64
  return (_jsx(ExternalCanvas, { gl: gl, requestFrame: requestFrame, onFrame: onFrame, children: tree }));
65
65
  }
66
- return (_jsx(StrictMode, { children: _jsx(Suspense, { fallback: null, children: _jsx(CanvasWithDebug, { ...canvasProps, debugEnabled: debug,
66
+ return (_jsx(StrictMode, { children: _jsx(Suspense, { fallback: null, children: _jsx(CanvasWithDebug, { ...canvasProps,
67
+ // No `dpr`: R3F's default caps the buffer at 2x, and that is a performance choice rather
68
+ // than a size one. Every pixel-authored size is derived from CSS units and the pixel size
69
+ // the renderer publishes (also CSS), so a 3x canvas rasterized by a caller still draws at
70
+ // the size it asked for while the buffer stays at 2x. A 3x buffer is 2.25 times the area
71
+ // of a 2x one, on exactly the devices that can least afford it.
72
+ debugEnabled: debug,
67
73
  // R3F force-writes ACES filmic tone mapping onto a fresh renderer unless `flat` is set,
68
74
  // which lands it on three's own default, `NoToneMapping`, instead. This renderer does not
69
75
  // tone map: `Lighting` keeps the rig's light sum in range by construction, rather than
@@ -85,8 +85,9 @@ export interface CameraState {
85
85
  /** How far the plan is tilted away from face-on, in degrees. */
86
86
  pitch: number;
87
87
  /**
88
- * How much of the plan one device pixel spans, in plan units — the factor anything sized in
88
+ * How much of the plan one CSS pixel spans, in plan units — the factor anything sized in
89
89
  * pixels is laid out against, and the quantity the imperative renderer published as `ptScale`.
90
+ * The device ratio is not part of it.
90
91
  */
91
92
  pixelSize: number;
92
93
  }
@@ -1,5 +1,6 @@
1
1
  import { extend } from '@react-three/fiber';
2
- import { Matrix4, PlaneGeometry, Vector2, Vector3, } from 'three';
2
+ import { Matrix4, PlaneGeometry, Vector2, } from 'three';
3
+ import { aimDepth } from '../../utils/math';
3
4
  import { InstancedMeshImpl } from '../instanced/InstancedMesh';
4
5
  import { RaycastingSprites } from './RaycastingSprites';
5
6
  import { ANCHOR_SCHEMA, ANCHOR_UNIFORM, patchSpriteMaterial, spriteReferenceDepth, } from './sprite-material';
@@ -165,41 +166,8 @@ export const BatchedSprites = extend(BatchedSpritesImpl);
165
166
  function furthestCorner(anchor) {
166
167
  return Math.hypot(0.5 + Math.abs(anchor.x), 0.5 + Math.abs(anchor.y));
167
168
  }
168
- /**
169
- * How far the camera's own axis travels before it meets a batch's own plane, in world units.
170
- *
171
- * That is the depth a plan-unit size is exact at, so it is what the shader cancels the perspective
172
- * divide against. It has to agree with the depth `measurePixelSize` in `space/layout-measure.ts`
173
- * works at, because a caller's `onScale` rule sizes a def in plan units from the pixel size that
174
- * function reports. Without controls mounted that function reads this same crossing point. With
175
- * them it reads their distance to their target, and the two agree while the target sits on the
176
- * plan, which is what orbiting a floor plan keeps true.
177
- *
178
- * The plane is the batch's own, taken from its world matrix, because a sprite batch draws at plan
179
- * `z = 0` and whatever places the plan places the mesh with it.
180
- * @param camera - The camera this draw uses.
181
- * @param planToWorld - The batch's world matrix.
182
- * @returns The distance in world units, or `0` for a camera that never meets the plane ahead of it.
183
- */
184
- function aimDepth(camera, planToWorld) {
185
- // A camera looks down its own negative z.
186
- const direction = _direction.setFromMatrixColumn(camera.matrixWorld, 2).normalize().negate();
187
- const normal = _normal.setFromMatrixColumn(planToWorld, 2).normalize();
188
- const facing = direction.dot(normal);
189
- // The camera looks along the plane and never meets it.
190
- if (facing === 0)
191
- return 0;
192
- const eye = _eye.setFromMatrixPosition(camera.matrixWorld);
193
- const depth = _origin.setFromMatrixPosition(planToWorld).sub(eye).dot(normal) / facing;
194
- // Behind the camera is no measurement, so the shader keeps its plan-unit sizing instead.
195
- return depth > 0 ? depth : 0;
196
- }
197
169
  /** The quad every sprite batch draws. One per process. See `BatchedSpritesImpl.geometry`. */
198
170
  const SPRITE_QUAD = new PlaneGeometry(1, 1);
199
171
  // Scratch, each filled and consumed within one synchronous call.
200
172
  const _anchor = new Vector2();
201
173
  const _matrix = new Matrix4();
202
- const _direction = new Vector3();
203
- const _normal = new Vector3();
204
- const _eye = new Vector3();
205
- const _origin = new Vector3();
@@ -1,5 +1,6 @@
1
1
  import { Color, DataTexture, FloatType, Frustum, Matrix4, RGBAFormat, Sphere, Vector3, } from 'three';
2
2
  import { Y_DOWN_FLIP } from '../../space/Space';
3
+ import { aimDepth } from '../../utils/math';
3
4
  /**
4
5
  * The data texture a batch's shader reads each member's transform and style from: one 32-float row
5
6
  * per member, refilled once per draw with frustum culling folded in.
@@ -58,12 +59,20 @@ export class MemberTexture {
58
59
  // member, so reading it per member made this loop quadratic — and it returns an *array* once
59
60
  // outlines are on, whose `.color` is undefined. The material being drawn is already in hand.
60
61
  const batchColor = host.color ?? material.color ?? 0xffffff;
62
+ // What a screen-space member's size is exact at, and where the camera looks from. Zero turns the
63
+ // correction off, for an orthographic camera (no divide to cancel) and for one that never meets
64
+ // the plan. See `depthScaleOf`.
65
+ const reference = camera.isPerspectiveCamera
66
+ ? aimDepth(camera, host.matrixWorld)
67
+ : 0;
68
+ _eye.setFromMatrixPosition(camera.matrixWorld);
69
+ _forward.setFromMatrixColumn(camera.matrixWorld, 2).normalize().negate();
61
70
  for (let memberId = 0; memberId < rowCount; memberId++) {
62
71
  const text = host.getText(memberId);
63
72
  // An unsynced member has no layout to write and draws nothing either.
64
73
  if (!text?.textRenderInfo)
65
74
  continue;
66
- faceCamera(text, _basis);
75
+ faceCamera(text, _basis, reference > 0 ? depthScaleOf(text, host.matrixWorld, reference) : 1);
67
76
  const start = memberId * FLOATS_PER_MEMBER;
68
77
  if (isDrawn(text)) {
69
78
  this.writeMember(texData, start, text, material, batchColor);
@@ -172,6 +181,32 @@ export class MemberTexture {
172
181
  }
173
182
  }
174
183
  }
184
+ /**
185
+ * How much a screen-space member is scaled so that it keeps the on-screen size its plan-unit size
186
+ * asks for wherever it sits in a perspective frame.
187
+ *
188
+ * The same correction a sprite's shader applies to an image (`spriteDepthScale` in
189
+ * `sprites/sprite-material.ts`), and for the same reason: a member sized in plan units shrinks by
190
+ * the projection's divide, and a pitched camera spreads the plan across a range of depths, so one
191
+ * pixel size is right only where the camera's axis crosses the plan. Without it an image beside its
192
+ * text is constant in pixels while the text is not, and anything laid out inside the image's
193
+ * pixels (a count in a counter circle) slides off it as the pin moves up or down the screen.
194
+ * @param text - The member, with its sprite anchor set.
195
+ * @param matrixWorld - The batch's world matrix.
196
+ * @param reference - The depth the plan-unit size is exact at. Must be positive.
197
+ * @returns The scale, one at the reference depth. One as well for an anchor at or behind the eye.
198
+ */
199
+ function depthScaleOf(text, matrixWorld, reference) {
200
+ const anchor = text.spriteAnchor;
201
+ if (!anchor)
202
+ return 1;
203
+ const depth = _offset
204
+ .set(anchor.x, anchor.y, text.position.z)
205
+ .applyMatrix4(matrixWorld)
206
+ .sub(_eye)
207
+ .dot(_forward);
208
+ return depth > 0 ? depth / reference : 1;
209
+ }
175
210
  /**
176
211
  * Turns a screen-space member to face the camera, and leaves a flat one alone.
177
212
  *
@@ -180,20 +215,23 @@ export class MemberTexture {
180
215
  * `BatchedTextImpl.raycast` and `isDrawn`. This path costs neither, because both of those read the
181
216
  * member's matrix already. Write the sprite matrix there and both follow.
182
217
  *
183
- * The composition is `T(anchor) . basis . T(position - anchor) . Rz(rotation.z) . S(scale)`. The
184
- * basis is the camera's rotation brought into the batch's own space, corrected by `Y_DOWN_FLIP`
185
- * for the camera's own rest pose (see {@link Y_DOWN_FLIP} in `space/Space.ts`). Everything else is
186
- * read off the member exactly as
187
- * `updateMatrix` reads it. Straight overhead the basis is the identity and this reduces to the flat
188
- * matrix, which is the equality the top-down baseline asserts on screen.
218
+ * The composition is
219
+ * `T(anchor) . basis . S(depth) . T(position - anchor) . Rz(rotation.z) . S(scale)`. The basis is
220
+ * the camera's rotation brought into the batch's own space, corrected by `Y_DOWN_FLIP` for the
221
+ * camera's own rest pose (see {@link Y_DOWN_FLIP} in `space/Space.ts`). `S(depth)` is
222
+ * {@link depthScaleOf}, which scales the offset from the anchor along with the glyphs. Everything
223
+ * else is read off the member exactly as `updateMatrix` reads it. Straight overhead with the
224
+ * anchor at the reference depth the basis is the identity and the depth scale is one, so this
225
+ * reduces to the flat matrix, which is the equality the top-down baseline asserts on screen.
189
226
  *
190
227
  * Written through `setMatrix` only when it changed, so a still camera costs sixteen comparisons and
191
228
  * no bounds invalidation. A pan or a zoom costs the same, because neither moves the camera's
192
229
  * rotation. Only a roll or a pitch dirties a row.
193
230
  * @param text - The member to turn.
194
231
  * @param basis - The camera basis for this draw, in the batch's own space.
232
+ * @param depthScale - The member's perspective correction. One for none.
195
233
  */
196
- function faceCamera(text, basis) {
234
+ function faceCamera(text, basis, depthScale) {
197
235
  const anchor = text.spriteAnchor;
198
236
  if (!anchor)
199
237
  return;
@@ -202,6 +240,7 @@ function faceCamera(text, basis) {
202
240
  _offset.set(text.position.x - anchor.x, text.position.y - anchor.y, 0);
203
241
  _spriteMatrix
204
242
  .copy(basis)
243
+ .multiply(_step.makeScale(depthScale, depthScale, depthScale))
205
244
  .multiply(_step.makeTranslation(_offset.x, _offset.y, _offset.z))
206
245
  .multiply(_step.makeRotationZ(text.rotation.z))
207
246
  .scale(text.scale)
@@ -246,3 +285,5 @@ const _cameraBasis = new Matrix4();
246
285
  const _spriteMatrix = new Matrix4();
247
286
  const _step = new Matrix4();
248
287
  const _offset = new Vector3();
288
+ const _eye = new Vector3();
289
+ const _forward = new Vector3();
@@ -21,6 +21,11 @@ export interface LayoutDriverProps {
21
21
  * the user sees change lands first. The writes stay inside the frame callback, before three's own
22
22
  * render, so they upload in the very frame that draws them.
23
23
  *
24
+ * It also drives the registry's placement pass, which depends on the whole camera: a pan, a pitch
25
+ * or a resize moves every screen box while the pixel size and roll sit still. The pass runs before
26
+ * the drain and before three's render, so a verdict is written in the frame that draws it.
27
+ * {@link runPlacement} decides when it runs.
28
+ *
24
29
  * No React is involved in a re-layout. The handlers mutate def fields and the defs' own sync closures
25
30
  * write them, so a zoom does not re-render a single component — which is what makes it affordable for
26
31
  * every label in a plan to re-lay out while the camera moves.
@@ -1,18 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Point } from '@expofp/geometry';
2
3
  import { useFrame, useThree } from '@react-three/fiber';
3
- import { useState } from 'react';
4
- import { Frustum } from 'three';
5
- import { useCameraControls } from '../controls';
4
+ import { useRef, useState } from 'react';
5
+ import { Frustum, Matrix4, Vector2, Vector4 } from 'three';
6
+ import { useCameraControls } from '../controls/hooks';
6
7
  import { measurePixelSize, measureRoll, pixelSizeMoved } from '../space/layout-measure';
7
8
  import { useSpace } from '../space/Space';
8
9
  import { planFrustum } from './def-culling';
9
10
  import { createLayoutTargets, LayoutTargetsContext } from './layout-targets';
10
- /**
11
- * How much of a frame one drain may spend writing laid-out defs into their slots, in milliseconds —
12
- * the same budget the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough
13
- * to leave a 60fps frame its render; the backlog it leaves is carried into the next frame.
14
- */
15
- const LAYOUT_BUDGET_MS = 3;
16
11
  /**
17
12
  * Drives camera-driven re-layout: it owns the renderer's {@link LayoutTargets} and watches both
18
13
  * layout axes every frame — the pixel size, swept past an epsilon, and the roll, swept on any
@@ -30,6 +25,11 @@ const LAYOUT_BUDGET_MS = 3;
30
25
  * the user sees change lands first. The writes stay inside the frame callback, before three's own
31
26
  * render, so they upload in the very frame that draws them.
32
27
  *
28
+ * It also drives the registry's placement pass, which depends on the whole camera: a pan, a pitch
29
+ * or a resize moves every screen box while the pixel size and roll sit still. The pass runs before
30
+ * the drain and before three's render, so a verdict is written in the frame that draws it.
31
+ * {@link runPlacement} decides when it runs.
32
+ *
33
33
  * No React is involved in a re-layout. The handlers mutate def fields and the defs' own sync closures
34
34
  * write them, so a zoom does not re-render a single component — which is what makes it affordable for
35
35
  * every label in a plan to re-lay out while the camera moves.
@@ -48,38 +48,145 @@ export function LayoutDriver(props) {
48
48
  // the controls — absent at this point whatever the mount order, since they publish themselves in
49
49
  // an effect and this runs during render — which is also what lets this component be rendered,
50
50
  // and tested, without any controls at all.
51
- const [targets] = useState(() => createLayoutTargets(invalidate, measurePixelSize(camera, space, gl.domElement.width) ?? 1, measureRoll(camera, space, gl.domElement.width, gl.domElement.height) ?? 0));
51
+ const [targets] = useState(() => createLayoutTargets(invalidate, measurePixelSize(camera, space, gl.domElement.width / gl.getPixelRatio()) ?? 1, measureRoll(camera, space, gl.domElement.width / gl.getPixelRatio(), gl.domElement.height / gl.getPixelRatio()) ?? 0));
52
+ // Frame state for the placement debounce. Nothing renders on it.
53
+ const placementRef = useRef({
54
+ seen: new Matrix4(),
55
+ size: new Vector2(),
56
+ frameAt: -Infinity,
57
+ changedAt: -Infinity,
58
+ placedAt: -Infinity,
59
+ });
52
60
  useFrame((state) => {
53
- const camera = state.camera;
54
- const { width, height } = state.gl.domElement;
55
- // One call for every camera — the renderer's own and external mode's folded matrix alike. The
56
- // controls are handed over rather than branched on here: with them the reading is the cheap
57
- // arithmetic the imperative renderer used, and without them it is measured off the matrices,
58
- // which both cameras carry.
59
- const pixelSize = measurePixelSize(camera, space, width, controls);
60
- // The epsilon compares against the sweep's target size, so a camera that stopped mid-sweep does
61
- // not re-mark the same sweep on every one of the frames that finish it.
61
+ const { camera, gl, viewport } = state;
62
+ const { width, height } = gl.domElement;
63
+ // Everything the renderer publishes is in CSS pixels, so the device ratio stops here.
64
+ const cssWidth = width / viewport.dpr;
65
+ const cssHeight = height / viewport.dpr;
66
+ // 1. The pixel size, swept past an epsilon against the sweep's own target.
67
+ const pixelSize = measurePixelSize(camera, space, cssWidth, controls);
62
68
  if (pixelSize !== undefined && pixelSizeMoved(pixelSize, targets.pixelSize)) {
63
69
  targets.applyScale(pixelSize);
64
70
  }
65
- // Roll has no epsilon — the imperative renderer swept its roll handlers on any bearing change
66
- // at all, and the rules themselves step in quarter turns, so almost every layout is a cheap
67
- // no-write. Which of the two ways it is reached — the controls' own angle, or a measurement off
68
- // the camera for external mode and bare scenes — is `measureRoll`'s to choose.
69
- const roll = measureRoll(camera, space, width, height, controls);
71
+ // 2. The roll, swept on any change: the rules step in quarter turns, so most are no-writes.
72
+ const roll = measureRoll(camera, space, cssWidth, cssHeight, controls);
70
73
  if (roll !== undefined && roll !== targets.roll)
71
74
  targets.applyRoll(roll);
75
+ // 3. The placement pass, ahead of the drain so its verdicts are written this frame.
76
+ const view = {
77
+ space,
78
+ camera,
79
+ pixelSize: targets.pixelSize,
80
+ width: cssWidth,
81
+ height: cssHeight,
82
+ };
83
+ if (runPlacement(targets, placementRef.current, view, performance.now()))
84
+ invalidate();
85
+ // 4. The writes, inside the frustum first, on a budget. A leftover backlog asks for a frame.
72
86
  if (!targets.hasWork)
73
87
  return;
74
- // Priority, never correctness: the frustum only decides who waits, and it is rebuilt each drain
75
- // so a camera that pans mid-sweep re-prioritizes what is left.
76
- const frustum = planFrustum(state.camera, space.matrix, _frustum);
77
- // The writes self-invalidate through the defs' slot writers; this asks for the *extra* frame a
78
- // leftover backlog needs, which is what keeps the demand loop ticking until the sweep is done.
88
+ const frustum = planFrustum(camera, space.matrix, _frustum);
79
89
  if (targets.drain(frustum, LAYOUT_BUDGET_MS))
80
90
  invalidate();
81
91
  });
82
92
  return _jsx(LayoutTargetsContext, { value: targets, children: children });
83
93
  }
94
+ /**
95
+ * Runs the placement pass once the plan has not visibly moved on screen for
96
+ * {@link PLACEMENT_DEBOUNCE_MS}, and at once when a participant registers, leaves or moves. Every
97
+ * pass reports.
98
+ *
99
+ * Visibly means faster than {@link SETTLE_SPEED_PX_PER_S}. The controls' easing tail, which
100
+ * creeps for hundreds of milliseconds after the plan looks still, therefore does not hold the pass.
101
+ * @param targets - The registry.
102
+ * @param state - The debounce state, updated here.
103
+ * @param context - The pass's context.
104
+ * @param now - The clock, in milliseconds.
105
+ * @returns Whether the driver must ask for a frame: a pass is waiting out the debounce.
106
+ */
107
+ function runPlacement(targets, state, context, now) {
108
+ const { space, camera, width, height } = context;
109
+ const step = Math.min(now - state.frameAt, MAX_STEP_MS);
110
+ const motion = screenMotion(space, camera, state.seen, width, height);
111
+ const resized = state.size.x !== width || state.size.y !== height;
112
+ if (resized || motion > (SETTLE_SPEED_PX_PER_S * step) / 1000)
113
+ state.changedAt = now;
114
+ state.seen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
115
+ state.size.set(width, height);
116
+ state.frameAt = now;
117
+ const pending = state.changedAt > state.placedAt;
118
+ const owed = targets.needsPlacement || targets.placementDrifted;
119
+ if (!owed && !(pending && now - state.changedAt >= PLACEMENT_DEBOUNCE_MS))
120
+ return pending;
121
+ state.placedAt = now;
122
+ targets.applyPlacement(context);
123
+ return false;
124
+ }
125
+ /**
126
+ * How far the plan moved on screen between the last frame's camera and this one, in CSS pixels:
127
+ * the most that any of a grid of points across the canvas moved. Each point is cast onto the plan
128
+ * through this frame's camera and projected back through the last frame's, so a pan, a zoom, a
129
+ * turn and a tilt all count, by however far they move what is on screen. A point that misses the
130
+ * plan, above the horizon, is skipped, as is one the last frame had behind the camera.
131
+ * @param space - The scene's space.
132
+ * @param camera - This frame's camera, its matrices up to date.
133
+ * @param previous - `projection * viewInverse` as of the last frame.
134
+ * @param cssWidth - The canvas width in CSS pixels.
135
+ * @param cssHeight - The canvas height in CSS pixels.
136
+ * @returns The motion in CSS pixels: 0 for a camera that did not move, and `Infinity` when no
137
+ * point lands on the plan, so a camera that sees none of it settles only by standing still.
138
+ */
139
+ function screenMotion(space, camera, previous, cssWidth, cssHeight) {
140
+ _motionNow.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
141
+ if (_motionNow.equals(previous))
142
+ return 0;
143
+ let motion = -1;
144
+ for (const x of MOTION_SAMPLES) {
145
+ for (const y of MOTION_SAMPLES) {
146
+ const plan = space.ndcToPlan(_motionNdc.set(x, y), camera, _motionPlan);
147
+ if (!plan)
148
+ continue;
149
+ _motionClip.set(plan.x, plan.y, 0, 1).applyMatrix4(space.matrix).applyMatrix4(previous);
150
+ if (_motionClip.w <= 0)
151
+ continue;
152
+ const dx = (_motionClip.x / _motionClip.w - x) * (cssWidth / 2);
153
+ const dy = (_motionClip.y / _motionClip.w - y) * (cssHeight / 2);
154
+ motion = Math.max(motion, Math.hypot(dx, dy));
155
+ }
156
+ }
157
+ return motion < 0 ? Infinity : motion;
158
+ }
159
+ /**
160
+ * How much of a frame one drain may spend writing laid-out defs into their slots, in milliseconds —
161
+ * the same budget the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough
162
+ * to leave a 60fps frame its render; the backlog it leaves is carried into the next frame.
163
+ */
164
+ const LAYOUT_BUDGET_MS = 3;
165
+ /**
166
+ * How long the view must stay unchanged before the placement pass runs, in milliseconds. Long
167
+ * enough that a drag or a run of wheel events reports once, not on every event.
168
+ */
169
+ const PLACEMENT_DEBOUNCE_MS = 60;
170
+ /**
171
+ * How fast the plan may move on screen, in CSS pixels a second, and still count as still: one CSS
172
+ * pixel a frame at 60 Hz. Each frame is held against its own time step, so the rule is the same at
173
+ * any frame rate.
174
+ */
175
+ const SETTLE_SPEED_PX_PER_S = 60;
176
+ /**
177
+ * The longest time step a frame's motion is measured over, in milliseconds. The first frame after an
178
+ * idle loop would otherwise read any jump as slow.
179
+ */
180
+ const MAX_STEP_MS = 1000 / 30;
181
+ /**
182
+ * Where {@link screenMotion} samples the canvas, in normalized device coordinates along each axis:
183
+ * a 3 by 3 grid reaching nearly to the edges, where a zoom or a turn moves the plan most.
184
+ */
185
+ const MOTION_SAMPLES = [-0.96, 0, 0.96];
84
186
  // Scratch for the drain's frustum, filled and consumed within one frame callback.
85
187
  const _frustum = new Frustum();
188
+ // Scratch for screenMotion, filled and consumed within one call.
189
+ const _motionNow = new Matrix4();
190
+ const _motionNdc = new Vector2();
191
+ const _motionPlan = new Point();
192
+ const _motionClip = new Vector4();
@@ -1,5 +1,6 @@
1
1
  import { type Frustum } from 'three';
2
2
  import { type LayerDef, type RenderableDef } from '../types/defs';
3
+ import { type PlacementContext } from './placement';
3
4
  /**
4
5
  * The defs a renderer re-lays out when the camera moves: the pixel size and the roll are the two
5
6
  * layout axes.
@@ -30,14 +31,32 @@ export interface LayoutTargets {
30
31
  readonly roll: number;
31
32
  /** Whether writes are still queued — what makes a caller keep frames coming. */
32
33
  readonly hasWork: boolean;
34
+ /**
35
+ * Whether a pass is owed because a participant registered, unregistered or was passed to
36
+ * `update()` since the last one ran.
37
+ *
38
+ * The driver also runs a pass when the camera moves, which this does not report: a camera move is
39
+ * the driver's own observation, not the registry's.
40
+ */
41
+ readonly needsPlacement: boolean;
42
+ /**
43
+ * Whether a participant's box moved since the last pass, through `update()`.
44
+ *
45
+ * The driver runs the pass at once for this, as for {@link needsPlacement}: the verdicts the def
46
+ * earned were earned at its old position.
47
+ */
48
+ readonly placementDrifted: boolean;
33
49
  /**
34
50
  * Registers defs for re-layout, and lays out and writes the ones that are not current — so a def
35
51
  * is current from the moment it is registered, and a membership change re-lays out only what it
36
52
  * added.
37
- * @param defs - The defs to lay out; every one of them declares an `onScale` or an `onRoll`.
53
+ * @param layer - The layer registering them, which is what places its participants in a collision
54
+ * scope: its own, or the one its {@link LayerDef.collisionGroup} names.
55
+ * @param defs - The defs to lay out; every one of them declares an `onScale`, an `onRoll` or a
56
+ * `placement`.
38
57
  * @returns An unregister function; it also drops whatever of these defs a sweep still queued.
39
58
  */
40
- register(defs: readonly RenderableDef[]): () => void;
59
+ register(layer: LayerDef, defs: readonly RenderableDef[]): () => void;
41
60
  /**
42
61
  * Records the new pixel size and runs every registered scale rule against it, queueing what they
43
62
  * report changed. Nothing is written here — that is {@link drain}'s job. Applying again before
@@ -52,6 +71,28 @@ export interface LayoutTargets {
52
71
  * @param roll - The new roll, in radians clockwise from north.
53
72
  */
54
73
  applyRoll(roll: number): void;
74
+ /**
75
+ * Runs the placement pass over every registered participant and queues what the reports change:
76
+ * what each `onPlace` returns, then what each clustering layer's `onCluster` returns, through the
77
+ * same queue — a layer among them is written at once, as from any rule.
78
+ *
79
+ * The third `apply*`, and the one that is not an axis: placement depends on the whole camera
80
+ * rather than on one quantity, so the driver decides when to call this rather than comparing a
81
+ * number. The pass itself is synchronous and complete; only the writes it produces are budgeted,
82
+ * through the same queue {@link drain} works off.
83
+ *
84
+ * A registered def that no longer participates, because its `placement` was removed or it stopped
85
+ * being screen-space, is forgotten by the pass: it has no handler left to tell, and forgetting
86
+ * means that participating again reports its verdict afresh. A caller that removes `placement`
87
+ * owns restoring the def's appearance: one left shrunk or faded by its last `onPlace(false)`
88
+ * stays that way, because the handler left with the options and nothing can report to it.
89
+ *
90
+ * A known gap follows from the same removal: the defs it was holding down stay stood down, until
91
+ * something else owes a pass, because removing an option is a plain mutation that notifies
92
+ * nobody. Their room is free, and nothing re-offers it.
93
+ * @param context - What the camera shows this pass.
94
+ */
95
+ applyPlacement(context: PlacementContext): void;
55
96
  /**
56
97
  * Writes queued defs within a frame's budget, and says whether any remain — the caller's cue to
57
98
  * ask for another frame.
@@ -79,7 +120,8 @@ export interface LayoutTargets {
79
120
  export declare const LayoutTargetsContext: import("react").Context<LayoutTargets | undefined>;
80
121
  /**
81
122
  * Registers a layer's re-layout targets for as long as the layer is mounted: the defs it holds that
82
- * declare an `onScale`, and the layer itself when it declares one.
123
+ * declare an `onScale`, an `onRoll` or a `placement`, and the layer itself when it declares a
124
+ * layout rule.
83
125
  *
84
126
  * A child *layer* is never registered here even when it declares a handler — it registers itself, and
85
127
  * it has to: a handler assigned to a layer after mount re-renders that layer alone, so a registration