@tangram.gl/tangram-layers 0.0.0-stage → 0.0.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.
Files changed (43) hide show
  1. package/README.md +36 -2
  2. package/dist/experimental/projected-basemaps.js +1310 -0
  3. package/dist/experimental/projected-basemaps.js.map +6 -0
  4. package/dist/experimental/terrain.js +379 -0
  5. package/dist/experimental/terrain.js.map +6 -0
  6. package/dist/experimental/webxr.js +1423 -0
  7. package/dist/experimental/webxr.js.map +6 -0
  8. package/dist/index.js +756 -0
  9. package/dist/index.js.map +6 -0
  10. package/dist/types/experimental/projected-basemaps.d.ts +120 -0
  11. package/dist/types/experimental/projected-detail.d.ts +33 -0
  12. package/dist/types/experimental/projected-navigation.d.ts +117 -0
  13. package/dist/types/experimental/terrain-display.d.ts +124 -0
  14. package/dist/types/experimental/terrain.d.ts +131 -0
  15. package/dist/types/first_person_view_adapter.d.ts +29 -0
  16. package/dist/types/globe_view_adapter.d.ts +33 -0
  17. package/dist/types/index.d.ts +84 -0
  18. package/dist/types/tangram-layer-lifecycle.d.ts +132 -0
  19. package/dist/types/tangram-layer.d.ts +157 -0
  20. package/dist/types/view_adapter_types.d.ts +42 -0
  21. package/dist/types/web_mercator_view_adapter.d.ts +77 -0
  22. package/package.json +80 -3
  23. package/src/experimental/projected-basemaps.ts +301 -0
  24. package/src/experimental/projected-detail.ts +116 -0
  25. package/src/experimental/projected-navigation.ts +323 -0
  26. package/src/experimental/terrain-display.ts +243 -0
  27. package/src/experimental/terrain.ts +240 -0
  28. package/src/experimental/webxr/grabbing.ts +166 -0
  29. package/src/experimental/webxr/index.d.ts +77 -0
  30. package/src/experimental/webxr/index.ts +72 -0
  31. package/src/experimental/webxr/interaction.ts +221 -0
  32. package/src/experimental/webxr/picking.ts +119 -0
  33. package/src/experimental/webxr/presentation.ts +457 -0
  34. package/src/experimental/webxr/projection.ts +371 -0
  35. package/src/experimental/webxr/types.ts +221 -0
  36. package/src/experimental/webxr/views.ts +190 -0
  37. package/src/first_person_view_adapter.ts +107 -0
  38. package/src/globe_view_adapter.ts +103 -0
  39. package/src/index.ts +64 -0
  40. package/src/tangram-layer-lifecycle.ts +130 -0
  41. package/src/tangram-layer.ts +679 -0
  42. package/src/view_adapter_types.ts +40 -0
  43. package/src/web_mercator_view_adapter.ts +185 -0
