@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,221 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+ // Adapted from visgl/luma.gl WebXR RFC commit 3b6f68a64295bf9cf82930149f5e3491b7c3d969.
5
+
6
+ import {getWebXRInputRay} from '@luma.gl/experimental';
7
+ import type {XRInteractionIntent, XRSpatialRay} from './types.ts';
8
+
9
+ /** Minimal luma.gl input snapshot consumed by {@link WebXRInputAdapter}. */
10
+ export type WebXRInputSnapshot = {
11
+ index: number;
12
+ handedness?: string;
13
+ targetRayMatrix?: readonly number[] | null;
14
+ selectActive?: boolean;
15
+ squeezeActive?: boolean;
16
+ gamepad?: {axes: readonly number[]; buttons?: readonly {pressed?: boolean; value?: number}[]} | null;
17
+ };
18
+
19
+ /** Structural WebXR session contract needed for reference-space negotiation. */
20
+ export type WebXRSession = object;
21
+
22
+ /** WebXR reference-space names supported by the fallback helper. */
23
+ export type WebXRReferenceSpaceType =
24
+ | 'viewer'
25
+ | 'local'
26
+ | 'local-floor'
27
+ | 'bounded-floor'
28
+ | 'unbounded';
29
+
30
+ /** Options for translating controller axes into navigation intents. */
31
+ export type WebXRInputAdapterOptions = {
32
+ moveDeadzone?: number;
33
+ turnDeadzone?: number;
34
+ /** Movement units emitted per second at full stick deflection. */
35
+ moveSpeed?: number;
36
+ /** Degrees emitted per second while the turn stick is held. */
37
+ turnSpeed?: number;
38
+ };
39
+
40
+ /**
41
+ * Converts luma.gl WebXR input snapshots into renderer-independent intents.
42
+ *
43
+ * This deliberately keeps controller policy outside the renderer and deck view.
44
+ */
45
+ export class WebXRInputAdapter {
46
+ readonly options: Required<WebXRInputAdapterOptions>;
47
+ private readonly previousSelect = new Map<string, boolean>();
48
+ private readonly previousSqueeze = new Map<string, boolean>();
49
+ private readonly previousPointers = new Map<string, XRSpatialRay>();
50
+
51
+ constructor(options: WebXRInputAdapterOptions = {}) {
52
+ this.options = {
53
+ moveDeadzone: options.moveDeadzone ?? 0.15,
54
+ turnDeadzone: options.turnDeadzone ?? 0.55,
55
+ moveSpeed: options.moveSpeed ?? 1,
56
+ turnSpeed: options.turnSpeed ?? 30
57
+ };
58
+ }
59
+
60
+ /** Translate one XR input frame into time-scaled navigation, pointing, and signal intents. */
61
+ update(
62
+ inputStates: readonly WebXRInputSnapshot[],
63
+ elapsedSeconds: number = 1 / 60
64
+ ): XRInteractionIntent[] {
65
+ const intents: XRInteractionIntent[] = [];
66
+ const activeKeys = new Set<string>();
67
+ for (const inputState of inputStates) {
68
+ const key = `${inputState.handedness || 'none'}:${inputState.index}`;
69
+ activeKeys.add(key);
70
+ const ray = getWebXRInputRay(
71
+ inputState as unknown as Parameters<typeof getWebXRInputRay>[0]
72
+ );
73
+ if (ray && ray.origin.every(Number.isFinite) && ray.direction.every(Number.isFinite) &&
74
+ Math.hypot(...ray.direction) > 0) {
75
+ const pointer: XRSpatialRay = {
76
+ origin: [ray.origin[0], ray.origin[1], ray.origin[2]],
77
+ direction: [ray.direction[0], ray.direction[1], ray.direction[2]],
78
+ handedness: inputState.handedness
79
+ };
80
+ this.previousPointers.set(key, pointer);
81
+ intents.push({type: 'point', pointer, action: 'hover', inputId: key});
82
+ appendActivationIntent({
83
+ intents,
84
+ active: Boolean(inputState.selectActive),
85
+ previous: this.previousSelect.get(key) || false,
86
+ pointer,
87
+ pressedAction: 'select',
88
+ releasedAction: 'release',
89
+ inputId: key, button: 'select'
90
+ });
91
+ appendActivationIntent({
92
+ intents,
93
+ active: Boolean(inputState.squeezeActive),
94
+ previous: this.previousSqueeze.get(key) || false,
95
+ pointer,
96
+ pressedAction: 'grab',
97
+ releasedAction: 'release',
98
+ inputId: key, button: 'squeeze'
99
+ });
100
+ } else {
101
+ const pointer = this.previousPointers.get(key);
102
+ if (pointer && (this.previousSelect.get(key) || this.previousSqueeze.get(key))) {
103
+ intents.push({type: 'point', pointer, action: 'cancel', inputId: key});
104
+ }
105
+ this.previousPointers.delete(key);
106
+ }
107
+ this.previousSelect.set(key, Boolean(inputState.selectActive));
108
+ this.previousSqueeze.set(key, Boolean(inputState.squeezeActive));
109
+ this.appendGamepadIntents(intents, inputState, Math.max(0, elapsedSeconds));
110
+ }
111
+ for (const key of this.previousSelect.keys()) {
112
+ if (!activeKeys.has(key)) {
113
+ const pointer = this.previousPointers.get(key);
114
+ if (pointer && (this.previousSelect.get(key) || this.previousSqueeze.get(key))) {
115
+ intents.push({type: 'point', pointer, action: 'cancel', inputId: key});
116
+ }
117
+ this.previousSelect.delete(key);
118
+ this.previousSqueeze.delete(key);
119
+ this.previousPointers.delete(key);
120
+ }
121
+ }
122
+ return intents;
123
+ }
124
+
125
+ /** Forget action transitions, for example when an XR session ends. */
126
+ reset(): void {
127
+ this.previousSelect.clear();
128
+ this.previousSqueeze.clear();
129
+ this.previousPointers.clear();
130
+ }
131
+
132
+ private appendGamepadIntents(
133
+ intents: XRInteractionIntent[],
134
+ inputState: WebXRInputSnapshot,
135
+ elapsedSeconds: number
136
+ ): void {
137
+ const axes = inputState.gamepad?.axes;
138
+ if (!axes || axes.length < 2) {
139
+ return;
140
+ }
141
+ const horizontal = applyDeadzone(axes[axes.length - 2] || 0, this.options.moveDeadzone);
142
+ const vertical = applyDeadzone(axes[axes.length - 1] || 0, this.options.moveDeadzone);
143
+ if (inputState.handedness === 'right') {
144
+ if (Math.abs(horizontal) >= this.options.turnDeadzone) {
145
+ intents.push({
146
+ type: 'navigate',
147
+ action: 'turn',
148
+ delta: [Math.sign(horizontal) * this.options.turnSpeed * elapsedSeconds],
149
+ handedness: inputState.handedness
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ if (horizontal || vertical) {
155
+ intents.push({
156
+ type: 'navigate',
157
+ action: 'move',
158
+ delta: [
159
+ horizontal * this.options.moveSpeed * elapsedSeconds,
160
+ -vertical * this.options.moveSpeed * elapsedSeconds
161
+ ],
162
+ handedness: inputState.handedness
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ /** Configure luma.gl WebXR with ordered reference-space fallbacks. */
169
+ export async function setWebXRSessionWithFallback(
170
+ manager: {
171
+ setSession(
172
+ session: WebXRSession,
173
+ options: {referenceSpaceType: WebXRReferenceSpaceType}
174
+ ): Promise<unknown>;
175
+ },
176
+ session: WebXRSession,
177
+ referenceSpaceTypes: readonly WebXRReferenceSpaceType[] = ['local-floor', 'local']
178
+ ): Promise<WebXRReferenceSpaceType> {
179
+ let lastError: unknown;
180
+ for (const referenceSpaceType of referenceSpaceTypes) {
181
+ try {
182
+ await manager.setSession(session, {referenceSpaceType});
183
+ return referenceSpaceType;
184
+ } catch (error) {
185
+ lastError = error;
186
+ if (!(error instanceof DOMException) || error.name !== 'NotSupportedError') {
187
+ throw error;
188
+ }
189
+ }
190
+ }
191
+ throw lastError || new Error('No requested WebXR reference space is available');
192
+ }
193
+
194
+ function appendActivationIntent({
195
+ intents,
196
+ active,
197
+ previous,
198
+ pointer,
199
+ pressedAction,
200
+ releasedAction,
201
+ inputId, button
202
+ }: {
203
+ intents: XRInteractionIntent[];
204
+ active: boolean;
205
+ previous: boolean;
206
+ pointer: XRSpatialRay;
207
+ pressedAction: 'select' | 'grab';
208
+ releasedAction: 'release';
209
+ inputId: string;
210
+ button: 'select' | 'squeeze';
211
+ }): void {
212
+ if (active && !previous) {
213
+ intents.push({type: 'point', pointer, action: pressedAction, inputId, button});
214
+ } else if (!active && previous) {
215
+ intents.push({type: 'point', pointer, action: releasedAction, inputId, button});
216
+ }
217
+ }
218
+
219
+ function applyDeadzone(value: number, deadzone: number): number {
220
+ return Math.abs(value) < deadzone ? 0 : value;
221
+ }
@@ -0,0 +1,119 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {Matrix4} from '@math.gl/core';
6
+ import WebMercatorViewAdapter from '../../web_mercator_view_adapter';
7
+ import {createXRPlacementMatrix, intersectContentSurface, transformXRRayToContent, isContentPointWithinMapBounds} from './projection';
8
+ import type {XRPresentationFrame, XRPresentationRenderView, XRSpatialRay, XRSurfaceHit,
9
+ XRSurfacePickingOptions, XRVector3} from './types';
10
+
11
+ /**
12
+ * Resolve a pointer against the zero-altitude map plane or globe sphere.
13
+ * Screen input uses the actual rendered eye, not a reconstructed logical camera.
14
+ * Room rays use the supplied placement snapshot. Misses, invalid rays and clipped
15
+ * screen hits return null. Explicit terrain meshes replace, rather than supplement,
16
+ * the analytic surface; this API does not select worker feature IDs or load a DEM.
17
+ */
18
+ export function pickXRSurface(options: XRSurfacePickingOptions): XRSurfaceHit | null {
19
+ const {pointer, placement, frame} = options;
20
+ const viewState = options.viewState || {
21
+ longitude: frame?.logicalViewport.longitude,
22
+ latitude: frame?.logicalViewport.latitude,
23
+ bearing: frame?.logicalViewport.bearing
24
+ };
25
+ let ray: XRSpatialRay;
26
+ let farPoint: XRVector3 | undefined;
27
+ let renderViewId: string | undefined;
28
+ if ('origin' in pointer) {
29
+ if (!isFiniteVector(pointer.origin) || !isFiniteVector(pointer.direction) ||
30
+ Math.hypot(...pointer.direction) === 0) return null;
31
+ const matrix = options.placementMatrix || createXRPlacementMatrix(placement, viewState);
32
+ if (!isInvertibleMatrix(matrix)) return null;
33
+ const magnitude = Math.hypot(...pointer.direction);
34
+ if (!Number.isFinite(magnitude)) return null;
35
+ ray = transformXRRayToContent({origin: pointer.origin, direction: [pointer.direction[0] / magnitude,
36
+ pointer.direction[1] / magnitude, pointer.direction[2] / magnitude]}, matrix);
37
+ } else {
38
+ if (!frame || !Number.isFinite(pointer.x) || !Number.isFinite(pointer.y)) return null;
39
+ let camera;
40
+ let viewport;
41
+ if (pointer.eye === 'center') {
42
+ if (frame.mode === 'immersive-vr') return null;
43
+ viewport = frame.logicalViewport;
44
+ camera = placement.type === 'globe'
45
+ ? {view: viewport.viewMatrix, projection: new Matrix4().copy(viewport.projectionMatrix).multiplyRight(viewport.viewMatrix)}
46
+ : WebMercatorViewAdapter.getCameraFrame(viewport);
47
+ } else {
48
+ const eye = frame.renderViews.find(view => view.viewport && containsPixel(getScreenViewport(view, frame), pointer.x, pointer.y) &&
49
+ (!pointer.eye || view.id === `${pointer.eye}-eye` || view.xrView?.eye === pointer.eye));
50
+ if (!eye?.camera) return null;
51
+ camera = eye.camera;
52
+ viewport = getScreenViewport(eye, frame);
53
+ renderViewId = eye.id;
54
+ }
55
+ if (!viewport || !containsPixel(viewport, pointer.x, pointer.y) ||
56
+ !isInvertibleMatrix(camera.projection) || !isInvertibleMatrix(camera.view)) return null;
57
+ // Globe HostCamera.projection already contains view × placement. Applying
58
+ // camera.view a second time would turn screen picks away from the globe.
59
+ const clipMatrix = new Matrix4().copy(camera.projection);
60
+ if (placement.type !== 'globe') clipMatrix.multiplyRight(camera.view);
61
+ if (!isInvertibleMatrix(clipMatrix)) return null;
62
+ const inverse = clipMatrix.invert();
63
+ const horizontal = 2 * (pointer.x - (viewport.x ?? 0)) / viewport.width - 1;
64
+ const vertical = 1 - 2 * (pointer.y - (viewport.y ?? 0)) / viewport.height;
65
+ const nearPoint = unprojectPoint(inverse, horizontal, vertical, -1);
66
+ farPoint = unprojectPoint(inverse, horizontal, vertical, 1) || undefined;
67
+ if (!nearPoint || !farPoint) return null;
68
+ ray = {origin: nearPoint, direction: [farPoint[0] - nearPoint[0],
69
+ farPoint[1] - nearPoint[1], farPoint[2] - nearPoint[2]]};
70
+ }
71
+ const length = Math.hypot(...ray.direction);
72
+ if (!isFiniteVector(ray.origin) || !Number.isFinite(length) || length === 0) return null;
73
+ const direction: XRVector3 = [ray.direction[0] / length, ray.direction[1] / length, ray.direction[2] / length];
74
+ if (options.terrain && options.terrain.projection !== (placement.type === 'globe' ? 'globe' : 'web-mercator')) return null;
75
+ const terrainHit = options.terrain?.intersectRay({origin: ray.origin, direction}, farPoint ? length : Infinity);
76
+ const hit: XRSurfaceHit | null = options.terrain ? (terrainHit ? {
77
+ position: terrainHit.position, coordinate: terrainHit.coordinate, terrain: {
78
+ triangleIndex: terrainHit.triangleIndex, normal: terrainHit.normal, barycentric: terrainHit.barycentric
79
+ }} : null) : intersectContentSurface({origin: ray.origin, direction}, placement, viewState);
80
+ if (!hit || !isFiniteVector(hit.position)) return null;
81
+ if (!isContentPointWithinMapBounds(hit.position, placement, viewState)) return null;
82
+ if (farPoint && Math.hypot(hit.position[0] - ray.origin[0], hit.position[1] - ray.origin[1],
83
+ hit.position[2] - ray.origin[2]) > length * (1 + 1e-9)) return null;
84
+ return renderViewId ? {...hit, renderViewId} : hit;
85
+ }
86
+
87
+ /** Convert native XR's bottom-origin framebuffer rectangle to top-origin screen coordinates. */
88
+ function getScreenViewport(view: XRPresentationRenderView, frame: XRPresentationFrame) {
89
+ const viewport = view.viewport!;
90
+ return view.xrView ? {...viewport,
91
+ y: frame.hostFrame.viewport.height - (viewport.y ?? 0) - viewport.height} : viewport;
92
+ }
93
+
94
+ /** Test a CSS-pixel position against a half-open render rectangle. */
95
+ function containsPixel(viewport: {x?: number; y?: number; width: number; height: number}, x: number, y: number): boolean {
96
+ const left = viewport.x ?? 0;
97
+ const top = viewport.y ?? 0;
98
+ return Number.isFinite(viewport.width) && Number.isFinite(viewport.height) &&
99
+ x >= left && y >= top && x < left + viewport.width && y < top + viewport.height;
100
+ }
101
+
102
+ /** Reject singular and non-finite transforms before math.gl inversion. */
103
+ function isInvertibleMatrix(matrix: ArrayLike<number>): boolean {
104
+ if (matrix.length !== 16 || !Array.from(matrix).every(Number.isFinite)) return false;
105
+ const determinant = new Matrix4(Array.from(matrix)).determinant();
106
+ return Number.isFinite(determinant) && determinant !== 0;
107
+ }
108
+
109
+ /** Unproject a clip-space point with an explicit homogeneous divide. */
110
+ function unprojectPoint(matrix: Matrix4, x: number, y: number, z: number): XRVector3 | null {
111
+ const point = matrix.transform([x, y, z, 1]);
112
+ if (!point.every(Number.isFinite) || point[3] === 0) return null;
113
+ return [point[0] / point[3], point[1] / point[3], point[2] / point[3]];
114
+ }
115
+
116
+ /** Narrow input validation without modifying application-owned arrays. */
117
+ function isFiniteVector(vector: readonly number[]): boolean {
118
+ return vector.length === 3 && vector.every(Number.isFinite);
119
+ }