@tangram.gl/tangram-layers 0.0.0-stage → 0.0.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/README.md +35 -2
- package/dist/experimental/projected-basemaps.js +1310 -0
- package/dist/experimental/projected-basemaps.js.map +6 -0
- package/dist/experimental/terrain.js +379 -0
- package/dist/experimental/terrain.js.map +6 -0
- package/dist/experimental/webxr.js +1423 -0
- package/dist/experimental/webxr.js.map +6 -0
- package/dist/index.js +756 -0
- package/dist/index.js.map +6 -0
- package/dist/types/experimental/projected-basemaps.d.ts +120 -0
- package/dist/types/experimental/projected-detail.d.ts +33 -0
- package/dist/types/experimental/projected-navigation.d.ts +117 -0
- package/dist/types/experimental/terrain-display.d.ts +124 -0
- package/dist/types/experimental/terrain.d.ts +131 -0
- package/dist/types/first_person_view_adapter.d.ts +29 -0
- package/dist/types/globe_view_adapter.d.ts +33 -0
- package/dist/types/index.d.ts +84 -0
- package/dist/types/tangram-layer-lifecycle.d.ts +132 -0
- package/dist/types/tangram-layer.d.ts +157 -0
- package/dist/types/view_adapter_types.d.ts +42 -0
- package/dist/types/web_mercator_view_adapter.d.ts +77 -0
- package/package.json +80 -3
- package/src/experimental/projected-basemaps.ts +301 -0
- package/src/experimental/projected-detail.ts +116 -0
- package/src/experimental/projected-navigation.ts +323 -0
- package/src/experimental/terrain-display.ts +243 -0
- package/src/experimental/terrain.ts +240 -0
- package/src/experimental/webxr/grabbing.ts +166 -0
- package/src/experimental/webxr/index.d.ts +77 -0
- package/src/experimental/webxr/index.ts +72 -0
- package/src/experimental/webxr/interaction.ts +221 -0
- package/src/experimental/webxr/picking.ts +119 -0
- package/src/experimental/webxr/presentation.ts +457 -0
- package/src/experimental/webxr/projection.ts +371 -0
- package/src/experimental/webxr/types.ts +221 -0
- package/src/experimental/webxr/views.ts +190 -0
- package/src/first_person_view_adapter.ts +107 -0
- package/src/globe_view_adapter.ts +103 -0
- package/src/index.ts +64 -0
- package/src/tangram-layer-lifecycle.ts +130 -0
- package/src/tangram-layer.ts +679 -0
- package/src/view_adapter_types.ts +40 -0
- 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
|
+
}
|