@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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **On-the-fly meshing of raster elevation tiles for the CesiumJS virtual globe**
4
4
 
5
- [![npm version](https://badge.fury.io/js/@macrostrat%2Fcesium-martini.svg)](https://badge.fury.io/js/@macrostrat%2Fcesium-martini)
5
+ [![npm version](https://badge.fury.io/js/@macrostrat%2Fcesium-martini.svg?cache=none)](https://badge.fury.io/js/@macrostrat%2Fcesium-martini)
6
6
 
7
7
  ![Himalayas](/img/himalayas.jpg)
8
8
 
@@ -150,6 +150,10 @@ The configuration also takes a single number and array.
150
150
 
151
151
  ## Changelog
152
152
 
153
+ ### `[1.5.1]`: February 2025
154
+
155
+ - Remove `.idea` files from bundle
156
+
153
157
  ### `[1.5.0]`: February 2025
154
158
 
155
159
  - Allow overzooming of tiles when zoom levels are skipped
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@macrostrat/cesium-martini",
3
- "version": "1.5.0",
4
- "description": "",
3
+ "version": "1.5.2",
4
+ "description": "On-the-fly meshing of raster elevation tiles for the CesiumJS virtual globe",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
7
7
  "source": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "typescript": "./src/index.ts",
10
+ "source": "./src/index.ts",
11
11
  "import": "./dist/index.js",
12
12
  "require": "./dist/index.cjs"
13
13
  }
@@ -26,6 +26,10 @@
26
26
  "format": "prettier --write src/**/*.ts examples/**/*.ts",
27
27
  "check": "tsc --noEmit"
28
28
  },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
29
33
  "author": "",
30
34
  "license": "ISC",
31
35
  "dependencies": {
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import "./module.d.ts";
2
+ import DefaultHeightmapResource from "./resources/heightmap-resource";
3
+ import MapboxTerrainResource from "./resources/mapbox-resource";
4
+ import {
5
+ MartiniTerrainProvider,
6
+ StretchedTilingScheme,
7
+ } from "./terrain-provider";
8
+ import MapboxTerrainProvider from "./mapbox-terrain-provider";
9
+ export * from "./worker/decoder";
10
+ export * from "./worker/worker-util";
11
+
12
+ export default MapboxTerrainProvider;
13
+ export {
14
+ MartiniTerrainProvider,
15
+ DefaultHeightmapResource,
16
+ MapboxTerrainResource,
17
+ StretchedTilingScheme,
18
+ };
@@ -0,0 +1,25 @@
1
+ import { TerrainProvider } from "cesium";
2
+ import MapboxTerrainResource, {
3
+ MapboxTerrainResourceOpts,
4
+ } from "./resources/mapbox-resource";
5
+ import { MartiniTerrainOpts, MartiniTerrainProvider } from "./terrain-provider";
6
+ import WorkerFarmTerrainDecoder from "./worker/decoder";
7
+ import MapboxTerrainWorker from "web-worker:./worker/mapbox-worker";
8
+
9
+ type MapboxTerrainOpts = Omit<MartiniTerrainOpts, "resource"> &
10
+ MapboxTerrainResourceOpts;
11
+
12
+ export default class MapboxTerrainProvider extends MartiniTerrainProvider<TerrainProvider> {
13
+ constructor(opts: MapboxTerrainOpts = {}) {
14
+ const resource = new MapboxTerrainResource(opts);
15
+ const decoder = new WorkerFarmTerrainDecoder({
16
+ worker: new MapboxTerrainWorker(),
17
+ });
18
+
19
+ super({
20
+ ...opts,
21
+ resource,
22
+ decoder,
23
+ });
24
+ }
25
+ }
@@ -0,0 +1,4 @@
1
+ declare module "web-worker:*" {
2
+ const value: new () => Worker;
3
+ export default value;
4
+ }
@@ -0,0 +1,120 @@
1
+ import { Resource, Credit } from "cesium";
2
+ import { TileCoordinates } from "../terrain-data";
3
+
4
+ export interface HeightmapResource {
5
+ credit?: Credit;
6
+ tileSize: number;
7
+ getTilePixels: (coords: TileCoordinates) => Promise<ImageData> | undefined;
8
+ getTileDataAvailable: (coords: TileCoordinates) => boolean;
9
+ }
10
+
11
+ interface CanvasRef {
12
+ canvas: HTMLCanvasElement;
13
+ context: CanvasRenderingContext2D;
14
+ }
15
+ export interface DefaultHeightmapResourceOpts {
16
+ url?: string;
17
+ // Legacy option, use skipZoomLevels instead
18
+ skipOddLevels?: boolean;
19
+ skipZoomLevels?: [number] | ((z: number) => boolean);
20
+ maxZoom?: number;
21
+ tileSize?: number;
22
+ }
23
+
24
+ export class DefaultHeightmapResource implements HeightmapResource {
25
+ resource: Resource = null;
26
+ tileSize: number = 256;
27
+ maxZoom: number;
28
+ skipZoomLevel: (z: number) => boolean;
29
+ contextQueue: CanvasRef[];
30
+
31
+ constructor(opts: DefaultHeightmapResourceOpts = {}) {
32
+ if (opts.url) {
33
+ this.resource = new Resource({ url: opts.url });
34
+ }
35
+ this.skipZoomLevel = () => false;
36
+ if (opts.skipZoomLevels) {
37
+ if (Array.isArray(opts.skipZoomLevels)) {
38
+ const _skipZoomLevels = opts.skipZoomLevels as [number];
39
+ this.skipZoomLevel = (z: number) => _skipZoomLevels.includes(z);
40
+ } else {
41
+ this.skipZoomLevel = opts.skipZoomLevels;
42
+ }
43
+ } else if (opts.skipOddLevels) {
44
+ this.skipZoomLevel = (z: number) => z % 2 == 1;
45
+ }
46
+
47
+ this.tileSize = opts.tileSize ?? 256;
48
+ this.maxZoom = opts.maxZoom ?? 15;
49
+ this.contextQueue = [];
50
+ }
51
+
52
+ getCanvas(): CanvasRef {
53
+ let ctx = this.contextQueue.pop();
54
+ if (ctx == null) {
55
+ const canvas = document.createElement("canvas");
56
+ canvas.width = this.tileSize;
57
+ canvas.height = this.tileSize;
58
+ const context = canvas.getContext("2d");
59
+ ctx = {
60
+ canvas,
61
+ context,
62
+ };
63
+ }
64
+ return ctx;
65
+ }
66
+
67
+ getPixels(img: HTMLImageElement | HTMLCanvasElement): ImageData {
68
+ const canvasRef = this.getCanvas();
69
+ const { context } = canvasRef;
70
+ //context.scale(1, -1);
71
+ // Chrome appears to vertically flip the image for reasons that are unclear
72
+ // We can make it work in Chrome by drawing the image upside-down at this step.
73
+ context.drawImage(img, 0, 0, this.tileSize, this.tileSize);
74
+ const pixels = context.getImageData(0, 0, this.tileSize, this.tileSize);
75
+ context.clearRect(0, 0, this.tileSize, this.tileSize);
76
+ this.contextQueue.push(canvasRef);
77
+ return pixels;
78
+ }
79
+
80
+ getTileResource(tileCoords: TileCoordinates) {
81
+ // reverseY for TMS tiling (https://gist.github.com/tmcw/4954720)
82
+ // See tiling schemes here: https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/
83
+ const { z, y } = tileCoords;
84
+ return this.resource.getDerivedResource({
85
+ templateValues: {
86
+ ...tileCoords,
87
+ reverseY: Math.pow(2, z) - y - 1,
88
+ },
89
+ preserveQueryParameters: true,
90
+ });
91
+ }
92
+
93
+ getTilePixels(coords: TileCoordinates): Promise<ImageData> | undefined {
94
+ const resource = this.getTileResource(coords);
95
+ const request = resource.fetchImage({
96
+ preferImageBitmap: false,
97
+ // @ts-ignore
98
+ retryAttempts: 3,
99
+ });
100
+ if (request == null) return undefined;
101
+ return request.then((img: HTMLImageElement | ImageBitmap) =>
102
+ // @ts-ignore
103
+ this.getPixels(img),
104
+ );
105
+ }
106
+
107
+ getTileDataAvailable({ z }) {
108
+ if (z == this.maxZoom) return true;
109
+ /* Weird hack:
110
+ For some reason, request render mode breaks if zoom 1 tiles are disabled.
111
+ So we have to make sure that we always report zoom 1 tiles as available.
112
+ */
113
+ if (z < 2) return true;
114
+ if (this.skipZoomLevel(z)) return false;
115
+ if (z > this.maxZoom) return false;
116
+ return true;
117
+ }
118
+ }
119
+
120
+ export default DefaultHeightmapResource;
@@ -0,0 +1,53 @@
1
+ import { Credit, Resource } from "cesium";
2
+ import {
3
+ DefaultHeightmapResource,
4
+ DefaultHeightmapResourceOpts,
5
+ } from "./heightmap-resource";
6
+
7
+ export enum ImageFormat {
8
+ WEBP = "webp",
9
+ PNG = "png",
10
+ PNGRAW = "pngraw",
11
+ }
12
+
13
+ export type MapboxTerrainResourceOpts = {
14
+ highResolution?: boolean;
15
+ imageFormat?: ImageFormat;
16
+ accessToken?: string;
17
+ urlTemplate?: string;
18
+ } & DefaultHeightmapResourceOpts;
19
+
20
+ export class MapboxTerrainResource extends DefaultHeightmapResource {
21
+ resource: Resource = null;
22
+ credit = new Credit("Mapbox");
23
+
24
+ constructor(opts: MapboxTerrainResourceOpts = {}) {
25
+ super(opts);
26
+ const highResolution = opts.highResolution ?? false;
27
+ const format = opts.imageFormat ?? ImageFormat.WEBP;
28
+ const { urlTemplate } = opts;
29
+
30
+ // overrides based on highResolution flag
31
+ if (highResolution) {
32
+ if (opts.maxZoom === undefined) {
33
+ this.maxZoom = 14;
34
+ }
35
+ if (opts.tileSize === undefined) {
36
+ this.tileSize = 512;
37
+ }
38
+ }
39
+
40
+ const defaultURL = `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}${
41
+ highResolution ? "@2x" : ""
42
+ }.${format}`;
43
+
44
+ this.resource = new Resource({ url: urlTemplate ?? defaultURL });
45
+ if (opts.accessToken) {
46
+ this.resource.setQueryParameters({
47
+ access_token: opts.accessToken,
48
+ });
49
+ }
50
+ }
51
+ }
52
+
53
+ export default MapboxTerrainResource;
@@ -0,0 +1,363 @@
1
+ import {
2
+ QuantizedMeshTerrainData,
3
+ Rectangle,
4
+ Ellipsoid,
5
+ Cartographic,
6
+ BoundingSphere,
7
+ OrientedBoundingBox,
8
+ Cartesian3,
9
+ Credit,
10
+ TilingScheme,
11
+ } from "cesium";
12
+ import {
13
+ TerrainWorkerOutput,
14
+ emptyMesh,
15
+ subsetByWindow,
16
+ TerrainUpscaleInput,
17
+ QuantizedMeshResult,
18
+ } from "./worker/worker-util";
19
+
20
+ export interface TileCoordinates {
21
+ x: number;
22
+ y: number;
23
+ z: number;
24
+ }
25
+
26
+ interface QuantizedMeshTerrainOptions {
27
+ quantizedVertices: Uint16Array;
28
+ indices: Uint16Array | Uint32Array;
29
+ minimumHeight: number;
30
+ maximumHeight: number;
31
+ boundingSphere: BoundingSphere;
32
+ orientedBoundingBox?: OrientedBoundingBox;
33
+ horizonOcclusionPoint: Cartesian3;
34
+ westIndices: number[];
35
+ southIndices: number[];
36
+ eastIndices: number[];
37
+ northIndices: number[];
38
+ westSkirtHeight: number;
39
+ southSkirtHeight: number;
40
+ eastSkirtHeight: number;
41
+ northSkirtHeight: number;
42
+ childTileMask?: number;
43
+ createdByUpsampling?: boolean;
44
+ encodedNormals?: Uint8Array;
45
+ waterMask?: Uint8Array;
46
+ credits?: Credit[];
47
+ }
48
+
49
+ export interface TerrainMeshMeta {
50
+ errorLevel: number;
51
+ tileSize: number;
52
+ maxVertexDistance: number | null;
53
+ tileRect: Rectangle;
54
+ ellipsoid: Ellipsoid;
55
+ overscaleFactor: number;
56
+ }
57
+
58
+ export function createTerrainMesh(
59
+ data: TerrainWorkerOutput,
60
+ meta: TerrainMeshMeta,
61
+ ) {
62
+ const {
63
+ minimumHeight,
64
+ maximumHeight,
65
+ quantizedVertices,
66
+ indices,
67
+ westIndices,
68
+ southIndices,
69
+ eastIndices,
70
+ northIndices,
71
+ quantizedHeights,
72
+ } = data;
73
+
74
+ const {
75
+ errorLevel,
76
+ tileSize,
77
+ maxVertexDistance,
78
+ tileRect,
79
+ ellipsoid,
80
+ overscaleFactor,
81
+ } = meta;
82
+
83
+ const err = errorLevel;
84
+ const skirtHeight = err * 20;
85
+
86
+ // Check if tileRect is not NaNs
87
+ if (isNaN(tileRect.east) || isNaN(tileRect.north)) {
88
+ throw new Error("Invalid tile rect");
89
+ }
90
+
91
+ const center = Rectangle.center(tileRect);
92
+
93
+ // Calculating occlusion height is kind of messy currently, but it definitely works
94
+ const halfAngle = tileRect.width / 2;
95
+ const dr = Math.cos(halfAngle); // half tile width since our ref point is at the center
96
+
97
+ let occlusionHeight = dr * ellipsoid.maximumRadius + maximumHeight;
98
+ if (halfAngle > Math.PI / 4) {
99
+ occlusionHeight = (1 + halfAngle) * ellipsoid.maximumRadius;
100
+ }
101
+
102
+ const occlusionPoint = new Cartographic(
103
+ center.longitude,
104
+ center.latitude,
105
+ occlusionHeight,
106
+ // Scaling factor of two just to be sure.
107
+ );
108
+
109
+ const horizonOcclusionPoint = ellipsoid.transformPositionToScaledSpace(
110
+ Cartographic.toCartesian(occlusionPoint),
111
+ );
112
+
113
+ let orientedBoundingBox = OrientedBoundingBox.fromRectangle(
114
+ tileRect,
115
+ minimumHeight,
116
+ maximumHeight,
117
+ ellipsoid,
118
+ );
119
+ let boundingSphere =
120
+ BoundingSphere.fromOrientedBoundingBox(orientedBoundingBox);
121
+
122
+ return new RasterTerrainData({
123
+ minimumHeight,
124
+ maximumHeight,
125
+ quantizedVertices,
126
+ indices,
127
+ boundingSphere,
128
+ orientedBoundingBox,
129
+ horizonOcclusionPoint,
130
+ westIndices,
131
+ southIndices,
132
+ eastIndices,
133
+ northIndices,
134
+ westSkirtHeight: skirtHeight,
135
+ southSkirtHeight: skirtHeight,
136
+ eastSkirtHeight: skirtHeight,
137
+ northSkirtHeight: skirtHeight,
138
+ childTileMask: 15,
139
+ createdByUpsampling: overscaleFactor > 0,
140
+ errorLevel: err,
141
+ maxVertexDistance,
142
+ tileSize,
143
+ quantizedHeights,
144
+ });
145
+ }
146
+
147
+ interface EmptyMeshOptions {
148
+ tileRect: Rectangle;
149
+ tileCoord: TileCoordinates;
150
+ ellipsoid: Ellipsoid;
151
+ errorLevel: number;
152
+ tileSize: number;
153
+ maxVertexDistance?: number;
154
+ }
155
+
156
+ export function createEmptyMesh(
157
+ opts: EmptyMeshOptions,
158
+ ): QuantizedMeshTerrainData {
159
+ const { tileRect, tileCoord, errorLevel, ellipsoid, maxVertexDistance } =
160
+ opts;
161
+ const center = Rectangle.center(tileRect);
162
+ const { z } = tileCoord;
163
+
164
+ const latScalar = Math.min(Math.abs(Math.sin(center.latitude)), 0.995);
165
+ let v = Math.max(
166
+ Math.ceil((200 / (z + 1)) * Math.pow(1 - latScalar, 0.25)),
167
+ 4,
168
+ );
169
+ const output = emptyMesh(v);
170
+ // We use zero for some undefined values
171
+ return createTerrainMesh(output, {
172
+ tileRect,
173
+ ellipsoid,
174
+ errorLevel,
175
+ overscaleFactor: 0,
176
+ maxVertexDistance,
177
+ tileSize: output.tileSize,
178
+ });
179
+ }
180
+
181
+ interface RasterParams {
182
+ quantizedHeights?: Float32Array;
183
+ errorLevel: number;
184
+ maxVertexDistance: number;
185
+ tileSize: number;
186
+ }
187
+
188
+ type RasterTerrainOptions = QuantizedMeshTerrainOptions & RasterParams;
189
+
190
+ class UpsampleTracker {
191
+ ne: boolean;
192
+ nw: boolean;
193
+ se: boolean;
194
+ sw: boolean;
195
+
196
+ constructor() {
197
+ this.ne = false;
198
+ this.nw = false;
199
+ this.se = false;
200
+ this.sw = false;
201
+ }
202
+
203
+ finished() {
204
+ return this.ne && this.nw && this.se && this.sw;
205
+ }
206
+ }
207
+
208
+ export class RasterTerrainData
209
+ extends QuantizedMeshTerrainData
210
+ implements RasterParams
211
+ {
212
+ quantizedHeights: Float32Array;
213
+ errorLevel: number;
214
+ maxVertexDistance: number;
215
+ tileSize: number;
216
+ private upsampleTracker: UpsampleTracker;
217
+
218
+ constructor(opts: RasterTerrainOptions) {
219
+ super(opts);
220
+ this.quantizedHeights = opts.quantizedHeights;
221
+ this.errorLevel = opts.errorLevel;
222
+ this.maxVertexDistance = opts.maxVertexDistance ?? opts.tileSize;
223
+ this.tileSize = opts.tileSize;
224
+ this.upsampleTracker = new UpsampleTracker();
225
+ }
226
+
227
+ upsample(
228
+ tilingScheme: TilingScheme,
229
+ thisX: number,
230
+ thisY: number,
231
+ thisLevel: number,
232
+ descendantX: number,
233
+ descendantY: number,
234
+ descendantLevel: number,
235
+ ) {
236
+ if (this.quantizedHeights == null) {
237
+ return super.upsample(
238
+ tilingScheme,
239
+ thisX,
240
+ thisY,
241
+ thisLevel,
242
+ descendantX,
243
+ descendantY,
244
+ descendantLevel,
245
+ );
246
+ } // Something wonky about our tiling scheme, perhaps
247
+ // 12/2215/2293 @2x
248
+ //const url = `https://a.tiles.mapbox.com/v4/mapbox.terrain-rgb/${z}/${x}/${y}${hires}.${this.format}?access_token=${this.accessToken}`;
249
+
250
+ const x = descendantX;
251
+ const y = descendantY;
252
+ const z = descendantLevel;
253
+
254
+ const tile = `${z}/${x}/${y}`;
255
+
256
+ //console.log(`Upsampling terrain data from zoom ${thisLevel} to ` + tile);
257
+
258
+ const dz = z - thisLevel;
259
+
260
+ const scalar = Math.pow(2, dz);
261
+
262
+ const ellipsoid = tilingScheme.ellipsoid;
263
+
264
+ const err = this.errorLevel / scalar;
265
+
266
+ const maxVertexDistance = Math.min(
267
+ this.maxVertexDistance * scalar,
268
+ this.tileSize,
269
+ );
270
+
271
+ const upscaledX = thisX * scalar;
272
+ const upscaledY = thisY * scalar;
273
+
274
+ const dx: number = x - upscaledX;
275
+ const dy: number = y - upscaledY;
276
+
277
+ const x0 = (dx * this.tileSize) / scalar;
278
+ const x1 = ((dx + 1) * this.tileSize) / scalar;
279
+ const y0 = (dy * this.tileSize) / scalar;
280
+ const y1 = ((dy + 1) * this.tileSize) / scalar;
281
+
282
+ const window = { x0, x1, y0, y1 };
283
+
284
+ const res = buildOverscaledTerrainTile({
285
+ tilingScheme,
286
+ heightData: subsetByWindow(this.quantizedHeights, window, true),
287
+ maxVertexDistance,
288
+ x,
289
+ y,
290
+ z,
291
+ errorLevel: err,
292
+ ellipsoidRadius: ellipsoid.maximumRadius,
293
+ tileSize: x1 - x0,
294
+ overscaleFactor: dz,
295
+ });
296
+ if (dz == 1) {
297
+ // If we've got a single child tile, we can track that we've upsampled the parent.
298
+ const quadrant = getQuadrant(dx as 0 | 1, dy as 0 | 1);
299
+ this.upsampleTracker[quadrant] = true;
300
+ }
301
+ if (this.upsampleTracker.finished()) {
302
+ // We've upsampled all child tiles and don't need to keep terrain data around anymore.
303
+ this.quantizedHeights = undefined;
304
+ }
305
+
306
+ return res;
307
+ }
308
+ }
309
+
310
+ function getQuadrant(dx: 0 | 1, dy: 0 | 1): "ne" | "nw" | "se" | "sw" {
311
+ if (dx == 0 && dy == 0) return "sw";
312
+ if (dx == 0 && dy == 1) return "nw";
313
+ if (dx == 1 && dy == 0) return "se";
314
+ if (dx == 1 && dy == 1) return "ne";
315
+ throw new Error("Invalid quadrant");
316
+ }
317
+
318
+ type OverscaleTerrainOptions = TerrainUpscaleInput & {
319
+ tilingScheme: TilingScheme;
320
+ };
321
+
322
+ async function buildOverscaledTerrainTile(opts: OverscaleTerrainOptions) {
323
+ const { tilingScheme, overscaleFactor, ...workerOpts } = opts;
324
+
325
+ const { x, y, z } = workerOpts;
326
+
327
+ const tileRect = tilingScheme.tileXYToRectangle(x, y, z);
328
+ const ellipsoid = tilingScheme.ellipsoid;
329
+
330
+ const { errorLevel, maxVertexDistance, tileSize } = workerOpts;
331
+
332
+ try {
333
+ const res = (await upsamplerFarm.scheduleTask(workerOpts, [
334
+ workerOpts.heightData.buffer,
335
+ ])) as QuantizedMeshResult;
336
+
337
+ return createTerrainMesh(res, {
338
+ tileRect,
339
+ ellipsoid,
340
+ errorLevel,
341
+ overscaleFactor,
342
+ tileSize,
343
+ // Maximum vertex distance
344
+ maxVertexDistance,
345
+ });
346
+ } catch (err) {
347
+ return createEmptyMesh({
348
+ tileRect,
349
+ errorLevel,
350
+ ellipsoid,
351
+ tileCoord: { x, y, z },
352
+ tileSize: 0,
353
+ });
354
+ }
355
+ }
356
+
357
+ import UpsamplerWorker from "web-worker:./worker/upsampler-worker";
358
+ import WorkerFarm from "./worker/worker-farm";
359
+
360
+ const upsamplerFarm = new WorkerFarm({
361
+ worker: new UpsamplerWorker(),
362
+ maxWorkers: 5,
363
+ });