@@ -0,0 +1,457 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {Matrix4} from '@math.gl/core';
6
+ import {DoubleClickDrag, EventManager, Pan, Pinch, Tap} from 'mjolnir.js';
7
+ import {createXRPlacementMatrix, getXRGlobeVisibleBounds} from './projection.js';
8
+ import type {MapController, InteractionState, ViewStateChangeParameters} from '@deck.gl/core';
9
+ import type {HostFrameOptions} from '@tangram.gl/tangram-renderer/core';
10
+ import type {XRDeckController, XRDeckView, XRFrameState, XRInteractionIntent, XRPlacement,
11
+ XRHostFrameFields, XRPresentationFrame, XRPresentationMode, XRPresentationRenderView, XRViewState,
12
+ XRViewportSize, XRVector3} from './types.js';
13
+
14
+ /** Initialization of the shared navigation state and independent room placement. */
15
+ export type WebXRPresentationOptions = {
16
+ view: XRDeckView;
17
+ viewState: XRViewState;
18
+ placement?: XRPlacement;
19
+ mode?: XRPresentationMode;
20
+ };
21
+
22
+ /** Host input and callbacks passed to deck.gl's controller. */
23
+ export type WebXRControllerOptions = {
24
+ element: HTMLElement;
25
+ timeline: ConstructorParameters<typeof MapController>[0]['timeline'];
26
+ onViewStateChange?: (parameters: ViewStateChangeParameters<XRViewState>) => void;
27
+ onStateChange?: (state: InteractionState) => void;
28
+ };
29
+
30
+ /** Input needed to prepare the next logical, stereo, or immersive frame. */
31
+ export type WebXRFrameOptions = XRViewportSize & {
32
+ frameState?: XRFrameState;
33
+ mode?: XRPresentationMode;
34
+ interpupillaryDistance?: number;
35
+ /** Opt-in shared worker/cache budgets; never multiplied by the number of eyes. */
36
+ tileResources?: HostFrameOptions['tileResources'];
37
+ };
38
+
39
+ /** Default human interpupillary distance used by desktop stereo preview, in meters. */
40
+ export const DEFAULT_INTERPUPILLARY_DISTANCE = 0.064;
41
+
42
+ /**
43
+ * Expands one logical deck.gl view into mono, stereo-preview, or immersive render views.
44
+ *
45
+ * Placement is independent from the logical geographic camera. Input from either
46
+ * eye is routed through a controller for that screen region and updates one shared view state.
47
+ */
48
+ export class WebXRPresentation {
49
+ /** Logical view shared by every eye. */
50
+ declare readonly view: XRDeckView;
51
+ /** Shared geographic state, updated by either controller. */
52
+ declare viewState: XRViewState;
53
+ /** Content pose in the independent room reference space. */
54
+ declare placement: XRPlacement;
55
+ /** Requested presentation mode. */
56
+ declare mode: XRPresentationMode;
57
+ /** Resolved mode used to size controller hit regions. */
58
+ declare controllerMode: XRPresentationMode;
59
+ /** Mono or left-eye controller. */
60
+ declare controller: XRDeckController | null;
61
+ /** Right-eye controller, present only during stereo preview. */
62
+ declare rightController: XRDeckController | null;
63
+ /** Shared desktop input manager. */
64
+ declare eventManager: EventManager | null;
65
+ /** Last rendered canvas dimensions. */
66
+ declare controllerSize: XRViewportSize;
67
+ /** Callbacks installed before either controller is created. */
68
+ declare controllerCallbacks: Required<Omit<WebXRControllerOptions, 'element'>>;
69
+
70
+ /** Initialize one logical view without allocating controllers or GPU resources. */
71
+ constructor({view, viewState, placement, mode = 'auto'}: WebXRPresentationOptions) {
72
+ this.view = view;
73
+ this.viewState = {...viewState};
74
+ this.placement = placement || createDefaultPlacement(view, viewState);
75
+ this.mode = mode;
76
+ this.controllerMode = mode;
77
+ this.controller = null;
78
+ this.rightController = null;
79
+ this.eventManager = null;
80
+ this.controllerSize = {width: 1, height: 1};
81
+ }
82
+
83
+ /** Read the shared state used by mono and both stereo eyes. */
84
+ getViewState() {
85
+ return this.viewState;
86
+ }
87
+
88
+ /** Apply a logical navigation patch and synchronize desktop controllers. */
89
+ setViewState(update: Partial<XRViewState> | ((state: XRViewState) => Partial<XRViewState>)) {
90
+ const patch = typeof update === 'function' ? update(this.viewState) : update;
91
+ this.viewState = {...this.viewState, ...patch};
92
+ this.updateController(this.controllerSize);
93
+ return this.viewState;
94
+ }
95
+
96
+ /** Replace the room placement without changing geographic navigation. */
97
+ setPlacement(placement: XRPlacement) {
98
+ this.placement = placement;
99
+ }
100
+
101
+ /** Change the requested presentation mode and desktop hit regions. */
102
+ setMode(mode: XRPresentationMode) {
103
+ this.mode = mode;
104
+ this.updateController(this.controllerSize);
105
+ }
106
+
107
+ /** Install deck.gl gestures backed by the shared logical navigation state. */
108
+ attachController({element, timeline, onViewStateChange = () => {}, onStateChange = () => {}}: WebXRControllerOptions) {
109
+ const controllerOptions = this.view.controller;
110
+ if (!controllerOptions) return null;
111
+ // Match deck.gl's gesture setup: mjolnir does not register recognizers by default.
112
+ // Order matters because gesture dependencies refer to previously registered names.
113
+ this.eventManager = new EventManager(element, {
114
+ touchAction: 'none',
115
+ recognizers: [
116
+ {recognizer: new Pan({event: 'multipan', threshold: 10, pointers: 2, trackpad: true})},
117
+ {recognizer: new Pinch({event: 'pinch', trackpad: true}), requireFailure: ['multipan']},
118
+ {recognizer: new Pan({event: 'pan', threshold: 1}),
119
+ recognizeWith: ['pinch'], requireFailure: ['multipan']},
120
+ {recognizer: new Tap({event: 'dblclick', taps: 2, enable: false})},
121
+ {recognizer: new DoubleClickDrag({event: 'dblclickdrag', enable: false}),
122
+ recognizeWith: ['dblclick']},
123
+ {recognizer: new Tap({event: 'click'}), recognizeWith: ['dblclickdrag'],
124
+ requireFailure: ['dblclick', 'dblclickdrag']}
125
+ ]
126
+ });
127
+ this.controllerCallbacks = {timeline, onViewStateChange, onStateChange};
128
+ this.controller = this.createController();
129
+ this.updateController(this.controllerSize);
130
+ return this.controller;
131
+ }
132
+
133
+ /** Create a controller for one screen region backed by the shared view state. */
134
+ createController() {
135
+ const controllerOptions = this.view.controller;
136
+ if (!controllerOptions || !this.eventManager) {
137
+ throw new Error('Attach a controller before creating an eye controller');
138
+ }
139
+ const {timeline, onViewStateChange, onStateChange} = this.controllerCallbacks;
140
+ const Controller = controllerOptions.type;
141
+ return new Controller({
142
+ timeline,
143
+ eventManager: this.eventManager,
144
+ makeViewport: (viewState: XRViewState) => requireViewport(this.view.makeViewport({
145
+ ...this.controllerSize,
146
+ width: this.controllerMode === 'stereo-preview'
147
+ ? this.controllerSize.width / 2 : this.controllerSize.width,
148
+ viewState
149
+ })),
150
+ onViewStateChange: (parameters: ViewStateChangeParameters<XRViewState>) => {
151
+ this.viewState = {...parameters.viewState};
152
+ this.updateController(this.controllerSize);
153
+ onViewStateChange({...parameters, viewState: this.viewState});
154
+ },
155
+ onStateChange
156
+ });
157
+ }
158
+
159
+ /** Match controller rectangles to the current mono or stereo dimensions. */
160
+ updateController({width, height}: XRViewportSize, mode = this.mode) {
161
+ this.controllerSize = {width, height};
162
+ this.controllerMode = mode;
163
+ if (!this.controller) return;
164
+ if (mode === 'stereo-preview') {
165
+ this.rightController ||= this.createController();
166
+ width /= 2;
167
+ } else if (this.rightController) {
168
+ this.rightController.finalize();
169
+ this.rightController = null;
170
+ }
171
+ const viewport = this.view.makeViewport({width, height, viewState: this.viewState});
172
+ const controllerOptions = this.view.controller;
173
+ if (!viewport || !controllerOptions) return;
174
+ this.controller.setProps({
175
+ ...this.viewState,
176
+ ...controllerOptions,
177
+ id: this.view.id,
178
+ x: viewport.x,
179
+ y: viewport.y,
180
+ width: viewport.width,
181
+ height: viewport.height
182
+ });
183
+ this.rightController?.setProps({
184
+ ...this.viewState,
185
+ ...controllerOptions,
186
+ // Keyboard input is global to the shared EventManager. Only the left
187
+ // controller handles it, otherwise FirstPersonView moves twice per key.
188
+ keyboard: false,
189
+ id: `${this.view.id}-right`,
190
+ x: width,
191
+ y: viewport.y,
192
+ width: viewport.width,
193
+ height: viewport.height
194
+ });
195
+ }
196
+
197
+ /** Advance desktop zoom and inertia transitions once per animation frame. */
198
+ updateTransitions() {
199
+ this.controller?.updateTransition();
200
+ this.rightController?.updateTransition();
201
+ }
202
+
203
+ /** Release desktop input resources without taking ownership of the host view. */
204
+ finalize() {
205
+ this.controller?.finalize();
206
+ this.rightController?.finalize();
207
+ this.eventManager?.destroy();
208
+ this.controller = null;
209
+ this.rightController = null;
210
+ this.eventManager = null;
211
+ }
212
+
213
+ /** Derive one eye viewport and its matching Tangram camera contract. */
214
+ makeRenderView({id, width, height, eyeOffset = 0, viewportX = 0}: XRViewportSize & {
215
+ id: string; eyeOffset?: number; viewportX?: number;
216
+ }): XRPresentationRenderView & {hostFrame: XRHostFrameFields} {
217
+ const deckViewport = this.view.makeEyeViewport({
218
+ width,
219
+ height,
220
+ viewState: this.viewState,
221
+ eyeOffset
222
+ });
223
+ if (!deckViewport) {
224
+ throw new Error(`${this.view.constructor.displayName} produced an empty viewport`);
225
+ }
226
+ const hostFrame = this.view.getHostFrame(deckViewport);
227
+ return {
228
+ id,
229
+ viewport: {x: viewportX, y: 0, width, height},
230
+ camera: hostFrame.camera,
231
+ hostFrame,
232
+ deckViewport
233
+ };
234
+ }
235
+
236
+ /** Derive parallel off-axis desktop eyes with one shared geographic anchor. */
237
+ makeStereoRenderViews({width, height, interpupillaryDistance = DEFAULT_INTERPUPILLARY_DISTANCE}: XRViewportSize & {
238
+ interpupillaryDistance?: number;
239
+ }): XRPresentationRenderView[] {
240
+ // Translate along camera-right, not geographic east or globe longitude.
241
+ // The off-axis projections keep the geographic anchor on the same screen
242
+ // position in both eyes, while nearer/farther geometry gains stereo depth.
243
+ return ['left-eye', 'right-eye'].map((id, index) => {
244
+ const renderView = this.makeRenderView({id, width, height});
245
+ const viewport = renderView.deckViewport;
246
+ const viewScale = Math.hypot(
247
+ viewport.viewMatrix[0], viewport.viewMatrix[4], viewport.viewMatrix[8]
248
+ );
249
+ const unitsPerXR = this.placement.type === 'globe'
250
+ ? viewScale * 256 / this.placement.radius
251
+ : viewScale * viewport.distanceScales.unitsPerMeter[2] *
252
+ (this.placement.type === 'map' ? this.placement.metersPerXRUnit : 1);
253
+ const eyeOffset = (index === 0 ? -0.5 : 0.5) * interpupillaryDistance * unitsPerXR;
254
+ const anchor = viewport.projectPosition([
255
+ this.viewState.longitude, this.viewState.latitude, 0
256
+ ]);
257
+ const anchorInView = new Matrix4().copy(viewport.viewMatrix).transformAsPoint(anchor);
258
+ // At/above the first-person horizon the ground anchor is not in front of
259
+ // the eye. Keep the convergence plane 100 physical meters forward instead
260
+ // of collapsing it to 1 cm and shearing both frusta away from ground.
261
+ const anchorDistance = -anchorInView[2];
262
+ const convergence = anchorDistance > 0.01 ? anchorDistance
263
+ : this.placement.type === 'first-person' ? 100 * unitsPerXR : 0.01;
264
+ const viewMatrix = new Matrix4().translate([-eyeOffset, 0, 0])
265
+ .multiplyRight(renderView.camera.view);
266
+ const projectionMatrix = new Matrix4().copy(viewport.projectionMatrix);
267
+ projectionMatrix[8] -= projectionMatrix[0] * eyeOffset / convergence;
268
+ renderView.camera = {
269
+ ...renderView.camera,
270
+ view: viewMatrix,
271
+ projection: this.view.getXRProjectionMatrix({projectionMatrix, viewMatrix}),
272
+ position: this.placement.type === 'globe'
273
+ ? getCameraPosition(viewMatrix)
274
+ : renderView.camera.position
275
+ };
276
+ renderView.hostFrame = {...renderView.hostFrame, camera: renderView.camera};
277
+ if (typeof this.view.getHostFrameForCamera === 'function') {
278
+ renderView.hostFrame = this.view.getHostFrameForCamera(viewport, renderView.camera);
279
+ }
280
+ return renderView;
281
+ });
282
+ }
283
+
284
+ /** Compose native XR eye matrices with content placement. */
285
+ makeXRRenderViews({frameState, placementMatrix}: {frameState: XRFrameState; placementMatrix: Matrix4}): XRPresentationRenderView[] {
286
+ const logicalViewport = requireViewport(this.view.makeViewport({
287
+ width: Math.max(...frameState.views.map((view) => view.viewport[0] + view.viewport[2])),
288
+ height: Math.max(...frameState.views.map((view) => view.viewport[1] + view.viewport[3])),
289
+ viewState: this.viewState
290
+ }));
291
+ return frameState.views.map((xrView) => {
292
+ const [x, y, width, height] = xrView.viewport;
293
+ const viewMatrix = new Matrix4().copy(xrView.viewMatrix).multiplyRight(placementMatrix);
294
+ const projectionMatrix = new Matrix4().copy(xrView.projectionMatrix);
295
+ const renderView: XRPresentationRenderView = {
296
+ id: xrView.eye || `eye-${xrView.index}`,
297
+ viewport: {x, y, width, height},
298
+ camera: {
299
+ view: viewMatrix,
300
+ projection: this.view.getXRProjectionMatrix({projectionMatrix, viewMatrix}),
301
+ position: this.placement.type === 'globe'
302
+ ? getCameraPosition(viewMatrix)
303
+ : [0, 0, 0]
304
+ },
305
+ deckViewport: logicalViewport,
306
+ xrView,
307
+ view: this.view,
308
+ viewState: this.viewState
309
+ };
310
+ if (typeof this.view.getHostFrameForCamera === 'function') {
311
+ renderView.hostFrame = this.view.getHostFrameForCamera(logicalViewport, renderView.camera, {width, height});
312
+ }
313
+ return renderView;
314
+ });
315
+ }
316
+
317
+ /** Prepare a presentation snapshot and renderer-independent host frame. */
318
+ createFrame({width, height, frameState, mode = this.mode, interpupillaryDistance, tileResources}: WebXRFrameOptions): XRPresentationFrame {
319
+ const resolvedMode = resolveMode(mode, frameState);
320
+ // A caller may select a presentation mode per frame without calling setMode.
321
+ // Keep controller hit regions and gesture viewport dimensions in lockstep.
322
+ this.updateController({width, height}, resolvedMode);
323
+ const logicalViewport = this.view.makeViewport({width, height, viewState: this.viewState});
324
+ if (!logicalViewport) throw new Error('The logical deck.gl view produced an empty viewport');
325
+ let renderViews;
326
+ if (resolvedMode === 'immersive-vr' && frameState?.views?.length) {
327
+ const placementMatrix = createXRPlacementMatrix(this.placement, this.viewState);
328
+ renderViews = this.makeXRRenderViews({frameState, placementMatrix});
329
+ } else if (resolvedMode === 'stereo-preview') {
330
+ const eyeWidth = Math.floor(width / 2);
331
+ renderViews = this.makeStereoRenderViews({
332
+ width: eyeWidth,
333
+ height,
334
+ interpupillaryDistance
335
+ });
336
+ renderViews[1].viewport.x = eyeWidth;
337
+ } else {
338
+ renderViews = [this.makeRenderView({id: 'mono', width, height})];
339
+ }
340
+ return {
341
+ mode: resolvedMode,
342
+ logicalViewport,
343
+ renderViews,
344
+ hostFrame: {...this.createHostFrame({width, height, renderViews, frameState}), tileResources}
345
+ };
346
+ }
347
+
348
+ /** Union per-eye visibility while preserving camera-independent host metadata. */
349
+ createHostFrame({width, height, renderViews, frameState}: XRViewportSize & {
350
+ renderViews: XRPresentationRenderView[]; frameState?: XRFrameState;
351
+ }): HostFrameOptions {
352
+ const fields = renderViews.find((renderView) => renderView.hostFrame)?.hostFrame;
353
+ const fallback = this.view.getHostFrame(
354
+ requireViewport(this.view.makeViewport({width, height, viewState: this.viewState}))
355
+ );
356
+ const frameFields = fields || fallback;
357
+ let projection = frameFields.projection;
358
+ if (this.placement.type === 'globe' && frameState?.views?.length) {
359
+ projection = {
360
+ ...frameFields.projection,
361
+ type: 'globe',
362
+ visibleBounds: getXRGlobeVisibleBounds({
363
+ views: frameState.views,
364
+ placement: this.placement,
365
+ viewState: this.viewState
366
+ })
367
+ };
368
+ }
369
+ return {
370
+ viewport: {x: 0, y: 0, width, height},
371
+ geographicAnchor: frameFields.view,
372
+ projection,
373
+ renderViews: renderViews.map(renderView => ({
374
+ ...renderView,
375
+ // Keep native render-pass rectangles untouched, but HostFrame routes
376
+ // top-origin canvas pixels (desktop CSS, immersive framebuffer units).
377
+ viewport: renderView.xrView ? {...renderView.viewport,
378
+ y: height - (renderView.viewport.y ?? 0) - renderView.viewport.height} : renderView.viewport,
379
+ geographicAnchor: renderView.hostFrame?.view,
380
+ // Immersive globe bounds already describe the union in room space.
381
+ projection: frameState?.views?.length && this.placement.type === 'globe'
382
+ ? undefined : renderView.hostFrame?.projection
383
+ })),
384
+ activeRenderViewId: renderViews[0].id,
385
+ tileBuffer: frameFields.tileBuffer,
386
+ globePreloadZoom: frameFields.globePreloadZoom
387
+ };
388
+ }
389
+
390
+ /** Apply navigation intents; non-navigation signals remain owned by the caller. */
391
+ dispatchInteractionIntent(intent: XRInteractionIntent) {
392
+ if (intent.type !== 'navigate') return this.viewState;
393
+ const [horizontal = 0, vertical = 0] = intent.delta;
394
+ if (intent.action === 'turn' || intent.action === 'rotate') {
395
+ return this.setViewState({bearing: (this.viewState.bearing || 0) + horizontal});
396
+ }
397
+ if (intent.action === 'pitch') {
398
+ return this.setViewState({pitch: (this.viewState.pitch || 0) + vertical});
399
+ }
400
+ if (intent.action === 'zoom') {
401
+ return this.setViewState({zoom: (this.viewState.zoom || 0) + vertical});
402
+ }
403
+ if (this.placement.type === 'first-person') {
404
+ const position = this.placement.position || [0, 0, 0];
405
+ this.placement = {
406
+ ...this.placement,
407
+ position: [position[0] + horizontal, position[1] + vertical, position[2]]
408
+ };
409
+ return this.viewState;
410
+ }
411
+ return this.setViewState({
412
+ longitude: (this.viewState.longitude || 0) + horizontal,
413
+ latitude: (this.viewState.latitude || 0) + vertical
414
+ });
415
+ }
416
+ }
417
+
418
+ /** Backward-compatible name used by the first WebXR example iteration. */
419
+ export class WebXRViewManager extends WebXRPresentation {}
420
+
421
+ /** Resolve unavailable immersive modes to explicit stereo preview. */
422
+ function resolveMode(mode: XRPresentationMode, frameState?: XRFrameState): Exclude<XRPresentationMode, 'auto'> {
423
+ if (mode === 'auto') return frameState?.views?.length ? 'immersive-vr' : 'mono';
424
+ if (mode === 'immersive-vr' && !frameState?.views?.length) return 'stereo-preview';
425
+ return mode;
426
+ }
427
+
428
+ /** Choose the existing room presentation defaults for each geographic view. */
429
+ function createDefaultPlacement(view: XRDeckView, viewState: XRViewState): XRPlacement {
430
+ const anchor: XRVector3 = [viewState.longitude || 0, viewState.latitude || 0, 0];
431
+ if (view.constructor.displayName === 'WebXRGlobeView') {
432
+ return {type: 'globe', anchor, pose: {position: [0, 1.35, -2.35]}, radius: 0.72};
433
+ }
434
+ if (view.constructor.displayName === 'WebXRFirstPersonView') {
435
+ return {type: 'first-person', origin: anchor, position: [0, 0, 0], bearing: 0};
436
+ }
437
+ return {
438
+ type: 'map',
439
+ anchor,
440
+ pose: {position: [0, 0.72, -1.8]},
441
+ metersPerXRUnit: 2500,
442
+ surface: {type: 'unbounded'}
443
+ };
444
+ }
445
+
446
+ /** Preserve the fixed-size camera position required by the renderer contract. */
447
+ function getCameraPosition(matrix: Matrix4): [number, number, number] {
448
+ const position: [number, number, number] = [0, 0, 0];
449
+ new Matrix4(matrix).invert().transformAsPoint([0, 0, 0], position);
450
+ return position;
451
+ }
452
+
453
+ /** Reject a hidden logical view before using its camera or controller dimensions. */
454
+ function requireViewport(viewport: import('./types.js').XRDeckViewport | null) {
455
+ if (!viewport) throw new Error('The logical deck.gl view produced an empty viewport');
456
+ return viewport;
457
+ }