@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,240 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import {CompositeLayer, COORDINATE_SYSTEM, type LayerProps, type UpdateParameters, type Viewport} from '@deck.gl/core';
|
|
6
|
+
import {SimpleMeshLayer} from '@deck.gl/mesh-layers';
|
|
7
|
+
import {Matrix4} from '@math.gl/core';
|
|
8
|
+
import {TerrainMeshSurface, TerrainSurfaceCollection, sampleTerrainElevation, PROJECTION_CONSTANTS, type TerrainSurfaceHit, type TerrainCollectionHit} from '@tangram.gl/tangram-renderer/core';
|
|
9
|
+
import {prepareGlobeTerrainMesh, getPreparedGlobeTerrainTemplate, scaleGlobeTerrainMesh, getGlobeTerrainExaggeration, type GlobeTerrainMeshTemplate, type GlobeTerrainPreparationSnapshot, type TerrainHeightGrid} from '@tangram.gl/tangram-renderer/map-logic';
|
|
10
|
+
import {getTerrainRenderMesh, resolveGlobeTerrainDisplay, type GlobeTerrainDisplaySnapshot} from './terrain-display';
|
|
11
|
+
|
|
12
|
+
export {GlobeTerrainDisplayPreparation} from './terrain-display';
|
|
13
|
+
export type {GlobeTerrainDisplaySnapshot, GlobeTerrainDisplayStatistics} from './terrain-display';
|
|
14
|
+
|
|
15
|
+
/** One current immutable neighborhood and its most recent displayed/query geometry. */
|
|
16
|
+
interface PreparedGlobeTerrainTile {
|
|
17
|
+
grid: TerrainHeightGrid;
|
|
18
|
+
neighbors: readonly TerrainHeightGrid[];
|
|
19
|
+
template: GlobeTerrainMeshTemplate;
|
|
20
|
+
exaggeration: number;
|
|
21
|
+
mesh: ReturnType<typeof getTerrainRenderMesh>;
|
|
22
|
+
surface: TerrainMeshSurface;
|
|
23
|
+
queryLimit: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Exact retained display/query arrays and CPU timings, not total process/GPU memory. */
|
|
27
|
+
export interface GlobeTerrainLayerStatistics {
|
|
28
|
+
/** Actual admitted tiles, which can differ from requested data after a failed replacement. */
|
|
29
|
+
tileCount: number;
|
|
30
|
+
/** Actual admitted display multiplier; zero before initial admission. */
|
|
31
|
+
exaggeration: number;
|
|
32
|
+
/** Packed geographic/color/index buffers plus immutable query geometry and physical heights. */
|
|
33
|
+
displayBytes: number;
|
|
34
|
+
/** Current lazy query hierarchy bytes, bounded independently from display storage. */
|
|
35
|
+
queryIndexBytes: number;
|
|
36
|
+
/** Current display-generation admission failure; previous geometry remains visible and pickable. */
|
|
37
|
+
error?: string;
|
|
38
|
+
/** Most recent synchronous update, including validation, rescaling and query snapshots. */
|
|
39
|
+
updateMs: number;
|
|
40
|
+
/** Most recent complete screen, elevation or segment query, including any lazy index construction. */
|
|
41
|
+
queryMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Non-instanced mesh input stays stable across camera and display updates. */
|
|
45
|
+
const terrainInstances = [{}];
|
|
46
|
+
|
|
47
|
+
/** Globe-only physical DEM input, without a loader, network request or source ownership. */
|
|
48
|
+
export type GlobeTerrainLayerProps = LayerProps & {
|
|
49
|
+
/** Immutable non-overlapping grids with balanced 2:1 edges in EPSG:3857 meters; no-data stays holes. */
|
|
50
|
+
data: readonly TerrainHeightGrid[];
|
|
51
|
+
/** Maximum display multiplier; defaults to 100, range [0, 1000]. Zero keeps the globe flat. */
|
|
52
|
+
globeTerrainExaggeration?: number;
|
|
53
|
+
/** Uniform curved cells per tile axis, range [2, 256]; defaults to 64. */
|
|
54
|
+
meshResolution?: number;
|
|
55
|
+
/** Optional cooperatively prepared complete generation, matching data identities/order and meshResolution. */
|
|
56
|
+
preparedTerrain?: GlobeTerrainPreparationSnapshot;
|
|
57
|
+
/** Optional complete draw/query generation; its effective multiplier overrides automatic camera rescaling. */
|
|
58
|
+
preparedDisplay?: GlobeTerrainDisplaySnapshot;
|
|
59
|
+
/** Current plus replacement display/query geometry typed-array cap; defaults to 128 MiB. */
|
|
60
|
+
maxDisplayBytes?: number;
|
|
61
|
+
/** Total current lazy query hierarchy typed-array cap; defaults to 64 MiB. */
|
|
62
|
+
maxQueryIndexBytes?: number;
|
|
63
|
+
/** Show triangle edges rather than shaded terrain. */
|
|
64
|
+
wireframe?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Apparent spherical diameter divided by the shorter viewport dimension. */
|
|
68
|
+
export function getGlobeScreenRatio(viewport: Viewport): number {
|
|
69
|
+
if (viewport.projectionMode !== 2 || viewport.width <= 0 || viewport.height <= 0) throw new Error('Globe terrain requires GlobeView');
|
|
70
|
+
const distance = Math.hypot(...viewport.cameraPosition);
|
|
71
|
+
const denominator = Math.sqrt(Math.max(Number.EPSILON, distance * distance - 256 * 256));
|
|
72
|
+
return Math.abs(viewport.projectionMatrix[5]) * 256 * viewport.height / (denominator * Math.min(viewport.width, viewport.height));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Experimental displaced globe terrain. The normal TangramLayer entry does not import this layer. */
|
|
76
|
+
export class GlobeTerrainLayer extends CompositeLayer<GlobeTerrainLayerProps> {
|
|
77
|
+
/** Stable deck.gl diagnostic name. */
|
|
78
|
+
static layerName = 'GlobeTerrainLayer';
|
|
79
|
+
/** Validated display controls; terrain data never changes unit convention. */
|
|
80
|
+
static defaultProps = {
|
|
81
|
+
globeTerrainExaggeration: {type: 'number', value: 100, min: 0, max: 1000},
|
|
82
|
+
meshResolution: {type: 'number', value: 64, min: 2, max: 256},
|
|
83
|
+
wireframe: false,
|
|
84
|
+
maxDisplayBytes: {type: 'number', value: 128 * 1024 * 1024, min: 0},
|
|
85
|
+
maxQueryIndexBytes: {type: 'number', value: 64 * 1024 * 1024, min: 0}
|
|
86
|
+
};
|
|
87
|
+
/** Immutable display meshes and their lazy triangle-query snapshots. */
|
|
88
|
+
declare state: {layers: SimpleMeshLayer[]; surfaces: TerrainMeshSurface[]; exaggeration: number; prepared?: PreparedGlobeTerrainTile[]; resolution?: number; memoryError?: string; updateMs?: number; queryMs?: number;
|
|
89
|
+
/** One cached admitted-membership/radial-bound snapshot; owns no additional geometry buffers. */
|
|
90
|
+
queryGeometry?: {
|
|
91
|
+
/** Identity used to invalidate membership after publication. */
|
|
92
|
+
surfaces: TerrainMeshSurface[];
|
|
93
|
+
/** Shared nearest-hit/sightline router over the existing query owners. */
|
|
94
|
+
collection: TerrainSurfaceCollection;
|
|
95
|
+
/** Conservative radius enclosing every current triangle, in globe common units. */
|
|
96
|
+
radius: number;
|
|
97
|
+
}};
|
|
98
|
+
|
|
99
|
+
/** Detached current storage/timing diagnostics; index bytes may grow only on an intersecting query. */
|
|
100
|
+
getStatistics(): GlobeTerrainLayerStatistics {
|
|
101
|
+
const tiles = this.state?.prepared ?? [];
|
|
102
|
+
return {tileCount: tiles.length, exaggeration: this.state?.exaggeration ?? 0, displayBytes: tiles.reduce((sum, tile) => sum + this.getDisplayBytes(tile.template), 0), queryIndexBytes: tiles.reduce((sum, tile) => sum + tile.surface.getStatistics().queryIndexBytes, 0), error: this.state?.memoryError, updateMs: this.state?.updateMs ?? 0, queryMs: this.state?.queryMs ?? 0};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Geographic/color buffers, render topology and one owned query snapshot; no duplicate index geometry. */
|
|
106
|
+
private getDisplayBytes(template: GlobeTerrainMeshTemplate): number {return template.vertexCount * 56 + template.triangleCount * 24;}
|
|
107
|
+
|
|
108
|
+
/** Recompute display geometry when the camera changes scale, not on every animation tick. */
|
|
109
|
+
shouldUpdateState({changeFlags}: UpdateParameters<this>): boolean {
|
|
110
|
+
return Boolean(changeFlags.propsOrDataChanged || changeFlags.viewportChanged);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Keep each eye's display multiplier tied to the active viewport; grids remain physically unchanged. */
|
|
114
|
+
updateState({changeFlags}: UpdateParameters<this>): void {
|
|
115
|
+
const started = performance.now();
|
|
116
|
+
const maxDisplayBytes = this.props.maxDisplayBytes ?? 128 * 1024 * 1024, maxQueryIndexBytes = this.props.maxQueryIndexBytes ?? 64 * 1024 * 1024;
|
|
117
|
+
for (const limit of [maxDisplayBytes, maxQueryIndexBytes]) if (!Number.isSafeInteger(limit) || limit < 0) throw new Error('Globe terrain byte limits must be non-negative integers');
|
|
118
|
+
if (this.props.modelMatrix || this.props.extensions.length) throw new Error('Globe terrain does not support vertex transforms or layer extensions');
|
|
119
|
+
if (this.props.data.some(grid => grid.verticalDatum !== this.props.data[0].verticalDatum)) throw new Error('Globe terrain grids require one compatible vertical datum');
|
|
120
|
+
if (new Set(this.props.data.map(grid => grid.bounds.join(','))).size !== this.props.data.length) throw new Error('Globe terrain grids must have distinct footprints');
|
|
121
|
+
const snapshot = this.props.preparedTerrain;
|
|
122
|
+
if (snapshot && (snapshot.resolution !== this.props.meshResolution || snapshot.grids.length !== this.props.data.length || snapshot.grids.some((grid, index) => grid !== this.props.data[index]))) throw new Error('Prepared globe terrain must match data and meshResolution');
|
|
123
|
+
const requestedExaggeration = getGlobeTerrainExaggeration(getGlobeScreenRatio(this.context.viewport), this.props.globeTerrainExaggeration);
|
|
124
|
+
const display = this.props.preparedDisplay;
|
|
125
|
+
if (display) {
|
|
126
|
+
const tiles = resolveGlobeTerrainDisplay(display), terrain = display.terrain;
|
|
127
|
+
if (terrain.resolution !== this.props.meshResolution || terrain.grids.length !== this.props.data.length || terrain.grids.some((grid, index) => grid !== this.props.data[index]) || snapshot && snapshot !== terrain) throw new Error('Prepared globe display must match data, terrain and meshResolution');
|
|
128
|
+
if (display.maxDisplayBytes !== maxDisplayBytes || display.maxQueryIndexBytes !== maxQueryIndexBytes) throw new Error('Prepared globe display budgets must match the layer');
|
|
129
|
+
if (!changeFlags.propsOrDataChanged && this.state.exaggeration === display.exaggeration) return;
|
|
130
|
+
const existingSurfaces = new Set(this.state.surfaces ?? []);
|
|
131
|
+
const newBytes = tiles.reduce((sum, tile) => sum + (existingSurfaces.has(tile.surface) ? 0 : this.getDisplayBytes(tile.template)), 0);
|
|
132
|
+
if (newBytes > 0 && this.getStatistics().displayBytes + newBytes > maxDisplayBytes) {this.setState({memoryError: 'Globe terrain display-byte limit exceeded; previous terrain retained', updateMs: performance.now() - started}); return;}
|
|
133
|
+
const circumference = 2 * Math.PI * 6378137;
|
|
134
|
+
const prepared = tiles.map(tile => ({...tile, neighbors: this.props.data.filter(candidate => candidate !== tile.grid && [-circumference, 0, circumference].some(shift => candidate.bounds[0] + shift <= tile.grid.bounds[2] && candidate.bounds[2] + shift >= tile.grid.bounds[0] && candidate.bounds[1] <= tile.grid.bounds[3] && candidate.bounds[3] >= tile.grid.bounds[1]))}));
|
|
135
|
+
this.publishTiles(prepared, display.exaggeration, started);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const exaggeration = requestedExaggeration;
|
|
139
|
+
if (!changeFlags.propsOrDataChanged && this.state.exaggeration === exaggeration) return;
|
|
140
|
+
const circumference = 2 * Math.PI * 6378137;
|
|
141
|
+
const previous = new Map(this.state.prepared?.map(tile => [tile.grid, tile]));
|
|
142
|
+
const queryLimit = Math.floor(maxQueryIndexBytes / Math.max(1, this.props.data.length));
|
|
143
|
+
const candidates = this.props.data.map(grid => {
|
|
144
|
+
// Sampling and stitching only depend on touching footprints, including wrapped corners.
|
|
145
|
+
const neighbors = this.props.data.filter(candidate => candidate !== grid && [-circumference, 0, circumference].some(shift =>
|
|
146
|
+
candidate.bounds[0] + shift <= grid.bounds[2] && candidate.bounds[2] + shift >= grid.bounds[0] && candidate.bounds[1] <= grid.bounds[3] && candidate.bounds[3] >= grid.bounds[1]));
|
|
147
|
+
const retained = previous.get(grid);
|
|
148
|
+
const sameNeighborhood = retained && this.state.resolution === this.props.meshResolution && retained.neighbors.length === neighbors.length && retained.neighbors.every((neighbor, index) => neighbor === neighbors[index]);
|
|
149
|
+
const template = snapshot ? getPreparedGlobeTerrainTemplate(snapshot, grid) : sameNeighborhood ? retained.template : prepareGlobeTerrainMesh(grid, {resolution: this.props.meshResolution, neighbors});
|
|
150
|
+
// A changed tile count must not churn warm geometry when the complete hierarchy still fits.
|
|
151
|
+
const requiredBytes = retained ? retained.surface.getStatistics().requiredQueryIndexBytes : 0;
|
|
152
|
+
const sameQueryPolicy = retained && (requiredBytes <= retained.queryLimit) === (requiredBytes <= queryLimit);
|
|
153
|
+
const reuse = sameNeighborhood && retained.template === template && retained.exaggeration === exaggeration && sameQueryPolicy;
|
|
154
|
+
return {grid, neighbors, template, retained: reuse ? retained : undefined};
|
|
155
|
+
});
|
|
156
|
+
// Preflight the peak retained current + new geometry, counting reused tiles only once.
|
|
157
|
+
const retainedBytes = this.getStatistics().displayBytes;
|
|
158
|
+
const replacementBytes = candidates.reduce((sum, tile) => sum + (tile.retained ? 0 : this.getDisplayBytes(tile.template)), 0);
|
|
159
|
+
const displayedBytes = candidates.reduce((sum, tile) => sum + this.getDisplayBytes(tile.template), 0);
|
|
160
|
+
if (displayedBytes > maxDisplayBytes || replacementBytes > 0 && retainedBytes + replacementBytes > maxDisplayBytes) {
|
|
161
|
+
this.setState({memoryError: 'Globe terrain display-byte limit exceeded; previous terrain retained', updateMs: performance.now() - started});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const prepared = candidates.map(({grid, neighbors, template, retained}) => {
|
|
165
|
+
if (retained) return retained;
|
|
166
|
+
const mesh = scaleGlobeTerrainMesh(template, exaggeration);
|
|
167
|
+
return {grid, neighbors, template, exaggeration, queryLimit, mesh: getTerrainRenderMesh(mesh),
|
|
168
|
+
surface: new TerrainMeshSurface({projection: 'globe', positions: mesh.positions, indices: mesh.indices, physicalHeights: mesh.physicalHeights, cullBackfaces: true, maxQueryIndexBytes: queryLimit})};
|
|
169
|
+
});
|
|
170
|
+
this.publishTiles(prepared, exaggeration, started);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Publish one complete draw/query generation and measure layer assembly as well as geometry work. */
|
|
174
|
+
private publishTiles(prepared: PreparedGlobeTerrainTile[], exaggeration: number, started: number): void {
|
|
175
|
+
this.setState({exaggeration, memoryError: undefined, queryGeometry: undefined,
|
|
176
|
+
prepared, resolution: this.props.meshResolution,
|
|
177
|
+
surfaces: prepared.map(tile => tile.surface),
|
|
178
|
+
layers: prepared.map(tile => new SimpleMeshLayer(this.getSubLayerProps({id: `mesh-${tile.grid.bounds.join(',')}`}), {
|
|
179
|
+
data: terrainInstances, mesh: tile.mesh,
|
|
180
|
+
coordinateSystem: COORDINATE_SYSTEM.LNGLAT, _instanced: false, getPosition: [0, 0, 0], getColor: [255, 255, 255],
|
|
181
|
+
// Colors already contain spherical relief lighting; do not transform them as planar ENU normals.
|
|
182
|
+
material: false, wireframe: this.props.wireframe, pickable: false, parameters: {...this.props.parameters, cullMode: 'back'}
|
|
183
|
+
})), updateMs: performance.now() - started});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Draw curved physical terrain, independently of the basemap's styling/tile lifecycle. */
|
|
187
|
+
renderLayers(): SimpleMeshLayer[] {return this.state.layers ?? [];}
|
|
188
|
+
|
|
189
|
+
/** Cache membership and a conservative radial bracket for the admitted generation, without copying geometry. */
|
|
190
|
+
private getQueryGeometry(): {collection: TerrainSurfaceCollection; radius: number} {
|
|
191
|
+
const surfaces = this.state?.surfaces ?? [], cached = this.state?.queryGeometry;
|
|
192
|
+
if (cached?.surfaces === surfaces) return cached;
|
|
193
|
+
const collection = new TerrainSurfaceCollection('globe', surfaces.map((surface, index) => ({id: String(index), surface})));
|
|
194
|
+
const radius = Math.max(0, ...surfaces.map(surface => {
|
|
195
|
+
const bounds = surface.getBounds();
|
|
196
|
+
return bounds ? Math.hypot(...bounds.minimum.map((value, axis) => Math.max(Math.abs(value), Math.abs(bounds.maximum[axis])))) : 0;
|
|
197
|
+
}));
|
|
198
|
+
if (this.state) this.state.queryGeometry = {surfaces, collection, radius};
|
|
199
|
+
return {collection, radius};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Probe the currently displayed top triangles radially, returning physical source elevation rather than relief. */
|
|
203
|
+
sampleElevation(coordinate: readonly [number, number]): TerrainSurfaceHit | null {
|
|
204
|
+
const started = performance.now();
|
|
205
|
+
// Conservative XYZ bounds bracket every displaced triangle, even when relief exceeds ordinary DEM heights.
|
|
206
|
+
const {collection, radius} = this.getQueryGeometry();
|
|
207
|
+
const hit = sampleTerrainElevation(collection, coordinate, {
|
|
208
|
+
minimumAltitude: -PROJECTION_CONSTANTS.earthRadius * (1 - Number.EPSILON),
|
|
209
|
+
maximumAltitude: ((radius + 1) / PROJECTION_CONSTANTS.globeRadius - 1) * PROJECTION_CONSTANTS.earthRadius
|
|
210
|
+
});
|
|
211
|
+
if (this.state) this.state.queryMs = performance.now() - started;
|
|
212
|
+
return hit;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Return the nearest loaded terrain blocker on a finite radius-256 common-space segment; null is not certified visibility. */
|
|
216
|
+
intersectTerrainSegment(start: readonly [number, number, number], end: readonly [number, number, number], options?: {
|
|
217
|
+
/** Origin self-intersection trim, in globe common units; defaults to zero. */
|
|
218
|
+
startInset?: number;
|
|
219
|
+
/** Target self-intersection trim, in globe common units; defaults to zero. */
|
|
220
|
+
endInset?: number;
|
|
221
|
+
}): TerrainCollectionHit | null {
|
|
222
|
+
const started = performance.now(), hit = this.getQueryGeometry().collection.intersectSegment(start, end, options);
|
|
223
|
+
if (this.state) this.state.queryMs = performance.now() - started;
|
|
224
|
+
return hit;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Query displayed triangles but return barycentrically interpolated physical source meters. */
|
|
228
|
+
pickTerrain(viewport: Viewport, pixel: readonly [number, number]): TerrainSurfaceHit | null {
|
|
229
|
+
const started = performance.now();
|
|
230
|
+
getGlobeScreenRatio(viewport);
|
|
231
|
+
if (pixel.length !== 2 || !pixel.every(Number.isFinite)) throw new Error('Globe terrain pixels must be finite');
|
|
232
|
+
const inverse = new Matrix4(viewport.viewProjectionMatrix).invert();
|
|
233
|
+
const points = [-1, 1].map(z => inverse.transform([2 * (pixel[0] - viewport.x) / viewport.width - 1, 1 - 2 * (pixel[1] - viewport.y) / viewport.height, z, 1]));
|
|
234
|
+
const origin: [number, number, number] = [points[0][0] / points[0][3], points[0][1] / points[0][3], points[0][2] / points[0][3]];
|
|
235
|
+
const direction: [number, number, number] = [points[1][0] / points[1][3] - origin[0], points[1][1] / points[1][3] - origin[1], points[1][2] / points[1][3] - origin[2]];
|
|
236
|
+
const nearest = this.getQueryGeometry().collection.intersectRay({origin, direction}, Math.hypot(...direction));
|
|
237
|
+
if (this.state) this.state.queryMs = performance.now() - started;
|
|
238
|
+
return nearest;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import {Matrix4, Quaternion} from '@math.gl/core';
|
|
6
|
+
import {pickXRSurface} from './picking';
|
|
7
|
+
import {createXRPlacementMatrix} from './projection';
|
|
8
|
+
import type {XRGlobePlacement, XRInteractionIntent, XRMapPlacement, XRSpatialRay,
|
|
9
|
+
XRSurfacePickingOptions, XRVector3} from './types';
|
|
10
|
+
|
|
11
|
+
/** Rendered placement snapshot used to start a room-space surface grab. */
|
|
12
|
+
export type XRSurfaceGrabContext = Omit<XRSurfacePickingOptions, 'pointer'>;
|
|
13
|
+
|
|
14
|
+
/** Private immutable baseline for one controller-owned gesture. */
|
|
15
|
+
type SurfaceGrab = {
|
|
16
|
+
inputId: string;
|
|
17
|
+
placement: XRMapPlacement | XRGlobePlacement;
|
|
18
|
+
context: XRSurfaceGrabContext;
|
|
19
|
+
matrix: Matrix4;
|
|
20
|
+
hit: XRVector3;
|
|
21
|
+
normal: XRVector3;
|
|
22
|
+
center: XRVector3;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Translate tabletop maps in their initial plane and rotate globes around their
|
|
27
|
+
* room-space center. This owns gesture state, not a renderer or XR session.
|
|
28
|
+
* Only room rays can grab; desktop screen pointers keep normal deck controls.
|
|
29
|
+
*/
|
|
30
|
+
export class WebXRSurfaceGrabber {
|
|
31
|
+
private grab: SurfaceGrab | null = null;
|
|
32
|
+
|
|
33
|
+
/** Whether one input currently owns a grab, including paused off-surface movement. */
|
|
34
|
+
isGrabbing(): boolean {
|
|
35
|
+
return this.grab !== null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** End a gesture without rolling back the application's last accepted placement. */
|
|
39
|
+
reset(): void {
|
|
40
|
+
this.grab = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Consume grab/hover/release/cancel intents and return a new placement on movement.
|
|
45
|
+
* Supply the actual rendered placement when starting; subsequent movement uses
|
|
46
|
+
* that frozen baseline, avoiding feedback as the application moves the object.
|
|
47
|
+
* Missing input IDs share a single implicit owner. Select-button releases never
|
|
48
|
+
* terminate a squeeze grab. First-person ground and screen grabs are unsupported.
|
|
49
|
+
*/
|
|
50
|
+
dispatchInteractionIntent(intent: XRInteractionIntent, context: XRSurfaceGrabContext):
|
|
51
|
+
XRMapPlacement | XRGlobePlacement | null {
|
|
52
|
+
if (intent.type !== 'point') return null;
|
|
53
|
+
const inputId = intent.inputId ?? 'pointer';
|
|
54
|
+
if (this.grab && this.grab.inputId !== inputId) return null;
|
|
55
|
+
if (intent.action === 'cancel' || (intent.action === 'release' && intent.button !== 'select')) {
|
|
56
|
+
this.reset();
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!('origin' in intent.pointer)) return null;
|
|
60
|
+
if (intent.action === 'grab' && !this.grab) {
|
|
61
|
+
this.beginGrab(inputId, intent.pointer, context);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (intent.action !== 'hover' || !this.grab) return null;
|
|
65
|
+
return this.moveGrab(intent.pointer);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Freeze geographic state, pose and the actual room matrix after a valid surface hit. */
|
|
69
|
+
private beginGrab(inputId: string, pointer: XRSpatialRay, context: XRSurfaceGrabContext): void {
|
|
70
|
+
if (context.placement.type === 'first-person') return;
|
|
71
|
+
const hit = pickXRSurface({...context, pointer});
|
|
72
|
+
if (!hit) return;
|
|
73
|
+
const placement = context.placement;
|
|
74
|
+
const position = placement.pose?.position ?? [0, 0, 0];
|
|
75
|
+
const orientation = placement.pose?.orientation ?? [0, 0, 0, 1];
|
|
76
|
+
const scale = placement.type === 'map' ? placement.metersPerXRUnit : placement.radius;
|
|
77
|
+
if (!position.every(Number.isFinite) || !orientation.every(Number.isFinite) ||
|
|
78
|
+
!(Math.hypot(...orientation) > 0) || !Number.isFinite(scale) || scale <= 0) return;
|
|
79
|
+
const snapshot = copyPlacement(placement);
|
|
80
|
+
const viewState = {...(context.viewState ?? {
|
|
81
|
+
longitude: context.frame?.logicalViewport.longitude,
|
|
82
|
+
latitude: context.frame?.logicalViewport.latitude})};
|
|
83
|
+
const matrix = new Matrix4().copy(context.placementMatrix ??
|
|
84
|
+
createXRPlacementMatrix(placement, viewState));
|
|
85
|
+
const normal = normalize([
|
|
86
|
+
matrix[1] * matrix[6] - matrix[2] * matrix[5],
|
|
87
|
+
matrix[2] * matrix[4] - matrix[0] * matrix[6],
|
|
88
|
+
matrix[0] * matrix[5] - matrix[1] * matrix[4]
|
|
89
|
+
]);
|
|
90
|
+
if (!normal || Math.abs(matrix[3]) + Math.abs(matrix[7]) + Math.abs(matrix[11]) > 1e-9 ||
|
|
91
|
+
Math.abs(matrix[15] - 1) > 1e-9) return;
|
|
92
|
+
this.grab = {inputId, placement: snapshot,
|
|
93
|
+
context: {...context, placement: snapshot, viewState, placementMatrix: matrix},
|
|
94
|
+
matrix, hit: transformPoint(matrix, hit.position), normal,
|
|
95
|
+
center: transformPoint(matrix, [0, 0, 0])};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve movement against the start plane or sphere, never the already moved object. */
|
|
99
|
+
private moveGrab(pointer: XRSpatialRay): XRMapPlacement | XRGlobePlacement | null {
|
|
100
|
+
const grab = this.grab;
|
|
101
|
+
if (!grab || !pointer.origin.every(Number.isFinite)) return null;
|
|
102
|
+
const direction = normalize(pointer.direction);
|
|
103
|
+
if (!direction) return null;
|
|
104
|
+
if (grab.placement.type === 'map') {
|
|
105
|
+
const denominator = dot(direction, grab.normal);
|
|
106
|
+
if (Math.abs(denominator) < 1e-7) return null;
|
|
107
|
+
const distance = dot(subtract(grab.hit, pointer.origin), grab.normal) / denominator;
|
|
108
|
+
if (!Number.isFinite(distance) || distance < 0) return null;
|
|
109
|
+
const hit: XRVector3 = [pointer.origin[0] + direction[0] * distance,
|
|
110
|
+
pointer.origin[1] + direction[1] * distance, pointer.origin[2] + direction[2] * distance];
|
|
111
|
+
const position = grab.placement.pose?.position ?? [0, 0, 0];
|
|
112
|
+
const nextPosition: XRVector3 = [position[0] + hit[0] - grab.hit[0],
|
|
113
|
+
position[1] + hit[1] - grab.hit[1], position[2] + hit[2] - grab.hit[2]];
|
|
114
|
+
if (!nextPosition.every(Number.isFinite)) return null;
|
|
115
|
+
const placement = copyPlacement(grab.placement);
|
|
116
|
+
return {...placement, pose: {...placement.pose,
|
|
117
|
+
position: nextPosition}};
|
|
118
|
+
}
|
|
119
|
+
const hit = pickXRSurface({...grab.context, pointer});
|
|
120
|
+
if (!hit) return null;
|
|
121
|
+
const start = normalize(subtract(grab.hit, grab.center));
|
|
122
|
+
const end = normalize(subtract(transformPoint(grab.matrix, hit.position), grab.center));
|
|
123
|
+
if (!start || !end) return null;
|
|
124
|
+
const rotation = new Quaternion().rotationTo([...start], [...end]);
|
|
125
|
+
const orientation = new Quaternion().copy(grab.placement.pose?.orientation ?? [0, 0, 0, 1])
|
|
126
|
+
.multiplyLeft(rotation).normalize();
|
|
127
|
+
const placement = copyPlacement(grab.placement);
|
|
128
|
+
return {...placement, pose: {...placement.pose,
|
|
129
|
+
orientation: [orientation[0], orientation[1], orientation[2], orientation[3]]}};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Isolate application-owned pose, anchor and finite surface dimensions from gesture state. */
|
|
134
|
+
function copyPlacement(placement: XRMapPlacement | XRGlobePlacement): XRMapPlacement | XRGlobePlacement {
|
|
135
|
+
const position = placement.pose?.position ?? [0, 0, 0];
|
|
136
|
+
const orientation = placement.pose?.orientation ?? [0, 0, 0, 1];
|
|
137
|
+
const pose: NonNullable<XRMapPlacement['pose']> = {position: [position[0], position[1], position[2]],
|
|
138
|
+
orientation: [orientation[0], orientation[1], orientation[2], orientation[3]]};
|
|
139
|
+
return placement.type === 'map'
|
|
140
|
+
? {...placement, anchor: [...placement.anchor], pose,
|
|
141
|
+
...(placement.surface ? {surface: {...placement.surface}} : {})}
|
|
142
|
+
: {...placement, anchor: [...placement.anchor], pose};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Transform an affine content position into XR reference-space meters. */
|
|
146
|
+
function transformPoint(matrix: Matrix4, point: XRVector3): XRVector3 {
|
|
147
|
+
const result = matrix.transform([point[0], point[1], point[2], 1]);
|
|
148
|
+
return [result[0], result[1], result[2]];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Normalize valid finite vectors, rejecting zero-length or overflowing input. */
|
|
152
|
+
function normalize(vector: XRVector3): XRVector3 | null {
|
|
153
|
+
const length = Math.hypot(...vector);
|
|
154
|
+
return length > 0 && Number.isFinite(length)
|
|
155
|
+
? [vector[0] / length, vector[1] / length, vector[2] / length] : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Subtract two reference-space coordinates. */
|
|
159
|
+
function subtract(first: XRVector3, second: XRVector3): XRVector3 {
|
|
160
|
+
return [first[0] - second[0], first[1] - second[1], first[2] - second[2]];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Project one vector onto another. */
|
|
164
|
+
function dot(first: XRVector3, second: XRVector3): number {
|
|
165
|
+
return first[0] * second[0] + first[1] * second[1] + first[2] * second[2];
|
|
166
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
XRViewState,
|
|
7
|
+
XRViewportSize,
|
|
8
|
+
XREyeViewportOptions,
|
|
9
|
+
XRProjectionOptions,
|
|
10
|
+
XRVector3,
|
|
11
|
+
XRQuaternion,
|
|
12
|
+
XRGeographicPosition,
|
|
13
|
+
XRPlacementPose,
|
|
14
|
+
XRBoundedMapSurface,
|
|
15
|
+
XRUnboundedMapSurface,
|
|
16
|
+
XRMapPlacement,
|
|
17
|
+
XRGlobePlacement,
|
|
18
|
+
XRFirstPersonPlacement,
|
|
19
|
+
XRPlacement,
|
|
20
|
+
XRPresentationMode,
|
|
21
|
+
XRFrameView,
|
|
22
|
+
XRFrameState,
|
|
23
|
+
XRDeckView,
|
|
24
|
+
XRDeckViewport,
|
|
25
|
+
XRDeckController,
|
|
26
|
+
XRHostFrameFields,
|
|
27
|
+
XRPresentationRenderView,
|
|
28
|
+
XRPresentationFrame,
|
|
29
|
+
XRSpatialRay,
|
|
30
|
+
XRScreenPointer,
|
|
31
|
+
XRInteractionIntent,
|
|
32
|
+
XRSurfaceHit,
|
|
33
|
+
XRSurfacePickingOptions
|
|
34
|
+
} from './types.js';
|
|
35
|
+
export type {
|
|
36
|
+
WebXRInputSnapshot,
|
|
37
|
+
WebXRSession,
|
|
38
|
+
WebXRReferenceSpaceType,
|
|
39
|
+
WebXRInputAdapterOptions
|
|
40
|
+
} from './interaction.js';
|
|
41
|
+
export {
|
|
42
|
+
WebXRInputAdapter,
|
|
43
|
+
setWebXRSessionWithFallback
|
|
44
|
+
} from './interaction.js';
|
|
45
|
+
export {
|
|
46
|
+
longitudeLatitudeToMeters,
|
|
47
|
+
metersToLongitudeLatitude,
|
|
48
|
+
createXRPoseMatrix,
|
|
49
|
+
createXRPlacementMatrix,
|
|
50
|
+
transformXRRayToContent,
|
|
51
|
+
intersectXRMap,
|
|
52
|
+
intersectXRGlobe,
|
|
53
|
+
getXRGlobeVisibleBounds,
|
|
54
|
+
unionGeographicBounds
|
|
55
|
+
} from './projection.js';
|
|
56
|
+
export {pickXRSurface} from './picking.js';
|
|
57
|
+
export {WebXRSurfaceGrabber} from './grabbing.js';
|
|
58
|
+
export type {XRSurfaceGrabContext} from './grabbing.js';
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
DEFAULT_INTERPUPILLARY_DISTANCE,
|
|
62
|
+
WebXRPresentation,
|
|
63
|
+
WebXRViewManager
|
|
64
|
+
} from './presentation.js';
|
|
65
|
+
export type {
|
|
66
|
+
WebXRPresentationOptions,
|
|
67
|
+
WebXRControllerOptions,
|
|
68
|
+
WebXRFrameOptions
|
|
69
|
+
} from './presentation.js';
|
|
70
|
+
export {
|
|
71
|
+
WebXRMapView,
|
|
72
|
+
WebXRMapController,
|
|
73
|
+
WebXRFirstPersonView,
|
|
74
|
+
WebXRFirstPersonController,
|
|
75
|
+
WebXRGlobeView,
|
|
76
|
+
WebXRGlobeController
|
|
77
|
+
} from './views.js';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
WebXRInputAdapter,
|
|
7
|
+
setWebXRSessionWithFallback
|
|
8
|
+
} from './interaction';
|
|
9
|
+
export type {
|
|
10
|
+
WebXRInputSnapshot,
|
|
11
|
+
WebXRSession,
|
|
12
|
+
WebXRReferenceSpaceType,
|
|
13
|
+
WebXRInputAdapterOptions
|
|
14
|
+
} from './interaction';
|
|
15
|
+
export {
|
|
16
|
+
DEFAULT_INTERPUPILLARY_DISTANCE,
|
|
17
|
+
WebXRPresentation,
|
|
18
|
+
WebXRViewManager
|
|
19
|
+
} from './presentation';
|
|
20
|
+
export type {WebXRPresentationOptions, WebXRControllerOptions, WebXRFrameOptions} from './presentation';
|
|
21
|
+
export {
|
|
22
|
+
longitudeLatitudeToMeters,
|
|
23
|
+
metersToLongitudeLatitude,
|
|
24
|
+
createXRPoseMatrix,
|
|
25
|
+
createXRPlacementMatrix,
|
|
26
|
+
transformXRRayToContent,
|
|
27
|
+
intersectXRMap,
|
|
28
|
+
intersectXRGlobe,
|
|
29
|
+
getXRGlobeVisibleBounds,
|
|
30
|
+
unionGeographicBounds
|
|
31
|
+
} from './projection';
|
|
32
|
+
export {pickXRSurface} from './picking';
|
|
33
|
+
export {WebXRSurfaceGrabber} from './grabbing';
|
|
34
|
+
export type {XRSurfaceGrabContext} from './grabbing';
|
|
35
|
+
export {
|
|
36
|
+
WebXRMapView,
|
|
37
|
+
WebXRMapController,
|
|
38
|
+
WebXRFirstPersonController,
|
|
39
|
+
WebXRFirstPersonView,
|
|
40
|
+
WebXRGlobeController,
|
|
41
|
+
WebXRGlobeView
|
|
42
|
+
} from './views';
|
|
43
|
+
export type {
|
|
44
|
+
XRViewState,
|
|
45
|
+
XRViewportSize,
|
|
46
|
+
XREyeViewportOptions,
|
|
47
|
+
XRProjectionOptions,
|
|
48
|
+
XRVector3,
|
|
49
|
+
XRQuaternion,
|
|
50
|
+
XRGeographicPosition,
|
|
51
|
+
XRPlacementPose,
|
|
52
|
+
XRBoundedMapSurface,
|
|
53
|
+
XRUnboundedMapSurface,
|
|
54
|
+
XRMapPlacement,
|
|
55
|
+
XRGlobePlacement,
|
|
56
|
+
XRFirstPersonPlacement,
|
|
57
|
+
XRPlacement,
|
|
58
|
+
XRPresentationMode,
|
|
59
|
+
XRFrameView,
|
|
60
|
+
XRFrameState,
|
|
61
|
+
XRDeckView,
|
|
62
|
+
XRDeckViewport,
|
|
63
|
+
XRDeckController,
|
|
64
|
+
XRHostFrameFields,
|
|
65
|
+
XRPresentationRenderView,
|
|
66
|
+
XRPresentationFrame,
|
|
67
|
+
XRSpatialRay,
|
|
68
|
+
XRScreenPointer,
|
|
69
|
+
XRInteractionIntent,
|
|
70
|
+
XRSurfaceHit,
|
|
71
|
+
XRSurfacePickingOptions
|
|
72
|
+
} from './types';
|