@tangram.gl/tangram-renderer 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 +59 -2
- package/dist/core.js +36923 -0
- package/dist/core.js.map +6 -0
- package/dist/index.js +32 -0
- package/dist/loaders-gl-worker.js +82 -0
- package/dist/loaders-gl-worker.js.map +6 -0
- package/dist/map-logic.js +2 -0
- package/dist/map-logic.js.map +6 -0
- package/dist/projected-basemaps-worker.js +3 -0
- package/dist/projected-basemaps-worker.js.map +6 -0
- package/dist/shader-modules.js +141 -0
- package/dist/shader-modules.js.map +6 -0
- package/dist/style-schema.js +126 -0
- package/dist/style-schema.js.map +6 -0
- package/dist/tangram-style.schema.json +882 -0
- package/dist/tangram.debug.mjs +37139 -0
- package/dist/tangram.debug.mjs.map +6 -0
- package/dist/tangram.min.mjs +4950 -0
- package/dist/types/builders/earcut.d.ts +15 -0
- package/dist/types/core.d.ts +29 -0
- package/dist/types/gl/constants.d.ts +2 -0
- package/dist/types/gl/globe_mesh.d.ts +48 -0
- package/dist/types/gl/glsl.d.ts +17 -0
- package/dist/types/gl/vertex-constants.d.ts +4 -0
- package/dist/types/gl/vertex-types.d.ts +34 -0
- package/dist/types/gl/vertex_data.d.ts +32 -0
- package/dist/types/gl/vertex_elements.d.ts +8 -0
- package/dist/types/gl/vertex_layout.d.ts +32 -0
- package/dist/types/glsl.d.ts +14 -0
- package/dist/types/gpu/luma_device_renderer.d.ts +66 -0
- package/dist/types/gpu/resource_lifecycle.d.ts +4 -0
- package/dist/types/gpu/tangram_gpu_backend.d.ts +151 -0
- package/dist/types/index.d.ts +155 -0
- package/dist/types/lights/light-definitions.d.ts +121 -0
- package/dist/types/map-logic/draped-terrain-bounds.d.ts +41 -0
- package/dist/types/map-logic/globe-terrain-preparation.d.ts +85 -0
- package/dist/types/map-logic/globe-terrain.d.ts +54 -0
- package/dist/types/map-logic/index.d.ts +33 -0
- package/dist/types/map-logic/label-identity.d.ts +22 -0
- package/dist/types/map-logic/label-placement.d.ts +32 -0
- package/dist/types/map-logic/screen-bounds.d.ts +15 -0
- package/dist/types/map-logic/terrain-data.d.ts +55 -0
- package/dist/types/map-logic/terrain-error.d.ts +51 -0
- package/dist/types/map-logic/terrain-ground-topology.d.ts +26 -0
- package/dist/types/map-logic/terrain-height-field.d.ts +59 -0
- package/dist/types/map-logic/terrain-navigation.d.ts +27 -0
- package/dist/types/map-logic/terrain-neighborhood.d.ts +5 -0
- package/dist/types/map-logic/terrain-query.d.ts +33 -0
- package/dist/types/map-logic/terrain-resource-budget.d.ts +51 -0
- package/dist/types/map-logic/terrain-transition-topology.d.ts +23 -0
- package/dist/types/map-logic/tile-build-queue.d.ts +51 -0
- package/dist/types/map-logic/tile-cache-policy.d.ts +46 -0
- package/dist/types/map-logic/tile-residency.d.ts +17 -0
- package/dist/types/map-logic/triangle-query-index.d.ts +87 -0
- package/dist/types/procedures/js-yaml.d.ts +15 -0
- package/dist/types/procedures/mesh-projector.d.ts +107 -0
- package/dist/types/procedures/mvt-dependencies.d.ts +20 -0
- package/dist/types/procedures/projected-coordinate-transform.d.ts +27 -0
- package/dist/types/procedures/projection-batch-executor.d.ts +53 -0
- package/dist/types/procedures/scene-yaml-legacy.d.ts +6 -0
- package/dist/types/procedures/terrain-draping.d.ts +165 -0
- package/dist/types/procedures/terrain-globe.d.ts +6 -0
- package/dist/types/procedures/terrain-styled-topology.d.ts +34 -0
- package/dist/types/procedures/web-mercator-legacy.d.ts +4 -0
- package/dist/types/procedures/web-mercator-math.d.ts +4 -0
- package/dist/types/scene/globals.d.ts +11 -0
- package/dist/types/scene/ground_footprint.d.ts +16 -0
- package/dist/types/scene/host_frame.d.ts +54 -0
- package/dist/types/scene/projection_math.d.ts +52 -0
- package/dist/types/scene/scene-resource-types.d.ts +177 -0
- package/dist/types/scene/scene_bundle.d.ts +69 -0
- package/dist/types/scene/visibility_adapter.d.ts +106 -0
- package/dist/types/selection/draped-terrain-surface.d.ts +78 -0
- package/dist/types/selection/terrain_surface.d.ts +149 -0
- package/dist/types/shader-modules/globe-horizon.d.ts +15 -0
- package/dist/types/shader-modules/height-decode.d.ts +13 -0
- package/dist/types/shader-modules/hillshade.d.ts +13 -0
- package/dist/types/shader-modules/index.d.ts +6 -0
- package/dist/types/shader-modules/planar.d.ts +12 -0
- package/dist/types/shader-modules/sphere-map.d.ts +13 -0
- package/dist/types/shader-modules/triplanar.d.ts +15 -0
- package/dist/types/sources/decoded_tile_store.d.ts +95 -0
- package/dist/types/sources/tile_source_metadata.d.ts +32 -0
- package/dist/types/styles/text/fontfaceobserver.d.ts +10 -0
- package/dist/types/tile/tile_id.d.ts +37 -0
- package/dist/types/tile/tile_traversal_adapter.d.ts +63 -0
- package/dist/types/types.d.ts +293 -0
- package/dist/types/utils/geo.d.ts +57 -0
- package/dist/types/utils/hash.d.ts +1 -0
- package/dist/types/utils/log.d.ts +27 -0
- package/dist/types/utils/props.d.ts +6 -0
- package/dist/types/utils/subscribe.d.ts +10 -0
- package/dist/types/utils/thread.d.ts +6 -0
- package/dist/types/utils/urls.d.ts +26 -0
- package/dist/types/utils/utils.d.ts +26 -0
- package/dist/types/utils/version.d.ts +2 -0
- package/dist/types/utils/worker-types.d.ts +68 -0
- package/dist/types/utils/worker_broker.d.ts +3 -0
- package/package.json +103 -3
- package/src/builders/common.ts +41 -0
- package/src/builders/earcut.d.ts +15 -0
- package/src/builders/geometry-types.ts +60 -0
- package/src/builders/points.ts +115 -0
- package/src/builders/polygons.ts +225 -0
- package/src/builders/polylines.ts +682 -0
- package/src/builders/wireframe.ts +31 -0
- package/src/core.d.ts +29 -0
- package/src/core.ts +33 -0
- package/src/experimental/loaders-gl-worker.ts +19 -0
- package/src/experimental/projected-basemaps-worker.ts +13 -0
- package/src/experimental/projected-mesh-cache.ts +137 -0
- package/src/experimental/projected-mesh-domain.ts +110 -0
- package/src/experimental/projected-mesh-refinement.ts +193 -0
- package/src/experimental/projected-mesh.ts +399 -0
- package/src/experimental/projected-normal.ts +26 -0
- package/src/experimental/projected-symbol-mesh.ts +74 -0
- package/src/gl/constants.ts +18 -0
- package/src/gl/context.ts +103 -0
- package/src/gl/extensions.ts +30 -0
- package/src/gl/globe_mesh.ts +285 -0
- package/src/gl/glsl.ts +257 -0
- package/src/gl/mesh-types.ts +56 -0
- package/src/gl/render_state.ts +111 -0
- package/src/gl/shader_program.ts +1310 -0
- package/src/gl/texture.ts +814 -0
- package/src/gl/uniform_buffer.ts +340 -0
- package/src/gl/vao.ts +154 -0
- package/src/gl/vbo_mesh.ts +349 -0
- package/src/gl/vertex-constants.ts +19 -0
- package/src/gl/vertex-types.ts +35 -0
- package/src/gl/vertex_data.ts +126 -0
- package/src/gl/vertex_elements.ts +54 -0
- package/src/gl/vertex_layout.ts +241 -0
- package/src/glsl.d.ts +14 -0
- package/src/gpu/luma_device_renderer.ts +579 -0
- package/src/gpu/pixel_readback.ts +51 -0
- package/src/gpu/resource_lifecycle.ts +32 -0
- package/src/gpu/tangram_gpu_backend.ts +195 -0
- package/src/index.d.ts +155 -0
- package/src/index.ts +105 -0
- package/src/labels/collision-types.ts +94 -0
- package/src/labels/collision.ts +273 -0
- package/src/labels/collision_grid.ts +61 -0
- package/src/labels/intersect.ts +30 -0
- package/src/labels/label-types.ts +91 -0
- package/src/labels/label.ts +207 -0
- package/src/labels/label_line.ts +801 -0
- package/src/labels/label_point.ts +131 -0
- package/src/labels/main-pass-types.ts +73 -0
- package/src/labels/main_pass.ts +221 -0
- package/src/labels/point_anchor.ts +100 -0
- package/src/labels/point_placement.ts +150 -0
- package/src/labels/projected-pass.ts +259 -0
- package/src/labels/repeat_group.ts +72 -0
- package/src/lights/ambient_light.glsl +18 -0
- package/src/lights/directional_light.glsl +39 -0
- package/src/lights/geographic-lights.ts +21 -0
- package/src/lights/light-definitions.ts +285 -0
- package/src/lights/light.ts +590 -0
- package/src/lights/lighting-uniforms.ts +80 -0
- package/src/lights/lighting-wgsl.ts +116 -0
- package/src/lights/material.glsl +237 -0
- package/src/lights/material.ts +145 -0
- package/src/lights/native-falloff.ts +37 -0
- package/src/lights/point_light.glsl +96 -0
- package/src/lights/spot_light.glsl +111 -0
- package/src/map-logic/draped-terrain-bounds.ts +71 -0
- package/src/map-logic/globe-terrain-preparation.ts +217 -0
- package/src/map-logic/globe-terrain.ts +296 -0
- package/src/map-logic/index.ts +37 -0
- package/src/map-logic/label-identity.ts +36 -0
- package/src/map-logic/label-placement.ts +117 -0
- package/src/map-logic/screen-bounds.ts +34 -0
- package/src/map-logic/terrain-data.ts +107 -0
- package/src/map-logic/terrain-error.ts +134 -0
- package/src/map-logic/terrain-ground-topology.ts +164 -0
- package/src/map-logic/terrain-height-field.ts +254 -0
- package/src/map-logic/terrain-navigation.ts +50 -0
- package/src/map-logic/terrain-neighborhood.ts +43 -0
- package/src/map-logic/terrain-query.ts +51 -0
- package/src/map-logic/terrain-resource-budget.ts +124 -0
- package/src/map-logic/terrain-transition-topology.ts +164 -0
- package/src/map-logic/tile-build-queue.ts +110 -0
- package/src/map-logic/tile-cache-policy.ts +89 -0
- package/src/map-logic/tile-residency.ts +50 -0
- package/src/map-logic/triangle-query-index.ts +265 -0
- package/src/procedures/js-yaml.d.ts +15 -0
- package/src/procedures/mesh-projector.ts +191 -0
- package/src/procedures/mlt-loaders.ts +55 -0
- package/src/procedures/mvt-dependencies.d.ts +20 -0
- package/src/procedures/mvt-legacy.ts +150 -0
- package/src/procedures/mvt-loaders.ts +53 -0
- package/src/procedures/mvt-parser.ts +48 -0
- package/src/procedures/mvt-properties.ts +36 -0
- package/src/procedures/mvt-tile-provider.ts +72 -0
- package/src/procedures/pmtiles-loader.ts +75 -0
- package/src/procedures/projected-coordinate-transform.ts +79 -0
- package/src/procedures/projection-batch-executor.ts +140 -0
- package/src/procedures/scene-yaml-legacy.ts +16 -0
- package/src/procedures/scene-yaml-loaders.ts +10 -0
- package/src/procedures/terrain-draping.ts +713 -0
- package/src/procedures/terrain-globe.ts +32 -0
- package/src/procedures/terrain-styled-topology.ts +184 -0
- package/src/procedures/web-mercator-legacy.ts +24 -0
- package/src/procedures/web-mercator-math.ts +50 -0
- package/src/scene/camera.ts +298 -0
- package/src/scene/camera_base.ts +116 -0
- package/src/scene/classic_scene.ts +20 -0
- package/src/scene/external_camera.ts +101 -0
- package/src/scene/globals.ts +120 -0
- package/src/scene/ground_footprint.ts +139 -0
- package/src/scene/host_frame.ts +326 -0
- package/src/scene/projected_tile_lod.ts +125 -0
- package/src/scene/projection_math.ts +142 -0
- package/src/scene/projection_shaders.ts +183 -0
- package/src/scene/renderer.ts +516 -0
- package/src/scene/scene-resource-types.ts +174 -0
- package/src/scene/scene.ts +2378 -0
- package/src/scene/scene_bundle.ts +304 -0
- package/src/scene/scene_debug.ts +129 -0
- package/src/scene/scene_loader.ts +402 -0
- package/src/scene/scene_worker.ts +528 -0
- package/src/scene/view.ts +683 -0
- package/src/scene/visibility_adapter.ts +319 -0
- package/src/scene/worker_url.ts +15 -0
- package/src/selection/draped-terrain-surface.ts +377 -0
- package/src/selection/selection-types.ts +79 -0
- package/src/selection/selection.ts +542 -0
- package/src/selection/selection_fragment.glsl +18 -0
- package/src/selection/selection_globals.glsl +9 -0
- package/src/selection/selection_pixels.ts +59 -0
- package/src/selection/selection_vertex.glsl +15 -0
- package/src/selection/terrain_surface.ts +298 -0
- package/src/shader-modules/globe-horizon.ts +52 -0
- package/src/shader-modules/height-decode.ts +41 -0
- package/src/shader-modules/hillshade.ts +70 -0
- package/src/shader-modules/index.ts +10 -0
- package/src/shader-modules/planar.ts +24 -0
- package/src/shader-modules/sphere-map.ts +38 -0
- package/src/shader-modules/triplanar.ts +45 -0
- package/src/sources/aligned_tile_source.ts +122 -0
- package/src/sources/data_source.ts +655 -0
- package/src/sources/decoded_tile_store.ts +245 -0
- package/src/sources/geojson.ts +300 -0
- package/src/sources/mvt.ts +247 -0
- package/src/sources/raster.ts +341 -0
- package/src/sources/shared_tile_source_adapter.ts +117 -0
- package/src/sources/sources.ts +10 -0
- package/src/sources/tile_source_adapter.ts +84 -0
- package/src/sources/tile_source_metadata.ts +65 -0
- package/src/sources/tile_source_state.ts +112 -0
- package/src/sources/topojson.ts +90 -0
- package/src/styles/feature_annotations.ts +16 -0
- package/src/styles/filter.ts +190 -0
- package/src/styles/geometry-style-types.ts +209 -0
- package/src/styles/globe_visibility_wgsl.ts +22 -0
- package/src/styles/layer-types.ts +45 -0
- package/src/styles/layer.ts +586 -0
- package/src/styles/lines/dasharray.ts +49 -0
- package/src/styles/lines/lines.ts +707 -0
- package/src/styles/lines/lines_wgsl.ts +224 -0
- package/src/styles/points/point-runtime-types.ts +125 -0
- package/src/styles/points/points.ts +1079 -0
- package/src/styles/points/points_fragment.glsl +126 -0
- package/src/styles/points/points_vertex.glsl +230 -0
- package/src/styles/points/points_wgsl.ts +189 -0
- package/src/styles/polygons/polygons.ts +268 -0
- package/src/styles/polygons/polygons_fragment.glsl +173 -0
- package/src/styles/polygons/polygons_vertex.glsl +213 -0
- package/src/styles/polygons/polygons_wgsl.ts +127 -0
- package/src/styles/property-types.ts +125 -0
- package/src/styles/raster/raster.ts +33 -0
- package/src/styles/raster/raster_globals.glsl +46 -0
- package/src/styles/style-mixing-types.ts +101 -0
- package/src/styles/style-runtime-types.ts +220 -0
- package/src/styles/style-schema.ts +133 -0
- package/src/styles/style.ts +1033 -0
- package/src/styles/style_globals.glsl +69 -0
- package/src/styles/style_manager.ts +464 -0
- package/src/styles/style_parser.ts +620 -0
- package/src/styles/text/font_manager.ts +180 -0
- package/src/styles/text/fontfaceobserver.d.ts +10 -0
- package/src/styles/text/text-types.ts +220 -0
- package/src/styles/text/text.ts +378 -0
- package/src/styles/text/text_canvas.ts +773 -0
- package/src/styles/text/text_labels.ts +436 -0
- package/src/styles/text/text_segments.ts +142 -0
- package/src/styles/text/text_settings.ts +228 -0
- package/src/styles/text/text_wgsl.ts +159 -0
- package/src/styles/text/text_wrap.ts +194 -0
- package/src/tile/globe_tile_preload.ts +40 -0
- package/src/tile/tangram_tileset_2d.ts +108 -0
- package/src/tile/tile-build-types.ts +155 -0
- package/src/tile/tile.ts +807 -0
- package/src/tile/tile_id.ts +172 -0
- package/src/tile/tile_manager.ts +889 -0
- package/src/tile/tile_pyramid.ts +119 -0
- package/src/tile/tile_resource_cache.ts +91 -0
- package/src/tile/tile_traversal_adapter.ts +132 -0
- package/src/types.ts +332 -0
- package/src/utils/debounce.ts +17 -0
- package/src/utils/debug_settings.ts +43 -0
- package/src/utils/errors.ts +12 -0
- package/src/utils/functions.ts +95 -0
- package/src/utils/geo.ts +364 -0
- package/src/utils/hash.ts +14 -0
- package/src/utils/log.ts +108 -0
- package/src/utils/media_capture.ts +193 -0
- package/src/utils/merge.ts +44 -0
- package/src/utils/obb.ts +134 -0
- package/src/utils/props.ts +43 -0
- package/src/utils/slice.ts +14 -0
- package/src/utils/subscribe.ts +58 -0
- package/src/utils/task.ts +154 -0
- package/src/utils/thread.ts +26 -0
- package/src/utils/urls.ts +223 -0
- package/src/utils/utils.ts +310 -0
- package/src/utils/vector.ts +197 -0
- package/src/utils/version.ts +8 -0
- package/src/utils/worker-types.ts +79 -0
- package/src/utils/worker_broker.ts +523 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import type {TerrainHeightGrid} from './terrain-data';
|
|
6
|
+
|
|
7
|
+
/** Immutable DEM-derived envelope; it says nothing about unloaded finer source detail. */
|
|
8
|
+
export interface TerrainErrorEnvelope {
|
|
9
|
+
/** Lowest and highest valid physical source elevations. */
|
|
10
|
+
readonly minimumHeight: number;
|
|
11
|
+
readonly maximumHeight: number;
|
|
12
|
+
/** Conservative directional bilinear gradient bounds per Mercator meter. */
|
|
13
|
+
readonly eastSlope: number;
|
|
14
|
+
readonly northSlope: number;
|
|
15
|
+
/** Mercator footprint width and height. */
|
|
16
|
+
readonly width: number;
|
|
17
|
+
readonly height: number;
|
|
18
|
+
/** Invalid samples prevent certification of the complete footprint. */
|
|
19
|
+
readonly complete: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Cache by immutable grid identity; camera movement must not rescan decoded samples. */
|
|
23
|
+
const envelopes = new WeakMap<TerrainHeightGrid, TerrainErrorEnvelope>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Conservative CSS-pixel Lipschitz bound for a projective transform over an
|
|
27
|
+
* absolute XYZ box. Null means the box crosses the eye plane: never substitute
|
|
28
|
+
* a center-only derivative and call it a bounded screen error.
|
|
29
|
+
*/
|
|
30
|
+
export function boundProjectedDisplacement(matrix: readonly number[], minimum: readonly [number, number, number], maximum: readonly [number, number, number],
|
|
31
|
+
viewport: {width: number; height: number}): number | null {
|
|
32
|
+
if (matrix.length !== 16 || ![...matrix, ...minimum, ...maximum, viewport.width, viewport.height].every(Number.isFinite) ||
|
|
33
|
+
minimum.some((value, axis) => value > maximum[axis]) || viewport.width <= 0 || viewport.height <= 0) throw new Error('Invalid terrain projection bound');
|
|
34
|
+
let minimumW = Infinity, horizontal = 0, vertical = 0;
|
|
35
|
+
for (let corner = 0; corner < 8; corner++) {
|
|
36
|
+
const position = minimum.map((value, axis) => corner & 1 << axis ? maximum[axis] : value);
|
|
37
|
+
const component = (row: number) => matrix[row] * position[0] + matrix[row + 4] * position[1] + matrix[row + 8] * position[2] + matrix[row + 12];
|
|
38
|
+
minimumW = Math.min(minimumW, component(3));
|
|
39
|
+
horizontal = Math.max(horizontal, Math.abs(component(0))); vertical = Math.max(vertical, Math.abs(component(1)));
|
|
40
|
+
}
|
|
41
|
+
if (minimumW <= 0) return null;
|
|
42
|
+
const norm = (row: number) => Math.hypot(matrix[row], matrix[row + 4], matrix[row + 8]);
|
|
43
|
+
const result = Math.hypot(viewport.width / 2 * (norm(0) / minimumW + horizontal / minimumW * norm(3) / minimumW),
|
|
44
|
+
viewport.height / 2 * (norm(1) / minimumW + vertical / minimumW * norm(3) / minimumW));
|
|
45
|
+
return Number.isFinite(result) ? result : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Measure source extrema and adjacent gradients once, retaining no additional sample arrays. */
|
|
49
|
+
export function measureTerrainError(grid: TerrainHeightGrid): TerrainErrorEnvelope {
|
|
50
|
+
const cached = envelopes.get(grid);
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
const width = grid.bounds[2] - grid.bounds[0], height = grid.bounds[3] - grid.bounds[1];
|
|
53
|
+
if (!Number.isSafeInteger(grid.width) || !Number.isSafeInteger(grid.height) || grid.width < 2 || grid.height < 2 ||
|
|
54
|
+
grid.heights.length !== grid.width * grid.height || grid.valid.length !== grid.heights.length || grid.bounds.length !== 4 || !grid.bounds.every(Number.isFinite) ||
|
|
55
|
+
!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || !['grid-node', 'pixel-center'].includes(grid.registration)) throw new Error('Invalid terrain error grid');
|
|
56
|
+
const horizontalSpacing = width / (grid.registration === 'pixel-center' ? grid.width : grid.width - 1);
|
|
57
|
+
const verticalSpacing = height / (grid.registration === 'pixel-center' ? grid.height : grid.height - 1);
|
|
58
|
+
let minimumHeight = Infinity, maximumHeight = -Infinity, eastSlope = 0, northSlope = 0, complete = true;
|
|
59
|
+
for (let index = 0; index < grid.heights.length; index++) {
|
|
60
|
+
if (!grid.valid[index]) {complete = false; continue;}
|
|
61
|
+
if (!Number.isFinite(grid.heights[index])) throw new Error('Terrain error envelope requires finite valid heights');
|
|
62
|
+
const value = grid.heights[index];
|
|
63
|
+
minimumHeight = Math.min(minimumHeight, value); maximumHeight = Math.max(maximumHeight, value);
|
|
64
|
+
if (index % grid.width && grid.valid[index - 1]) eastSlope = Math.max(eastSlope, Math.abs(value - grid.heights[index - 1]) / horizontalSpacing);
|
|
65
|
+
if (index >= grid.width && grid.valid[index - grid.width]) northSlope = Math.max(northSlope, Math.abs(value - grid.heights[index - grid.width]) / verticalSpacing);
|
|
66
|
+
}
|
|
67
|
+
if (minimumHeight === Infinity) {minimumHeight = 0; maximumHeight = 0;}
|
|
68
|
+
if (![minimumHeight, maximumHeight, eastSlope, northSlope].every(Number.isFinite)) throw new Error('Terrain error envelope exceeds finite range');
|
|
69
|
+
const envelope = Object.freeze({minimumHeight, maximumHeight, eastSlope, northSlope, width, height, complete});
|
|
70
|
+
envelopes.set(grid, envelope); return envelope;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bound vertical interpolation error by the bilinear field's Lipschitz envelope.
|
|
75
|
+
* Deliberately conservative on ramps: this is not a fitted/sample-only RMSE.
|
|
76
|
+
* A separate spherical sagitta covers surface curvature, in globe-radius units.
|
|
77
|
+
*/
|
|
78
|
+
export function boundTerrainMeshError(envelope: TerrainErrorEnvelope, resolution: number): {heightError: number; globeCurvatureError: number} {
|
|
79
|
+
if (!Number.isSafeInteger(resolution) || resolution < 2 || resolution > 64 || (resolution & (resolution - 1)) ||
|
|
80
|
+
![envelope.width, envelope.height, envelope.minimumHeight, envelope.maximumHeight, envelope.eastSlope, envelope.northSlope].every(Number.isFinite) ||
|
|
81
|
+
envelope.width <= 0 || envelope.height <= 0 || envelope.maximumHeight < envelope.minimumHeight || envelope.eastSlope < 0 || envelope.northSlope < 0) throw new Error('Invalid terrain error policy');
|
|
82
|
+
const heightError = Math.min(envelope.maximumHeight - envelope.minimumHeight,
|
|
83
|
+
(envelope.eastSlope * envelope.width + envelope.northSlope * envelope.height) / resolution);
|
|
84
|
+
// Mercator's inverse derivative bounds angular change by Mercator distance/R.
|
|
85
|
+
const angularDiameter = Math.min(Math.PI, (envelope.width + envelope.height) / (6378137 * resolution));
|
|
86
|
+
// Any chord point is within R*(1-cos(diameter)) of the corresponding radial sphere.
|
|
87
|
+
const globeCurvatureError = 256 * 2 * Math.sin(angularDiameter / 2) ** 2;
|
|
88
|
+
return {heightError, globeCurvatureError};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Select a dyadic resolution with hysteresis from a caller's conservative pixels-per-unit bounds. */
|
|
92
|
+
export function selectTerrainMeshResolution(grids: readonly TerrainHeightGrid[], options: {
|
|
93
|
+
/** Maximum acceptable interpolation-plus-curvature error, CSS pixels; default 2. */
|
|
94
|
+
targetPixels?: number;
|
|
95
|
+
/** Upper bound on projected displacement per physical vertical meter, including exaggeration. */
|
|
96
|
+
pixelsPerMeter: number;
|
|
97
|
+
/** Upper bound on projected displacement per radius-256 globe unit; zero for a planar view. */
|
|
98
|
+
pixelsPerGlobeUnit?: number;
|
|
99
|
+
/** Prior dyadic resolution supplies a 20% coarsening deadband. */
|
|
100
|
+
previousResolution?: number;
|
|
101
|
+
}): {resolution: number; projectedError: number; limited: boolean; complete: boolean} {
|
|
102
|
+
const target = options.targetPixels ?? 2, globeScale = options.pixelsPerGlobeUnit ?? 0;
|
|
103
|
+
if (![target, options.pixelsPerMeter, globeScale].every(Number.isFinite) || target <= 0 || options.pixelsPerMeter < 0 || globeScale < 0) throw new Error('Invalid projected terrain error scale');
|
|
104
|
+
const envelopes = grids.map(measureTerrainError);
|
|
105
|
+
// Canonical terrain averages touching-edge samples. Independent flat tiles
|
|
106
|
+
// with different heights therefore cannot claim zero reconstruction error.
|
|
107
|
+
// A range envelope is conservative until tighter edge-conformance metadata
|
|
108
|
+
// is available; never hide that uncertainty behind a smaller mesh cell.
|
|
109
|
+
const seamErrors = grids.map((grid, index) => {
|
|
110
|
+
let error = 0;
|
|
111
|
+
for (let other = 0; other < grids.length; other++) {
|
|
112
|
+
if (other === index) continue;
|
|
113
|
+
const neighbor = grids[other], circumference = 2 * Math.PI * 6378137;
|
|
114
|
+
const tolerance = Math.max(envelopes[index].width, envelopes[other].width) * 1e-10;
|
|
115
|
+
if ([-circumference, 0, circumference].some(shift => {
|
|
116
|
+
const horizontal = Math.min(grid.bounds[2], neighbor.bounds[2] + shift) - Math.max(grid.bounds[0], neighbor.bounds[0] + shift);
|
|
117
|
+
const vertical = Math.min(grid.bounds[3], neighbor.bounds[3]) - Math.max(grid.bounds[1], neighbor.bounds[1]);
|
|
118
|
+
return horizontal >= -tolerance && vertical >= -tolerance;
|
|
119
|
+
})) error = Math.max(error, Math.abs(envelopes[index].maximumHeight - envelopes[other].minimumHeight), Math.abs(envelopes[other].maximumHeight - envelopes[index].minimumHeight));
|
|
120
|
+
}
|
|
121
|
+
return error;
|
|
122
|
+
});
|
|
123
|
+
const errorAt = (resolution: number, includeSeams = false) => Math.max(0, ...envelopes.map((envelope, index) => {
|
|
124
|
+
const error = boundTerrainMeshError(envelope, resolution);
|
|
125
|
+
return Math.max(error.heightError, includeSeams ? seamErrors[index] : 0) * options.pixelsPerMeter + error.globeCurvatureError * globeScale;
|
|
126
|
+
}));
|
|
127
|
+
let resolution = options.previousResolution ?? 2;
|
|
128
|
+
boundTerrainMeshError({minimumHeight: 0, maximumHeight: 0, eastSlope: 0, northSlope: 0, width: 1, height: 1, complete: true}, resolution);
|
|
129
|
+
while (resolution < 64 && errorAt(resolution) > target) resolution *= 2;
|
|
130
|
+
while (resolution > 2 && errorAt(resolution / 2) < target * 0.8) resolution /= 2;
|
|
131
|
+
// More vertices cannot reduce a conservative shared-edge uncertainty bound.
|
|
132
|
+
const projectedError = errorAt(resolution, true), complete = envelopes.every(envelope => envelope.complete);
|
|
133
|
+
return {resolution, projectedError, limited: projectedError > target || !complete, complete};
|
|
134
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import type {TerrainHeightGrid} from './terrain-data';
|
|
6
|
+
import type {TerrainFieldBounds} from './terrain-height-field';
|
|
7
|
+
|
|
8
|
+
const CIRCUMFERENCE = 2 * Math.PI * 6378137;
|
|
9
|
+
/** Immutable field identities own one validated periodic patch neighborhood, not generated mesh buffers. */
|
|
10
|
+
const patchPlans = new WeakMap<readonly TerrainHeightGrid[], readonly TerrainFieldBounds[]>();
|
|
11
|
+
|
|
12
|
+
/** Source-independent triangles; tile ownership is clipped after projection, never by moving boundary nodes. */
|
|
13
|
+
export interface TerrainGroundTopology {
|
|
14
|
+
/** Absolute unwrapped EPSG:3857 XY, two components per vertex. */
|
|
15
|
+
positions: Float64Array;
|
|
16
|
+
/** Exact dyadic world fractions: X from the western world edge, Y southward from the northern edge. */
|
|
17
|
+
normalizedPositions: Float64Array;
|
|
18
|
+
/** Exact periodic sampling XY before whole-world translation; may share the positions array in world zero. */
|
|
19
|
+
samplePositions: Float64Array;
|
|
20
|
+
/** Counterclockwise top triangles, including conforming coarse/fine edge nodes. */
|
|
21
|
+
indices: Uint32Array;
|
|
22
|
+
/** Subtract this whole-world X offset before sampling the periodic DEM neighborhood. */
|
|
23
|
+
sampleOffset: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Include every touching neighbor's edge knots on a patch boundary, preserving traversal order. */
|
|
27
|
+
export function getTerrainEdgeNodes(bounds: TerrainFieldBounds, neighbors: readonly TerrainFieldBounds[], resolution: number,
|
|
28
|
+
start: readonly [number, number], end: readonly [number, number]): [number, number][] {
|
|
29
|
+
const [west, south, east, north] = bounds;
|
|
30
|
+
const vertical = start[0] === end[0], axis = vertical ? 1 : 0;
|
|
31
|
+
if (!(vertical && (start[0] === 0 || start[0] === resolution) || start[1] === end[1] && (start[1] === 0 || start[1] === resolution))) return [];
|
|
32
|
+
const tolerance = Math.max(east - west, north - south) * 1e-10;
|
|
33
|
+
const fixed = vertical ? west + start[0] / resolution * (east - west) : north - start[1] / resolution * (north - south);
|
|
34
|
+
const values = new Set<number>();
|
|
35
|
+
for (const candidate of neighbors) {
|
|
36
|
+
if (vertical ? Math.min(Math.abs(fixed - candidate[0]), Math.abs(fixed - candidate[2])) > tolerance : Math.min(Math.abs(fixed - candidate[1]), Math.abs(fixed - candidate[3])) > tolerance) continue;
|
|
37
|
+
for (let step = 0; step <= resolution; step++) {
|
|
38
|
+
const value = vertical ? (north - (candidate[3] - step / resolution * (candidate[3] - candidate[1]))) / (north - south) * resolution :
|
|
39
|
+
(candidate[0] + step / resolution * (candidate[2] - candidate[0]) - west) / (east - west) * resolution;
|
|
40
|
+
if (value > Math.min(start[axis], end[axis]) + 1e-8 && value < Math.max(start[axis], end[axis]) - 1e-8) values.add(Math.round(value * 1e8) / 1e8);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...values].sort((first, second) => (first - second) * Math.sign(end[axis] - start[axis])).map(value => vertical ? [start[0], value] : [value, start[1]]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Snap a decoded tile footprint to one world lattice; opt-in ground topology requires square quadtree tiles. */
|
|
47
|
+
function snapTileBounds(bounds: TerrainFieldBounds, normalizeToPrimaryWorld = false): TerrainFieldBounds {
|
|
48
|
+
if (bounds.length !== 4) throw new Error('Canonical terrain ground requires quadtree DEM footprints');
|
|
49
|
+
const edge = bounds[2] - bounds[0], zoom = Math.round(Math.log2(CIRCUMFERENCE / edge));
|
|
50
|
+
if (!Number.isSafeInteger(zoom) || zoom < 0 || zoom > 30) throw new Error('Canonical terrain ground requires quadtree DEM footprints');
|
|
51
|
+
const span = CIRCUMFERENCE / 2 ** zoom;
|
|
52
|
+
const column = Math.round((bounds[0] + CIRCUMFERENCE / 2) / span), row = Math.round((CIRCUMFERENCE / 2 - bounds[3]) / span);
|
|
53
|
+
const snapped: TerrainFieldBounds = [column * span - CIRCUMFERENCE / 2, CIRCUMFERENCE / 2 - (row + 1) * span,
|
|
54
|
+
(column + 1) * span - CIRCUMFERENCE / 2, CIRCUMFERENCE / 2 - row * span];
|
|
55
|
+
if (row < 0 || row >= 2 ** zoom || bounds.some((value, index) => !Number.isFinite(value) || Math.abs(value - snapped[index]) > Math.max(1e-6, span * 1e-8))) throw new Error('Canonical terrain ground requires quadtree DEM footprints');
|
|
56
|
+
if (normalizeToPrimaryWorld) {
|
|
57
|
+
const normalizedColumn = ((column % 2 ** zoom) + 2 ** zoom) % 2 ** zoom;
|
|
58
|
+
return [normalizedColumn * span - CIRCUMFERENCE / 2, snapped[1], (normalizedColumn + 1) * span - CIRCUMFERENCE / 2, snapped[3]];
|
|
59
|
+
}
|
|
60
|
+
return snapped;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Retain every neighbor, including those outside a source clip whose knots affect an intersecting cell. */
|
|
64
|
+
function getPatchPlan(grids: readonly TerrainHeightGrid[]): readonly TerrainFieldBounds[] {
|
|
65
|
+
const cached = patchPlans.get(grids);
|
|
66
|
+
if (cached) return cached;
|
|
67
|
+
const patches = grids.flatMap(grid => {
|
|
68
|
+
const bounds = snapTileBounds(grid.bounds, true);
|
|
69
|
+
return [-1, 0, 1].map(world => [bounds[0] + world * CIRCUMFERENCE, bounds[1], bounds[2] + world * CIRCUMFERENCE, bounds[3]] as const);
|
|
70
|
+
});
|
|
71
|
+
for (let index = 0; index < patches.length; index++) for (let previous = 0; previous < index; previous++) {
|
|
72
|
+
const patch = patches[index], other = patches[previous], tolerance = Math.max(patch[2] - patch[0], other[2] - other[0]) * 1e-10;
|
|
73
|
+
const horizontal = Math.min(patch[2], other[2]) - Math.max(patch[0], other[0]);
|
|
74
|
+
const vertical = Math.min(patch[3], other[3]) - Math.max(patch[1], other[1]);
|
|
75
|
+
if (horizontal > tolerance && vertical > tolerance) throw new Error('Canonical terrain ground footprints overlap');
|
|
76
|
+
if (horizontal > tolerance && vertical >= -tolerance || vertical > tolerance && horizontal >= -tolerance) {
|
|
77
|
+
const ratio = (patch[2] - patch[0]) / (other[2] - other[0]);
|
|
78
|
+
if (![0.5, 1, 2].some(value => Math.abs(value - ratio) < 1e-8)) throw new Error('Canonical terrain ground requires balanced 2:1 edges');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
patchPlans.set(grids, patches);
|
|
82
|
+
return patches;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Quadtree copies shift to the sampling world; optional generic previous fields retain their absolute domain. */
|
|
86
|
+
export function wrapTerrainGroundGrids(grids: readonly TerrainHeightGrid[], requireQuadtree = true, samplingWorldOffset = 0): TerrainHeightGrid[] {
|
|
87
|
+
return grids.flatMap(grid => {
|
|
88
|
+
let bounds: TerrainFieldBounds;
|
|
89
|
+
try {bounds = snapTileBounds(grid.bounds, true);}
|
|
90
|
+
catch (error) {if (requireQuadtree) throw error; return [grid];}
|
|
91
|
+
return [-1, 0, 1].map(world => ({...grid, bounds: [bounds[0] + world * CIRCUMFERENCE + samplingWorldOffset, bounds[1], bounds[2] + world * CIRCUMFERENCE + samplingWorldOffset, bounds[3]] as const}));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build complete DEM-owned cells intersecting a source tile. Do not clip triangles
|
|
97
|
+
* at source/fallback edges: both owners must submit the same curved triangle there.
|
|
98
|
+
* Fragment/query half-open clips select ownership. Current fields must have balanced
|
|
99
|
+
* 2:1 quadtree edges; unsupported fields and exhausted budgets reject, never truncate.
|
|
100
|
+
*/
|
|
101
|
+
export function createTerrainGroundTopology(grids: readonly TerrainHeightGrid[], bounds: TerrainFieldBounds,
|
|
102
|
+
resolution = 32, maxVertices = 131072): TerrainGroundTopology {
|
|
103
|
+
if (grids.length > 96 || !Number.isSafeInteger(resolution) || resolution < 2 || resolution > 64 || (resolution & (resolution - 1)) !== 0 ||
|
|
104
|
+
!Number.isSafeInteger(maxVertices) || maxVertices < 0 || bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[0] >= bounds[2] || bounds[1] >= bounds[3] ||
|
|
105
|
+
bounds[2] - bounds[0] > CIRCUMFERENCE * (1 + 1e-10)) throw new Error('Invalid canonical terrain ground limits or bounds');
|
|
106
|
+
const sampleOffset = Math.floor(((bounds[0] + bounds[2]) / 2 + CIRCUMFERENCE / 2) / CIRCUMFERENCE) * CIRCUMFERENCE;
|
|
107
|
+
const localBounds: TerrainFieldBounds = [bounds[0] - sampleOffset, bounds[1], bounds[2] - sampleOffset, bounds[3]];
|
|
108
|
+
const patches = getPatchPlan(grids);
|
|
109
|
+
const positions: number[] = [], normalizedPositions: number[] = [], indices: number[] = [], nodes = new Map<string, number>();
|
|
110
|
+
/** Reuse lattice nodes across DEM patch boundaries and independent source-tile builds. */
|
|
111
|
+
function vertex(patch: TerrainFieldBounds, column: number, row: number): number {
|
|
112
|
+
const zoom = Math.round(Math.log2(CIRCUMFERENCE / (patch[2] - patch[0]))), span = CIRCUMFERENCE / 2 ** zoom;
|
|
113
|
+
const worldColumn = Math.round((patch[0] + CIRCUMFERENCE / 2) / span), worldRow = Math.round((CIRCUMFERENCE / 2 - patch[3]) / span);
|
|
114
|
+
const x = span / resolution * (worldColumn * resolution + column) - CIRCUMFERENCE / 2;
|
|
115
|
+
const y = CIRCUMFERENCE / 2 - span / resolution * (worldRow * resolution + row);
|
|
116
|
+
const key = `${x},${y}`;
|
|
117
|
+
let index = nodes.get(key);
|
|
118
|
+
if (index === undefined) {
|
|
119
|
+
if (nodes.size >= maxVertices) throw new Error('Canonical terrain ground vertex budget exceeded');
|
|
120
|
+
index = nodes.size; nodes.set(key, index); positions.push(x, y);
|
|
121
|
+
normalizedPositions.push((worldColumn + column / resolution) / 2 ** zoom + Math.round(sampleOffset / CIRCUMFERENCE),
|
|
122
|
+
(worldRow + row / resolution) / 2 ** zoom);
|
|
123
|
+
}
|
|
124
|
+
return index;
|
|
125
|
+
}
|
|
126
|
+
/** Preserve the same DEM triangles on both sides of a source ownership boundary. */
|
|
127
|
+
function triangle(patch: TerrainFieldBounds, corners: readonly [number, number][]): void {
|
|
128
|
+
const ring: number[] = [];
|
|
129
|
+
for (let edge = 0; edge < 3; edge++) {
|
|
130
|
+
const start = corners[edge], end = corners[(edge + 1) % 3];
|
|
131
|
+
ring.push(vertex(patch, ...start), ...getTerrainEdgeNodes(patch, patches, resolution, start, end).map(point => vertex(patch, ...point)));
|
|
132
|
+
}
|
|
133
|
+
if (ring.length === 3) indices.push(...ring);
|
|
134
|
+
else {
|
|
135
|
+
// Dyadic interior weights keep local positions exactly representable
|
|
136
|
+
// across independent source zooms; one-third centroids do not.
|
|
137
|
+
const center = vertex(patch, corners[0][0] / 2 + corners[1][0] / 4 + corners[2][0] / 4,
|
|
138
|
+
corners[0][1] / 2 + corners[1][1] / 4 + corners[2][1] / 4);
|
|
139
|
+
for (let edge = 0; edge < ring.length; edge++) indices.push(center, ring[edge], ring[(edge + 1) % ring.length]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const patch of patches) {
|
|
143
|
+
const west = Math.max(localBounds[0], patch[0]), east = Math.min(localBounds[2], patch[2]);
|
|
144
|
+
const south = Math.max(localBounds[1], patch[1]), north = Math.min(localBounds[3], patch[3]);
|
|
145
|
+
if (west >= east || south >= north) continue;
|
|
146
|
+
const cellSpan = (patch[2] - patch[0]) / resolution;
|
|
147
|
+
// Whole-world translation can shift an exact ownership edge by a few
|
|
148
|
+
// double ulps. Snap only within representational error, not a fixed
|
|
149
|
+
// fraction that could erase tiny high-zoom ownership windows.
|
|
150
|
+
const tolerance = Math.max(CIRCUMFERENCE, ...patch.map(Math.abs)) / cellSpan * Number.EPSILON * 4;
|
|
151
|
+
const cell = (value: number) => Math.abs(value - Math.round(value)) <= tolerance ? Math.round(value) : value;
|
|
152
|
+
const firstColumn = Math.max(0, Math.floor(cell((west - patch[0]) / (patch[2] - patch[0]) * resolution)));
|
|
153
|
+
const lastColumn = Math.min(resolution, Math.ceil(cell((east - patch[0]) / (patch[2] - patch[0]) * resolution)));
|
|
154
|
+
const firstRow = Math.max(0, Math.floor(cell((patch[3] - north) / (patch[3] - patch[1]) * resolution)));
|
|
155
|
+
const lastRow = Math.min(resolution, Math.ceil(cell((patch[3] - south) / (patch[3] - patch[1]) * resolution)));
|
|
156
|
+
for (let row = firstRow; row < lastRow; row++) for (let column = firstColumn; column < lastColumn; column++) {
|
|
157
|
+
triangle(patch, [[column, row], [column, row + 1], [column + 1, row]]);
|
|
158
|
+
triangle(patch, [[column + 1, row], [column, row + 1], [column + 1, row + 1]]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const samplePositions = Float64Array.from(positions);
|
|
162
|
+
return {positions: sampleOffset ? Float64Array.from(samplePositions, (value, index) => index % 2 ? value : value + sampleOffset) : samplePositions,
|
|
163
|
+
samplePositions, normalizedPositions: Float64Array.from(normalizedPositions), indices: Uint32Array.from(indices), sampleOffset};
|
|
164
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import {sampleTerrainHeight, type TerrainHeightGrid} from './terrain-data';
|
|
6
|
+
|
|
7
|
+
/** Absolute EPSG:3857 [west, south, east, north], including zero-width probe bounds. */
|
|
8
|
+
export type TerrainFieldBounds = readonly [number, number, number, number];
|
|
9
|
+
|
|
10
|
+
/** Retained mask admission; geometry, source arrays and JavaScript metadata are separate. */
|
|
11
|
+
export interface TerrainHeightFieldOptions {
|
|
12
|
+
/** Maximum retained summed-area mask bytes; default 8 MiB. Zero uses exact sample scans. */
|
|
13
|
+
maxMaskBytes?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Detached operation counts, suitable for deterministic performance tests rather than FPS estimates. */
|
|
17
|
+
export interface TerrainHeightFieldStatistics {
|
|
18
|
+
/** Number of retained immutable source references. */
|
|
19
|
+
grids: number;
|
|
20
|
+
/** Retained summed-area mask bytes, excluding source arrays and tree metadata. */
|
|
21
|
+
maskBytes: number;
|
|
22
|
+
/** Admitted mask constructions, each built at most once per grid. */
|
|
23
|
+
maskBuilds: number;
|
|
24
|
+
/** Validity samples read during mask construction or exact fallback scans. */
|
|
25
|
+
maskSamples: number;
|
|
26
|
+
/** Spatial tree envelopes tested by sampling and coverage queries. */
|
|
27
|
+
boundsTests: number;
|
|
28
|
+
/** Candidate grid footprints visited after tree pruning. */
|
|
29
|
+
candidateGrids: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Private source identity, detached footprint and lazy mask state. */
|
|
33
|
+
interface FieldEntry {
|
|
34
|
+
/** Detached descriptor sharing immutable sample arrays. */
|
|
35
|
+
grid: TerrainHeightGrid;
|
|
36
|
+
/** Original accumulation order, independent of spatial sorting. */
|
|
37
|
+
index: number;
|
|
38
|
+
/** Exact source footprint. */
|
|
39
|
+
bounds: TerrainFieldBounds;
|
|
40
|
+
/** Legacy touching-edge tolerance envelope. */
|
|
41
|
+
sampleBounds: TerrainFieldBounds;
|
|
42
|
+
/** Lazy prefix; null means fully valid, undefined means unprepared/unadmitted. */
|
|
43
|
+
mask?: Uint32Array | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Balanced spatial envelope; leaves contain exactly one source. */
|
|
47
|
+
interface FieldNode {
|
|
48
|
+
/** Closed union of every descendant sampling envelope. */
|
|
49
|
+
bounds: TerrainFieldBounds;
|
|
50
|
+
/** Source owner on a leaf. */
|
|
51
|
+
entry?: FieldEntry;
|
|
52
|
+
/** Median-partitioned child envelopes on an internal node. */
|
|
53
|
+
children?: readonly [FieldNode, FieldNode];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Immutable-source physical height sampling and conservative footprint admission.
|
|
58
|
+
* Snapshots membership/bounds, not height arrays: do not mutate shared grids.
|
|
59
|
+
* No provider, worker, deck view, GPU resource or implicit longitude wrapping is owned.
|
|
60
|
+
*/
|
|
61
|
+
export class TerrainHeightField {
|
|
62
|
+
/** Original order for small-field direct sampling, avoiding tree/sort overhead. */
|
|
63
|
+
private readonly entries: readonly FieldEntry[];
|
|
64
|
+
/** Immutable spatial tree built once per admitted field. */
|
|
65
|
+
private readonly root: FieldNode | null;
|
|
66
|
+
/** Fixed retained summed-area mask admission limit. */
|
|
67
|
+
private readonly maxMaskBytes: number;
|
|
68
|
+
/** Mutable counters never exposed by reference. */
|
|
69
|
+
private readonly statistics: TerrainHeightFieldStatistics;
|
|
70
|
+
|
|
71
|
+
/** Retain structurally valid decoded grids with one datum; overlaps preserve ordered sample averaging. */
|
|
72
|
+
constructor(grids: readonly TerrainHeightGrid[], options: TerrainHeightFieldOptions = {}) {
|
|
73
|
+
this.maxMaskBytes = options.maxMaskBytes ?? 8 * 1024 * 1024;
|
|
74
|
+
if (!Number.isSafeInteger(this.maxMaskBytes) || this.maxMaskBytes < 0) throw new Error('Invalid terrain field mask budget');
|
|
75
|
+
this.statistics = {grids: grids.length, maskBytes: 0, maskBuilds: 0, maskSamples: 0, boundsTests: 0, candidateGrids: 0};
|
|
76
|
+
const entries = grids.map((grid, index): FieldEntry => {
|
|
77
|
+
const [west, south, east, north] = grid.bounds;
|
|
78
|
+
if (grid.bounds.length !== 4 || !grid.bounds.every(Number.isFinite) || west >= east || south >= north ||
|
|
79
|
+
!Number.isFinite(east - west) || !Number.isFinite(north - south) ||
|
|
80
|
+
!Number.isSafeInteger(grid.width) || !Number.isSafeInteger(grid.height) || grid.width < 2 || grid.height < 2 ||
|
|
81
|
+
grid.heights.length !== grid.width * grid.height || grid.valid.length !== grid.heights.length ||
|
|
82
|
+
!['pixel-center', 'grid-node'].includes(grid.registration) || !['unknown', 'ellipsoidal', 'orthometric'].includes(grid.verticalDatum) ||
|
|
83
|
+
grid.verticalDatum !== grids[0].verticalDatum) throw new Error('Invalid terrain field grid or datum');
|
|
84
|
+
const bounds: TerrainFieldBounds = [west, south, east, north];
|
|
85
|
+
const tolerance = Math.max(east - west, north - south) * 1e-10;
|
|
86
|
+
return {grid: {...grid, bounds}, index, bounds, sampleBounds: [west - tolerance, south - tolerance, east + tolerance, north + tolerance]};
|
|
87
|
+
});
|
|
88
|
+
this.entries = entries;
|
|
89
|
+
this.root = entries.length ? buildFieldNode([...entries]) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Same ordered bilinear arithmetic, touching-edge tolerance and no-data rule as sampleTerrainNeighborhood. */
|
|
93
|
+
sampleHeight(position: readonly [number, number]): number | null {
|
|
94
|
+
if (position.length !== 2 || !position.every(Number.isFinite)) return null;
|
|
95
|
+
const direct = this.entries.length <= 16;
|
|
96
|
+
const bounds: TerrainFieldBounds = [position[0], position[1], position[0], position[1]];
|
|
97
|
+
const candidates = direct ? this.entries : this.findEntries(bounds);
|
|
98
|
+
let sum = 0, count = 0;
|
|
99
|
+
for (const entry of candidates) {
|
|
100
|
+
if (direct) this.statistics.boundsTests++;
|
|
101
|
+
if (!intersectsBounds(entry.sampleBounds, bounds)) continue;
|
|
102
|
+
if (direct) this.statistics.candidateGrids++;
|
|
103
|
+
const [west, south, east, north] = entry.bounds;
|
|
104
|
+
const height = sampleTerrainHeight(entry.grid, [Math.max(west, Math.min(east, position[0])), Math.max(south, Math.min(north, position[1]))]);
|
|
105
|
+
if (height === null) return null;
|
|
106
|
+
sum += height; count++;
|
|
107
|
+
}
|
|
108
|
+
return count ? sum / count : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Require complete rectangular footprint coverage and no invalid bilinear-support sample; not an exact triangle mask. */
|
|
112
|
+
coversBounds(bounds: TerrainFieldBounds): boolean {
|
|
113
|
+
if (bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[0] > bounds[2] || bounds[1] > bounds[3] ||
|
|
114
|
+
!Number.isFinite(bounds[2] - bounds[0]) || !Number.isFinite(bounds[3] - bounds[1])) throw new Error('Invalid terrain field probe bounds');
|
|
115
|
+
const width = bounds[2] - bounds[0], height = bounds[3] - bounds[1];
|
|
116
|
+
if (!Number.isFinite(width * height) || width > 0 && height > 0 && width * height === 0) throw new Error('Terrain field probe area must be finite and representable');
|
|
117
|
+
if (!width && !height) return this.sampleHeight([bounds[0], bounds[1]]) !== null;
|
|
118
|
+
const clipped: TerrainFieldBounds[] = [];
|
|
119
|
+
for (const entry of this.findEntries(bounds)) {
|
|
120
|
+
const left = Math.max(bounds[0], entry.bounds[0]), right = Math.min(bounds[2], entry.bounds[2]);
|
|
121
|
+
const bottom = Math.max(bounds[1], entry.bounds[1]), top = Math.min(bounds[3], entry.bounds[3]);
|
|
122
|
+
if (left > right || bottom > top || width && left === right || height && bottom === top) continue;
|
|
123
|
+
if (this.hasMissingSamples(entry, [left, bottom, right, top])) return false;
|
|
124
|
+
clipped.push([left, bottom, right, top]);
|
|
125
|
+
}
|
|
126
|
+
// Union, not additive area: overlapping footprints must not hide an unloaded gap.
|
|
127
|
+
const single = clipped.length === 1 ? clipped[0] : null;
|
|
128
|
+
const covered = single ? width && height ? (single[2] - single[0]) * (single[3] - single[1]) : width ? single[2] - single[0] : single[3] - single[1] :
|
|
129
|
+
width && height ? unionArea(clipped) : unionLength(clipped.map(rectangle => width ? [rectangle[0], rectangle[2]] : [rectangle[1], rectangle[3]]));
|
|
130
|
+
return covered >= (width && height ? width * height : width || height) * (1 - 1e-10);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Scalar counters only; callers cannot mutate cache ownership through diagnostics. */
|
|
134
|
+
getStatistics(): TerrainHeightFieldStatistics {return {...this.statistics};}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Physical extrema over bilinear cell rectangles in a fully covered probe.
|
|
138
|
+
* A missing footprint or invalid sample returns null, not a clearance
|
|
139
|
+
* certificate. Overlapping grids use a conservative per-grid envelope.
|
|
140
|
+
*/
|
|
141
|
+
getHeightRange(bounds: TerrainFieldBounds): {minimum: number; maximum: number} | null {
|
|
142
|
+
if (!this.coversBounds(bounds)) return null;
|
|
143
|
+
let minimum = Infinity, maximum = -Infinity;
|
|
144
|
+
for (const entry of this.findEntries(bounds)) {
|
|
145
|
+
const {grid} = entry, pixelCenter = grid.registration === 'pixel-center';
|
|
146
|
+
const west = Math.max(bounds[0], grid.bounds[0]), east = Math.min(bounds[2], grid.bounds[2]);
|
|
147
|
+
const south = Math.max(bounds[1], grid.bounds[1]), north = Math.min(bounds[3], grid.bounds[3]);
|
|
148
|
+
if (west > east || south > north) continue;
|
|
149
|
+
const columns = [west, east], rows = [south, north];
|
|
150
|
+
for (let column = 0; column < grid.width; column++) {
|
|
151
|
+
const x = grid.bounds[0] + (column + (pixelCenter ? 0.5 : 0)) / (pixelCenter ? grid.width : grid.width - 1) * (grid.bounds[2] - grid.bounds[0]);
|
|
152
|
+
if (x > west && x < east) columns.push(x);
|
|
153
|
+
}
|
|
154
|
+
for (let row = 0; row < grid.height; row++) {
|
|
155
|
+
const y = grid.bounds[3] - (row + (pixelCenter ? 0.5 : 0)) / (pixelCenter ? grid.height : grid.height - 1) * (grid.bounds[3] - grid.bounds[1]);
|
|
156
|
+
if (y > south && y < north) rows.push(y);
|
|
157
|
+
}
|
|
158
|
+
// A bilinear patch has no strict interior maximum/minimum. Its
|
|
159
|
+
// extrema over an axis-aligned clipped cell occur at these knots.
|
|
160
|
+
for (const y of rows) for (const x of columns) {
|
|
161
|
+
const value = sampleTerrainHeight(grid, [x, y]);
|
|
162
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
163
|
+
minimum = Math.min(minimum, value); maximum = Math.max(maximum, value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return Number.isFinite(minimum) ? {minimum, maximum} : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Traverse closed envelopes, then preserve original source order for floating-point accumulation. */
|
|
170
|
+
private findEntries(bounds: TerrainFieldBounds): FieldEntry[] {
|
|
171
|
+
const entries: FieldEntry[] = [], pending = this.root ? [this.root] : [];
|
|
172
|
+
while (pending.length) {
|
|
173
|
+
const node = pending.pop()!;
|
|
174
|
+
this.statistics.boundsTests++;
|
|
175
|
+
if (!intersectsBounds(node.bounds, bounds)) continue;
|
|
176
|
+
if (node.entry) {entries.push(node.entry); this.statistics.candidateGrids++;}
|
|
177
|
+
else if (node.children) pending.push(...node.children);
|
|
178
|
+
}
|
|
179
|
+
return entries.sort((first, second) => first.index - second.index);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Admit a lazy reusable prefix or scan the same support rectangle exactly when it does not fit. */
|
|
183
|
+
private hasMissingSamples(entry: FieldEntry, bounds: TerrainFieldBounds): boolean {
|
|
184
|
+
const {grid} = entry, stride = grid.width + 1;
|
|
185
|
+
const required = stride * (grid.height + 1) * Uint32Array.BYTES_PER_ELEMENT;
|
|
186
|
+
if (entry.mask === undefined && required <= this.maxMaskBytes - this.statistics.maskBytes) {
|
|
187
|
+
let hasMissing = false;
|
|
188
|
+
for (const valid of grid.valid) {
|
|
189
|
+
this.statistics.maskSamples++;
|
|
190
|
+
if (!valid) {hasMissing = true; break;}
|
|
191
|
+
}
|
|
192
|
+
if (!hasMissing) {entry.mask = null; return false;}
|
|
193
|
+
const prefix = new Uint32Array(stride * (grid.height + 1));
|
|
194
|
+
for (let row = 0; row < grid.height; row++) for (let column = 0; column < grid.width; column++) {
|
|
195
|
+
const offset = (row + 1) * stride + column + 1, invalid = Number(!grid.valid[row * grid.width + column]);
|
|
196
|
+
prefix[offset] = invalid + prefix[offset - 1] + prefix[offset - stride] - prefix[offset - stride - 1];
|
|
197
|
+
}
|
|
198
|
+
this.statistics.maskSamples += grid.valid.length;
|
|
199
|
+
entry.mask = prefix;
|
|
200
|
+
this.statistics.maskBytes += required; this.statistics.maskBuilds++;
|
|
201
|
+
}
|
|
202
|
+
if (entry.mask === null) return false;
|
|
203
|
+
const pixel = grid.registration === 'pixel-center';
|
|
204
|
+
const column = (x: number) => Math.max(0, Math.min(grid.width - 1, (x - grid.bounds[0]) / (grid.bounds[2] - grid.bounds[0]) * (pixel ? grid.width : grid.width - 1) - (pixel ? 0.5 : 0)));
|
|
205
|
+
const row = (y: number) => Math.max(0, Math.min(grid.height - 1, (grid.bounds[3] - y) / (grid.bounds[3] - grid.bounds[1]) * (pixel ? grid.height : grid.height - 1) - (pixel ? 0.5 : 0)));
|
|
206
|
+
const firstColumn = Math.floor(column(bounds[0])), lastColumn = Math.ceil(column(bounds[2]));
|
|
207
|
+
const firstRow = Math.floor(row(bounds[3])), lastRow = Math.ceil(row(bounds[1]));
|
|
208
|
+
if (entry.mask) return Boolean(entry.mask[(lastRow + 1) * stride + lastColumn + 1] - entry.mask[firstRow * stride + lastColumn + 1] -
|
|
209
|
+
entry.mask[(lastRow + 1) * stride + firstColumn] + entry.mask[firstRow * stride + firstColumn]);
|
|
210
|
+
for (let row = firstRow; row <= lastRow; row++) for (let column = firstColumn; column <= lastColumn; column++) {
|
|
211
|
+
this.statistics.maskSamples++;
|
|
212
|
+
if (!grid.valid[row * grid.width + column]) return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Closed rectangle intersection, including tolerance-expanded sampling envelopes. */
|
|
219
|
+
function intersectsBounds(first: TerrainFieldBounds, second: TerrainFieldBounds): boolean {
|
|
220
|
+
return first[0] <= second[2] && first[2] >= second[0] && first[1] <= second[3] && first[3] >= second[1];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Median split on the longest axis; no external spatial-index dependency is needed. */
|
|
224
|
+
function buildFieldNode(entries: FieldEntry[]): FieldNode {
|
|
225
|
+
const bounds: [number, number, number, number] = [Infinity, Infinity, -Infinity, -Infinity];
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
bounds[0] = Math.min(bounds[0], entry.sampleBounds[0]); bounds[1] = Math.min(bounds[1], entry.sampleBounds[1]);
|
|
228
|
+
bounds[2] = Math.max(bounds[2], entry.sampleBounds[2]); bounds[3] = Math.max(bounds[3], entry.sampleBounds[3]);
|
|
229
|
+
}
|
|
230
|
+
if (entries.length === 1) return {bounds, entry: entries[0]};
|
|
231
|
+
const axis = bounds[2] - bounds[0] >= bounds[3] - bounds[1] ? 0 : 1;
|
|
232
|
+
entries.sort((first, second) => (first.bounds[axis] / 2 + first.bounds[axis + 2] / 2) - (second.bounds[axis] / 2 + second.bounds[axis + 2] / 2) || first.index - second.index);
|
|
233
|
+
const middle = Math.floor(entries.length / 2);
|
|
234
|
+
return {bounds, children: [buildFieldNode(entries.slice(0, middle)), buildFieldNode(entries.slice(middle))]};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Length of an interval union, never counting overlapping source coverage twice. */
|
|
238
|
+
function unionLength(intervals: [number, number][]): number {
|
|
239
|
+
intervals.sort((first, second) => first[0] - second[0]);
|
|
240
|
+
let total = 0, end = -Infinity;
|
|
241
|
+
for (const [left, right] of intervals) {total += Math.max(0, right - Math.max(left, end)); end = Math.max(end, right);}
|
|
242
|
+
return total;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Rectangle union using independent vertical slabs; only spatially admitted sources participate. */
|
|
246
|
+
function unionArea(rectangles: TerrainFieldBounds[]): number {
|
|
247
|
+
const columns = [...new Set(rectangles.flatMap(rectangle => [rectangle[0], rectangle[2]]))].sort((first, second) => first - second);
|
|
248
|
+
let area = 0;
|
|
249
|
+
for (let column = 1; column < columns.length; column++) {
|
|
250
|
+
const left = columns[column - 1], right = columns[column];
|
|
251
|
+
area += (right - left) * unionLength(rectangles.filter(rectangle => rectangle[0] <= left && rectangle[2] >= right).map(rectangle => [rectangle[1], rectangle[3]]));
|
|
252
|
+
}
|
|
253
|
+
return area;
|
|
254
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// tangram-layers
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import type {TerrainHeightField} from './terrain-height-field';
|
|
6
|
+
|
|
7
|
+
/** Conservative swept square-footprint guard; it is not a rigid-body physics solver. */
|
|
8
|
+
export interface TerrainMotionResult {
|
|
9
|
+
/** True only when every admitted path rectangle has valid source coverage. */
|
|
10
|
+
readonly available: boolean;
|
|
11
|
+
/** No translation should be published when true; orientation can still change. */
|
|
12
|
+
readonly blocked: boolean;
|
|
13
|
+
/** Explicit distinction between an obstacle, no-data and a bounded-work refusal. */
|
|
14
|
+
readonly reason: 'clear' | 'terrain' | 'missing' | 'budget';
|
|
15
|
+
/** Exact rectangle queries performed, useful for deterministic work-budget tests. */
|
|
16
|
+
readonly probes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Guard all intermediate XY coverage and the entire body footprint, not just
|
|
21
|
+
* endpoint/centerline rays. XY and radius are EPSG:3857 meters; Z/clearance are
|
|
22
|
+
* physical vertical meters. A square conservatively contains a circular avatar.
|
|
23
|
+
*/
|
|
24
|
+
export function guardTerrainMotion(field: TerrainHeightField, start: readonly [number, number, number], end: readonly [number, number, number], options: {
|
|
25
|
+
/** Half-width of the conservative horizontal footprint in Mercator meters. */
|
|
26
|
+
radius: number;
|
|
27
|
+
/** Required physical eye-to-ground distance. */
|
|
28
|
+
clearance: number;
|
|
29
|
+
/** Maximum horizontal travel per query rectangle, default 2 Mercator meters. */
|
|
30
|
+
maxStep?: number;
|
|
31
|
+
/** Hard rectangle-query cap, default 128. Excessive movement holds translation. */
|
|
32
|
+
maxProbes?: number;
|
|
33
|
+
}): TerrainMotionResult {
|
|
34
|
+
const maxStep = options.maxStep ?? 2, maxProbes = options.maxProbes ?? 128;
|
|
35
|
+
if (start.length !== 3 || end.length !== 3 || ![...start, ...end, options.radius, options.clearance, maxStep].every(Number.isFinite) ||
|
|
36
|
+
options.radius < 0 || options.clearance < 0 || maxStep <= 0 || !Number.isSafeInteger(maxProbes) || maxProbes < 1 || maxProbes > 1024) throw new Error('Invalid swept terrain motion');
|
|
37
|
+
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
38
|
+
const steps = Math.max(1, Math.ceil(distance / maxStep));
|
|
39
|
+
if (steps > maxProbes) return {available: false, blocked: true, reason: 'budget', probes: 0};
|
|
40
|
+
let obstacle = false;
|
|
41
|
+
for (let step = 0; step < steps; step++) {
|
|
42
|
+
const first = start.map((value, axis) => value + (end[axis] - value) * step / steps);
|
|
43
|
+
const second = start.map((value, axis) => value + (end[axis] - value) * (step + 1) / steps);
|
|
44
|
+
const range = field.getHeightRange([Math.min(first[0], second[0]) - options.radius, Math.min(first[1], second[1]) - options.radius,
|
|
45
|
+
Math.max(first[0], second[0]) + options.radius, Math.max(first[1], second[1]) + options.radius]);
|
|
46
|
+
if (!range) return {available: false, blocked: true, reason: 'missing', probes: step + 1};
|
|
47
|
+
obstacle ||= Math.min(first[2], second[2]) - options.clearance < range.maximum;
|
|
48
|
+
}
|
|
49
|
+
return {available: true, blocked: obstacle, reason: obstacle ? 'terrain' : 'clear', probes: steps};
|
|
50
|
+
}
|