@macrostrat/cesium-martini 1.5.0 → 1.5.2

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.
@@ -0,0 +1,343 @@
1
+ import {
2
+ Cartographic,
3
+ OrientedBoundingBox,
4
+ BoundingSphere,
5
+ Rectangle,
6
+ Ellipsoid,
7
+ WebMercatorTilingScheme,
8
+ Math as CMath,
9
+ Event as CEvent,
10
+ TerrainProvider,
11
+ Credit,
12
+ TilingScheme,
13
+ QuantizedMeshTerrainData,
14
+ } from "cesium";
15
+
16
+ import {
17
+ TerrainWorkerInput,
18
+ emptyMesh as _emptyMesh,
19
+ } from "./worker/worker-util";
20
+ import { HeightmapResource } from "./resources/heightmap-resource";
21
+ import WorkerFarmTerrainDecoder, {
22
+ TerrainDecoder,
23
+ DefaultTerrainDecoder,
24
+ } from "./worker/decoder";
25
+ import {
26
+ createEmptyMesh,
27
+ createTerrainMesh,
28
+ TerrainMeshMeta,
29
+ } from "./terrain-data";
30
+ import { TileCoordinates } from "./terrain-data";
31
+
32
+ // https://github.com/CesiumGS/cesium/blob/1.68/Source/Scene/MapboxImageryProvider.js#L42
33
+
34
+ export interface MartiniTerrainOpts {
35
+ resource: HeightmapResource;
36
+ decoder?: TerrainDecoder;
37
+
38
+ ellipsoid?: Ellipsoid;
39
+ tilingScheme?: TilingScheme;
40
+ // workerURL: string;
41
+ detailScalar?: number;
42
+ minimumErrorLevel?: number;
43
+ maxWorkers?: number;
44
+ minZoomLevel?: number;
45
+ fillPoles?: boolean;
46
+ }
47
+
48
+ export class StretchedTilingScheme extends WebMercatorTilingScheme {
49
+ tileXYToRectangle(
50
+ x: number,
51
+ y: number,
52
+ level: number,
53
+ res: Rectangle,
54
+ ): Rectangle {
55
+ let result = super.tileXYToRectangle(x, y, level);
56
+ if (y == 0) {
57
+ result.north = Math.PI / 2;
58
+ }
59
+ if (y + 1 == Math.pow(2, level)) {
60
+ result.south = -Math.PI / 2;
61
+ }
62
+ return result;
63
+ }
64
+ }
65
+
66
+ export class MartiniTerrainProvider<TerrainProvider> {
67
+ hasWaterMask = false;
68
+ hasVertexNormals = false;
69
+ credit = new Credit("Mapbox");
70
+ ready: boolean;
71
+ readyPromise: Promise<boolean>;
72
+ availability = null;
73
+ errorEvent = new CEvent();
74
+ tilingScheme: TilingScheme;
75
+ ellipsoid: Ellipsoid;
76
+ levelOfDetailScalar: number | null = null;
77
+ maxWorkers: number = 5;
78
+ minError: number = 0.1;
79
+ minZoomLevel: number;
80
+ fillPoles: boolean = true;
81
+ _errorAtMinZoom: number = 1000;
82
+
83
+ resource: HeightmapResource = null;
84
+ decoder: TerrainDecoder = null;
85
+
86
+ RADIUS_SCALAR = 1.0;
87
+
88
+ // @ts-ignore
89
+ constructor(opts: MartiniTerrainOpts = {}) {
90
+ //this.martini = new Martini(257);
91
+ this.resource = opts.resource;
92
+ this.credit = this.resource.credit ?? new Credit("Mapbox");
93
+
94
+ this.decoder = opts.decoder;
95
+ this.maxWorkers = opts.maxWorkers ?? 5;
96
+
97
+ if (!this.decoder) {
98
+ const maxWorkers = this.maxWorkers;
99
+ if (maxWorkers > 0) {
100
+ this.decoder = new WorkerFarmTerrainDecoder({ maxWorkers });
101
+ } else {
102
+ this.decoder = new DefaultTerrainDecoder();
103
+ }
104
+ }
105
+ this.minZoomLevel = opts.minZoomLevel ?? 3;
106
+ this.fillPoles = opts.fillPoles ?? true;
107
+
108
+ this.ready = true;
109
+ this.readyPromise = Promise.resolve(true);
110
+ this.minError = opts.minimumErrorLevel ?? 0.1;
111
+
112
+ this.errorEvent.addEventListener(console.log, this);
113
+ this.ellipsoid = opts.ellipsoid ?? Ellipsoid.WGS84;
114
+
115
+ if (opts.tilingScheme == null) {
116
+ let scheme = WebMercatorTilingScheme;
117
+ if (this.fillPoles) {
118
+ scheme = StretchedTilingScheme;
119
+ }
120
+ this.tilingScheme = new scheme({
121
+ numberOfLevelZeroTilesX: 1,
122
+ numberOfLevelZeroTilesY: 1,
123
+ ellipsoid: this.ellipsoid,
124
+ });
125
+ } else {
126
+ this.tilingScheme = opts.tilingScheme;
127
+ }
128
+
129
+ this.levelOfDetailScalar = (opts.detailScalar ?? 2.0) + CMath.EPSILON5;
130
+
131
+ //this.errorEvent.addEventListener(console.log, this);
132
+ this.ellipsoid =
133
+ opts.tilingScheme?.ellipsoid ?? opts.ellipsoid ?? Ellipsoid.WGS84;
134
+
135
+ this._errorAtMinZoom = this.errorAtZoom(this.minZoomLevel);
136
+ }
137
+
138
+ requestTileGeometry(x, y, z, request) {
139
+ // Look for tiles both below the zoom level and below the error threshold for the zoom level at the equator...
140
+ if (
141
+ this.minZoomLevel != 0 &&
142
+ (z < this.minZoomLevel ||
143
+ this.scaledErrorForTile(x, y, z) > this._errorAtMinZoom)
144
+ ) {
145
+ // If we are below the minimum zoom level, we return empty heightmaps
146
+ // to avoid unnecessary requests for low-resolution data.
147
+ return Promise.resolve(this.emptyMesh(x, y, z));
148
+ }
149
+
150
+ // Note: we still load a TON of tiles near the poles. We might need to do some overzooming here...
151
+ return this.decoder.requestTileGeometry(
152
+ { x, y, z },
153
+ this.processTile.bind(this),
154
+ );
155
+ }
156
+
157
+ async processTile({ x, y, z }: TileCoordinates) {
158
+ // Something wonky about our tiling scheme, perhaps
159
+ // 12/2215/2293 @2x
160
+ //const url = `https://a.tiles.mapbox.com/v4/mapbox.terrain-rgb/${z}/${x}/${y}${hires}.${this.format}?access_token=${this.accessToken}`;
161
+ const tileRect = this.tilingScheme.tileXYToRectangle(x, y, z);
162
+ const errorLevel = this.errorAtZoom(z);
163
+ const maxVertexDistance = this.maxVertexDistance(tileRect);
164
+
165
+ try {
166
+ const { tileSize, getTilePixels } = this.resource;
167
+ const r1 = getTilePixels.bind(this.resource, { x, y, z });
168
+
169
+ if (r1 == null) {
170
+ return;
171
+ }
172
+ const px = await r1();
173
+
174
+ let pixelData = px.data;
175
+ if (pixelData == null) {
176
+ return;
177
+ }
178
+
179
+ ///const center = Rectangle.center(tileRect);
180
+
181
+ const params: TerrainWorkerInput = {
182
+ imageData: pixelData,
183
+ maxVertexDistance,
184
+ x,
185
+ y,
186
+ z,
187
+ errorLevel,
188
+ ellipsoidRadius: this.tilingScheme.ellipsoid.maximumRadius,
189
+ tileSize,
190
+ };
191
+
192
+ const res = await this.decoder.decodeTerrain(params, pixelData.buffer);
193
+
194
+ const meta: TerrainMeshMeta = {
195
+ ellipsoid: this.tilingScheme.ellipsoid,
196
+ errorLevel,
197
+ overscaleFactor: 0,
198
+ maxVertexDistance,
199
+ tileRect,
200
+ tileSize,
201
+ };
202
+
203
+ /** This builds a final terrain mesh object that can optionally
204
+ * be upscaled to a higher resolution.
205
+ */
206
+ return createTerrainMesh(res, meta);
207
+ } catch (err) {
208
+ //console.log(err);
209
+ return createEmptyMesh({
210
+ tileRect,
211
+ errorLevel,
212
+ ellipsoid: this.tilingScheme.ellipsoid,
213
+ tileCoord: { x, y, z },
214
+ tileSize: 0,
215
+ });
216
+ }
217
+ }
218
+
219
+ errorAtZoom(zoom: number) {
220
+ return Math.max(
221
+ this.getLevelMaximumGeometricError(zoom) / this.levelOfDetailScalar,
222
+ this.minError,
223
+ );
224
+ }
225
+
226
+ scaledErrorForTile(x: number, y: number, z: number) {
227
+ const tileRect = this.tilingScheme.tileXYToRectangle(x, y, z);
228
+ const center = Rectangle.center(tileRect);
229
+ return this.errorAtZoom(z) / Math.pow(1 - Math.sin(center.latitude), 2);
230
+ }
231
+
232
+ maxVertexDistance(tileRect: Rectangle) {
233
+ return Math.ceil(2 / tileRect.height);
234
+ }
235
+
236
+ emptyMesh(x: number, y: number, z: number) {
237
+ const tileRect = this.tilingScheme.tileXYToRectangle(x, y, z);
238
+ const center = Rectangle.center(tileRect);
239
+
240
+ const latScalar = Math.min(Math.abs(Math.sin(center.latitude)), 0.995);
241
+ let v = Math.max(
242
+ Math.ceil((200 / (z + 1)) * Math.pow(1 - latScalar, 0.25)),
243
+ 4,
244
+ );
245
+ const output = _emptyMesh(v);
246
+ const err = this.errorAtZoom(z);
247
+ return this.createQuantizedMeshData(tileRect, err, output);
248
+ }
249
+
250
+ createQuantizedMeshData(tileRect, errorLevel, workerOutput) {
251
+ const {
252
+ minimumHeight,
253
+ maximumHeight,
254
+ quantizedVertices,
255
+ indices,
256
+ westIndices,
257
+ southIndices,
258
+ eastIndices,
259
+ northIndices,
260
+ } = workerOutput;
261
+
262
+ const err = errorLevel;
263
+ const skirtHeight = err * 20;
264
+
265
+ const center = Rectangle.center(tileRect);
266
+
267
+ // Calculating occlusion height is kind of messy currently, but it definitely works
268
+ const halfAngle = tileRect.width / 2;
269
+ const dr = Math.cos(halfAngle); // half tile width since our ref point is at the center
270
+
271
+ let occlusionHeight = dr * this.ellipsoid.maximumRadius + maximumHeight;
272
+ if (halfAngle > Math.PI / 4) {
273
+ occlusionHeight = (1 + halfAngle) * this.ellipsoid.maximumRadius;
274
+ }
275
+
276
+ const occlusionPoint = new Cartographic(
277
+ center.longitude,
278
+ center.latitude,
279
+ occlusionHeight,
280
+ // Scaling factor of two just to be sure.
281
+ );
282
+
283
+ const horizonOcclusionPoint = this.ellipsoid.transformPositionToScaledSpace(
284
+ Cartographic.toCartesian(occlusionPoint),
285
+ );
286
+
287
+ let orientedBoundingBox = OrientedBoundingBox.fromRectangle(
288
+ tileRect,
289
+ minimumHeight,
290
+ maximumHeight,
291
+ this.tilingScheme.ellipsoid,
292
+ );
293
+ let boundingSphere =
294
+ BoundingSphere.fromOrientedBoundingBox(orientedBoundingBox);
295
+
296
+ // SE NW NE
297
+ // NE NW SE
298
+
299
+ /** TODO: we need to create raster terrain data. */
300
+ return new QuantizedMeshTerrainData({
301
+ minimumHeight,
302
+ maximumHeight,
303
+ quantizedVertices,
304
+ indices,
305
+ boundingSphere,
306
+ orientedBoundingBox,
307
+ horizonOcclusionPoint,
308
+ westIndices,
309
+ southIndices,
310
+ eastIndices,
311
+ northIndices,
312
+ westSkirtHeight: skirtHeight,
313
+ southSkirtHeight: skirtHeight,
314
+ eastSkirtHeight: skirtHeight,
315
+ northSkirtHeight: skirtHeight,
316
+ childTileMask: 15,
317
+ });
318
+ }
319
+
320
+ getLevelMaximumGeometricError(level) {
321
+ const levelZeroMaximumGeometricError =
322
+ TerrainProvider.getEstimatedLevelZeroGeometricErrorForAHeightmap(
323
+ this.tilingScheme.ellipsoid,
324
+ 65,
325
+ this.tilingScheme.getNumberOfXTilesAtLevel(0),
326
+ );
327
+
328
+ /*
329
+ Scalar to control overzooming
330
+ - also seems to control zooming for imagery layers
331
+ - This scalar was causing trouble for non-256 tile sizes,
332
+ and we've removed it for now. It could be reintroduced
333
+ if it seems necessary
334
+ */
335
+ const scalar = 1; //this.resource.tileSize / 256 ;
336
+
337
+ return levelZeroMaximumGeometricError / scalar / (1 << level);
338
+ }
339
+
340
+ getTileDataAvailable(x, y, z) {
341
+ return this.resource.getTileDataAvailable({ x, y, z });
342
+ }
343
+ }
@@ -0,0 +1,57 @@
1
+ import { HeightmapTerrainData, QuantizedMeshTerrainData } from "cesium";
2
+ import { TileCoordinates } from "../terrain-data";
3
+ import WorkerFarm from "./worker-farm";
4
+ import { TerrainWorkerInput, QuantizedMeshResult } from "./worker-util";
5
+
6
+ export interface TerrainDecoder {
7
+ requestTileGeometry: (
8
+ coords: TileCoordinates,
9
+ processFunction: (
10
+ coords: TileCoordinates,
11
+ ) => Promise<HeightmapTerrainData | QuantizedMeshTerrainData>,
12
+ ) => Promise<HeightmapTerrainData | QuantizedMeshTerrainData> | undefined;
13
+ decodeTerrain: (
14
+ params: TerrainWorkerInput,
15
+ data: ArrayBufferLike,
16
+ ) => Promise<QuantizedMeshResult>;
17
+ }
18
+
19
+ export class DefaultTerrainDecoder implements TerrainDecoder {
20
+ inProgress: number = 0;
21
+ maxRequests: number = 2;
22
+
23
+ requestTileGeometry(coords, processFunction) {
24
+ if (this.inProgress > this.maxRequests) return undefined;
25
+ this.inProgress += 1;
26
+ return processFunction(coords).finally(() => {
27
+ this.inProgress -= 1;
28
+ });
29
+ }
30
+
31
+ decodeTerrain(params, data) {
32
+ return Promise.resolve(null);
33
+ }
34
+ }
35
+
36
+ interface WorkerFarmDecoderOpts {
37
+ maxWorkers?: number;
38
+ worker?: Worker;
39
+ }
40
+
41
+ export class WorkerFarmTerrainDecoder extends DefaultTerrainDecoder {
42
+ farm: WorkerFarm;
43
+
44
+ constructor(opts: WorkerFarmDecoderOpts) {
45
+ super();
46
+ this.farm = new WorkerFarm({ worker: opts.worker });
47
+ this.maxRequests = opts.maxWorkers ?? 5;
48
+ }
49
+
50
+ decodeTerrain(params, data) {
51
+ return this.farm.scheduleTask(params, [
52
+ data,
53
+ ]) as Promise<QuantizedMeshResult>;
54
+ }
55
+ }
56
+
57
+ export default WorkerFarmTerrainDecoder;
@@ -0,0 +1,86 @@
1
+ import {
2
+ rgbTerrainToGrid,
3
+ createQuantizedMeshData,
4
+ TerrainWorkerInput,
5
+ } from "./worker-util";
6
+ import ndarray from "ndarray";
7
+ import Martini from "@mapbox/martini";
8
+ // https://github.com/CesiumGS/cesium/blob/1.76/Source/WorkersES6/createVerticesFromQuantizedTerrainMesh.js
9
+
10
+ let martiniCache = {};
11
+
12
+ function decodeTerrain(
13
+ parameters: TerrainWorkerInput,
14
+ transferableObjects?: Transferable[],
15
+ ) {
16
+ const {
17
+ imageData,
18
+ tileSize = 256,
19
+ errorLevel,
20
+ maxVertexDistance,
21
+ } = parameters;
22
+
23
+ // Height data can be either an array of numbers (for pre-existing terrain data)
24
+ // or an image data array (for decoding from an image)
25
+
26
+ const heightData = {
27
+ type: "image",
28
+ array: imageData,
29
+ };
30
+
31
+ let terrain: Float32Array;
32
+ if (heightData.type === "image") {
33
+ const { array } = heightData;
34
+ const pixels = ndarray(
35
+ new Uint8Array(array),
36
+ [tileSize, tileSize, 4],
37
+ [4, 4 * tileSize, 1],
38
+ 0,
39
+ );
40
+ terrain = rgbTerrainToGrid(pixels);
41
+ } else {
42
+ // @ts-ignore
43
+ terrain = heightData.array;
44
+ }
45
+
46
+ // Tile size must be maintained through the life of the worker
47
+ martiniCache[tileSize] ??= new Martini(tileSize + 1);
48
+
49
+ const tile = martiniCache[tileSize].createTile(terrain);
50
+
51
+ const canUpscaleTile = true; //heightData.type === "image";
52
+
53
+ // get a mesh (vertices and triangles indices) for a 10m error
54
+ const mesh = tile.getMesh(errorLevel, Math.min(maxVertexDistance, tileSize));
55
+ const res = createQuantizedMeshData(
56
+ tile,
57
+ mesh,
58
+ tileSize,
59
+ // Only include vertex data if anticipate upscaling tile
60
+ canUpscaleTile ? terrain : null,
61
+ );
62
+ transferableObjects.push(res.indices.buffer);
63
+ transferableObjects.push(res.quantizedVertices.buffer);
64
+ if (res.quantizedHeights) {
65
+ transferableObjects.push(res.quantizedHeights.buffer);
66
+ }
67
+ return res;
68
+ }
69
+
70
+ self.onmessage = function (msg) {
71
+ const { id, payload } = msg.data;
72
+ if (id == null) return;
73
+ let objects: Transferable[] = [];
74
+ let res = null;
75
+ try {
76
+ res = decodeTerrain(payload, objects);
77
+ // @ts-ignore
78
+ self.postMessage({ id, payload: res }, objects);
79
+ } catch (err) {
80
+ const msg = err.message ?? err;
81
+ self.postMessage({ id, err: msg.toString() });
82
+ } finally {
83
+ res = null;
84
+ objects = null;
85
+ }
86
+ };
@@ -0,0 +1,64 @@
1
+ /** Worker to upsample terrain meshes */
2
+ import { createQuantizedMeshData, TerrainUpscaleInput } from "./worker-util";
3
+ import Martini from "@mapbox/martini";
4
+ // https://github.com/CesiumGS/cesium/blob/1.76/Source/WorkersES6/createVerticesFromQuantizedTerrainMesh.js
5
+
6
+ let martiniCache: Record<number, Martini> = {};
7
+
8
+ function decodeTerrain(
9
+ parameters: TerrainUpscaleInput,
10
+ transferableObjects?: Transferable[],
11
+ ) {
12
+ const {
13
+ heightData,
14
+ tileSize = 256,
15
+ errorLevel,
16
+ maxVertexDistance,
17
+ } = parameters;
18
+
19
+ // Height data can be either an array of numbers (for pre-existing terrain data)
20
+ // or an image data array (for decoding from an image)
21
+
22
+ let terrain: Float32Array = heightData;
23
+
24
+ // Tile size must be maintained through the life of the worker
25
+ martiniCache[tileSize] ??= new Martini(tileSize + 1);
26
+
27
+ const tile = martiniCache[tileSize].createTile(terrain);
28
+
29
+ const canUpscaleTile = true; //heightData.type === "image";
30
+
31
+ // get a mesh (vertices and triangles indices) for a 10m error
32
+ const mesh = tile.getMesh(errorLevel, Math.min(maxVertexDistance, tileSize));
33
+ const res = createQuantizedMeshData(
34
+ tile,
35
+ mesh,
36
+ tileSize,
37
+ // Only include vertex data if anticipate upscaling tile
38
+ canUpscaleTile ? terrain : null,
39
+ );
40
+ transferableObjects.push(res.indices.buffer);
41
+ transferableObjects.push(res.quantizedVertices.buffer);
42
+ if (res.quantizedHeights) {
43
+ transferableObjects.push(res.quantizedHeights.buffer);
44
+ }
45
+ return res;
46
+ }
47
+
48
+ self.onmessage = function (msg) {
49
+ const { id, payload } = msg.data;
50
+ if (id == null) return;
51
+ let objects: Transferable[] = [];
52
+ let res = null;
53
+ try {
54
+ res = decodeTerrain(payload, objects);
55
+ // @ts-ignore
56
+ self.postMessage({ id, payload: res }, objects);
57
+ } catch (err) {
58
+ const msg = err.message ?? err;
59
+ self.postMessage({ id, err: msg.toString() });
60
+ } finally {
61
+ res = null;
62
+ objects = null;
63
+ }
64
+ };
@@ -0,0 +1,85 @@
1
+ const resolves = {};
2
+ const rejects = {};
3
+ let globalMsgId = 0; // Activate calculation in the worker, returning a promise
4
+
5
+ async function sendMessage(
6
+ worker: Worker,
7
+ payload: any,
8
+ transferableObjects: Transferable[],
9
+ ) {
10
+ const msgId = globalMsgId++;
11
+ const msg = {
12
+ id: msgId,
13
+ payload,
14
+ };
15
+ return new Promise(function (resolve, reject) {
16
+ // save callbacks for later
17
+ resolves[msgId] = resolve;
18
+ rejects[msgId] = reject;
19
+ worker.postMessage(msg, transferableObjects);
20
+ });
21
+ } // Handle incoming calculation result
22
+
23
+ function handleMessage(msg) {
24
+ const { id, err, payload } = msg.data;
25
+ if (payload) {
26
+ const resolve = resolves[id];
27
+ if (resolve) {
28
+ resolve(payload);
29
+ }
30
+ } else {
31
+ // error condition
32
+ const reject = rejects[id];
33
+ if (reject) {
34
+ if (err) {
35
+ reject(err);
36
+ } else {
37
+ reject("Got nothing");
38
+ }
39
+ }
40
+ }
41
+
42
+ // purge used callbacks
43
+ delete resolves[id];
44
+ delete rejects[id];
45
+ }
46
+
47
+ export class WorkerFarm {
48
+ worker: Worker;
49
+ inProgressWorkers: number = 0;
50
+ maxWorkers: number = 5;
51
+ processingQueue: Function[] = [];
52
+
53
+ constructor(opts) {
54
+ this.worker = opts.worker;
55
+ this.worker.onmessage = handleMessage;
56
+ }
57
+
58
+ async scheduleTask(params, transferableObjects) {
59
+ const res = await sendMessage(this.worker, params, transferableObjects);
60
+ this.releaseWorker();
61
+ return res;
62
+ }
63
+
64
+ async holdForAvailableWorker(): Promise<void> {
65
+ let resultPromise: Promise<void>;
66
+ if (this.inProgressWorkers > this.maxWorkers) {
67
+ resultPromise = new Promise((resolve, reject) => {
68
+ this.processingQueue.push(resolve);
69
+ });
70
+ } else {
71
+ resultPromise = Promise.resolve(null);
72
+ }
73
+ await resultPromise;
74
+ this.inProgressWorkers += 1;
75
+ }
76
+
77
+ releaseWorker() {
78
+ this.inProgressWorkers -= 1;
79
+ if (this.processingQueue.length > 0) {
80
+ this.processingQueue.shift()();
81
+ }
82
+ }
83
+ }
84
+
85
+ export default WorkerFarm;