map-zero 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +31 -2
- package/docs/cesium.md +24 -9
- package/package.json +4 -3
- package/packages/cesium/package.json +6 -3
- package/packages/cesium/src/imagery-worker.js +604 -0
- package/packages/cesium/src/imagery.js +434 -0
- package/packages/cesium/src/index.js +199 -35
- package/packages/ol/package.json +1 -1
- package/packages/ol/src/index.js +349 -16
- package/src/3dtiles/b3dm.js +18 -2
- package/src/3dtiles/clipper-surfaces.js +121 -20
- package/src/3dtiles/export.js +298 -25
- package/src/3dtiles/extrude.js +78 -23
- package/src/3dtiles/flat.js +8 -20
- package/src/3dtiles/glb.js +78 -27
- package/src/3dtiles/gpkg-features.js +4 -4
- package/src/3dtiles/precision.js +47 -0
- package/src/cli.js +100 -2
- package/src/export-pmtiles.js +17 -16
- package/src/from-bbox.js +335 -0
- package/src/gpkg-read.js +15 -2
- package/src/html.js +28 -17
- package/src/manifest.js +1 -8
- package/src/mvt.js +35 -3
- package/src/package.js +343 -0
- package/src/server.js +38 -10
- package/src/style-command.js +1 -1
- package/src/style-filters.js +2 -3
- package/styles/presets/neon-dark-3d.json +0 -90
package/src/3dtiles/export.js
CHANGED
|
@@ -19,13 +19,21 @@ import {
|
|
|
19
19
|
} from './gpkg-buildings.js';
|
|
20
20
|
import { buildContentNode, buildTileset } from './tileset.js';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Orchestrates conversion from a .mapzero package into static Cesium 3D Tiles.
|
|
24
|
+
*
|
|
25
|
+
* The exporter reads GeoPackage data, subdivides each layer into manageable
|
|
26
|
+
* geographic leaves, builds layer-specific meshes, wraps those meshes as GLB
|
|
27
|
+
* and b3dm files, then updates manifest.json with Cesium metadata.
|
|
28
|
+
*/
|
|
29
|
+
|
|
22
30
|
const DEFAULT_BUILDING_HEIGHT = 9;
|
|
23
31
|
const DEFAULT_MAX_FEATURES = 2500;
|
|
24
32
|
const DEFAULT_MAX_DEPTH = 4;
|
|
33
|
+
const DEFAULT_3D_LAYERS = ['buildings'];
|
|
25
34
|
const SUPPORTED_3D_LAYERS = ['buildings', 'landuse', 'water', 'aip', 'railways', 'roads', 'boundaries'];
|
|
26
35
|
const LAYER_ALIASES = {
|
|
27
|
-
aviation: 'aip'
|
|
28
|
-
aip: 'aviation'
|
|
36
|
+
aviation: 'aip'
|
|
29
37
|
};
|
|
30
38
|
|
|
31
39
|
/**
|
|
@@ -149,7 +157,7 @@ export async function export3dTiles(options) {
|
|
|
149
157
|
throw new Error('no 3D Tiles were generated');
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
await
|
|
160
|
+
await updateManifest3dTiles(manifestPath, manifest, exportedTilesets, /** @type {[number, number, number, number]} */ (manifest.bbox));
|
|
153
161
|
options.onProgress?.({
|
|
154
162
|
phase: 'done',
|
|
155
163
|
leafCount: totalLeaves,
|
|
@@ -171,6 +179,15 @@ export async function export3dTiles(options) {
|
|
|
171
179
|
}
|
|
172
180
|
}
|
|
173
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Export extruded building footprints as the main 3D volume layer.
|
|
184
|
+
*
|
|
185
|
+
* @param {import('better-sqlite3').Database} db
|
|
186
|
+
* @param {Record<string, any>} manifest
|
|
187
|
+
* @param {string} outRoot
|
|
188
|
+
* @param {any} options
|
|
189
|
+
* @returns {Promise<any>}
|
|
190
|
+
*/
|
|
174
191
|
async function exportBuildingLayer(db, manifest, outRoot, options) {
|
|
175
192
|
const metadata = readBuildingsMetadata(db, /** @type {[number, number, number, number]} */ (manifest.bbox));
|
|
176
193
|
const featureCount = countBuildings(db, metadata, metadata.bbox);
|
|
@@ -195,6 +212,18 @@ async function exportBuildingLayer(db, manifest, outRoot, options) {
|
|
|
195
212
|
}, options);
|
|
196
213
|
}
|
|
197
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Export layers that may contain polygons, lines, and points as a pair of flat
|
|
217
|
+
* surface meshes: polygon fill plus offset linework. AIP/aviation also converts
|
|
218
|
+
* selected point features into small disks so they remain visible in 3D.
|
|
219
|
+
*
|
|
220
|
+
* @param {import('better-sqlite3').Database} db
|
|
221
|
+
* @param {Record<string, any>} manifest
|
|
222
|
+
* @param {string} outRoot
|
|
223
|
+
* @param {string} layerId
|
|
224
|
+
* @param {any} options
|
|
225
|
+
* @returns {Promise<any>}
|
|
226
|
+
*/
|
|
198
227
|
async function exportMixedSurfaceLayer(db, manifest, outRoot, layerId, options) {
|
|
199
228
|
let metadata;
|
|
200
229
|
try {
|
|
@@ -228,7 +257,7 @@ async function exportMixedSurfaceLayer(db, manifest, outRoot, layerId, options)
|
|
|
228
257
|
const lines = linesFromFeatures([...lineFeatures, ...outlineFeatures]);
|
|
229
258
|
const lineMesh = await buildClipperLineSurfaceMesh(lines, {
|
|
230
259
|
widthMeters: lineWidthMeters(layerId, options.style),
|
|
231
|
-
height:
|
|
260
|
+
height: lineSurfaceHeight(layerId),
|
|
232
261
|
scale: 100,
|
|
233
262
|
arcToleranceMeters: isAipLayer(layerId) ? 0.35 : 0.25,
|
|
234
263
|
cleanDistanceMeters: 0.05,
|
|
@@ -237,7 +266,7 @@ async function exportMixedSurfaceLayer(db, manifest, outRoot, layerId, options)
|
|
|
237
266
|
const polygonMesh = layerId === 'boundaries'
|
|
238
267
|
? null
|
|
239
268
|
: buildPolygonSurfaceMesh([...polygonFeatures, ...pointFeatures], {
|
|
240
|
-
height:
|
|
269
|
+
height: polygonSurfaceHeight(layerId)
|
|
241
270
|
});
|
|
242
271
|
|
|
243
272
|
return {
|
|
@@ -252,6 +281,16 @@ async function exportMixedSurfaceLayer(db, manifest, outRoot, layerId, options)
|
|
|
252
281
|
}, options);
|
|
253
282
|
}
|
|
254
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Export polygon/point-oriented layers with the generic flat mesh builder.
|
|
286
|
+
*
|
|
287
|
+
* @param {import('better-sqlite3').Database} db
|
|
288
|
+
* @param {Record<string, any>} manifest
|
|
289
|
+
* @param {string} outRoot
|
|
290
|
+
* @param {string} layerId
|
|
291
|
+
* @param {any} options
|
|
292
|
+
* @returns {Promise<any>}
|
|
293
|
+
*/
|
|
255
294
|
async function exportFlatLayer(db, manifest, outRoot, layerId, options) {
|
|
256
295
|
let metadata;
|
|
257
296
|
try {
|
|
@@ -276,7 +315,8 @@ async function exportFlatLayer(db, manifest, outRoot, layerId, options) {
|
|
|
276
315
|
});
|
|
277
316
|
return {
|
|
278
317
|
mesh: buildFlatLayerMesh(layerId, features, {
|
|
279
|
-
lineWidthMeters: lineWidthMeters(layerId, options.style)
|
|
318
|
+
lineWidthMeters: lineWidthMeters(layerId, options.style),
|
|
319
|
+
height: polygonSurfaceHeight(layerId)
|
|
280
320
|
}),
|
|
281
321
|
featureCount: features.length,
|
|
282
322
|
skipped: 0
|
|
@@ -285,6 +325,15 @@ async function exportFlatLayer(db, manifest, outRoot, layerId, options) {
|
|
|
285
325
|
}, options);
|
|
286
326
|
}
|
|
287
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Export roads as dissolved offset surfaces rather than simple line ribbons.
|
|
330
|
+
*
|
|
331
|
+
* @param {import('better-sqlite3').Database} db
|
|
332
|
+
* @param {Record<string, any>} manifest
|
|
333
|
+
* @param {string} outRoot
|
|
334
|
+
* @param {any} options
|
|
335
|
+
* @returns {Promise<any>}
|
|
336
|
+
*/
|
|
288
337
|
async function exportRoadLayer(db, manifest, outRoot, options) {
|
|
289
338
|
let metadata;
|
|
290
339
|
try {
|
|
@@ -311,7 +360,7 @@ async function exportRoadLayer(db, manifest, outRoot, options) {
|
|
|
311
360
|
const bodyWidth = roadBodyWidthMeters(options.style);
|
|
312
361
|
const body = await buildClipperLineSurfaceMesh(lines, {
|
|
313
362
|
widthMeters: bodyWidth,
|
|
314
|
-
height:
|
|
363
|
+
height: lineSurfaceHeight('roads'),
|
|
315
364
|
scale: 100,
|
|
316
365
|
arcToleranceMeters: 0.45,
|
|
317
366
|
cleanDistanceMeters: 0.05,
|
|
@@ -327,6 +376,16 @@ async function exportRoadLayer(db, manifest, outRoot, options) {
|
|
|
327
376
|
}, options);
|
|
328
377
|
}
|
|
329
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Export a line-only layer, currently railways, through the Clipper surface path.
|
|
381
|
+
*
|
|
382
|
+
* @param {import('better-sqlite3').Database} db
|
|
383
|
+
* @param {Record<string, any>} manifest
|
|
384
|
+
* @param {string} outRoot
|
|
385
|
+
* @param {string} layerId
|
|
386
|
+
* @param {any} options
|
|
387
|
+
* @returns {Promise<any>}
|
|
388
|
+
*/
|
|
330
389
|
async function exportLineSurfaceLayer(db, manifest, outRoot, layerId, options) {
|
|
331
390
|
let metadata;
|
|
332
391
|
try {
|
|
@@ -352,7 +411,7 @@ async function exportLineSurfaceLayer(db, manifest, outRoot, layerId, options) {
|
|
|
352
411
|
const lines = linesFromFeatures(features);
|
|
353
412
|
const mesh = await buildClipperLineSurfaceMesh(lines, {
|
|
354
413
|
widthMeters: lineWidthMeters(layerId, options.style),
|
|
355
|
-
height: layerId
|
|
414
|
+
height: lineSurfaceHeight(layerId),
|
|
356
415
|
scale: 100,
|
|
357
416
|
arcToleranceMeters: layerId === 'railways' ? 0.4 : 0.25,
|
|
358
417
|
cleanDistanceMeters: 0.05,
|
|
@@ -368,6 +427,18 @@ async function exportLineSurfaceLayer(db, manifest, outRoot, layerId, options) {
|
|
|
368
427
|
}, options);
|
|
369
428
|
}
|
|
370
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Write all leaf meshes for one layer as b3dm files and assemble tileset.json.
|
|
432
|
+
*
|
|
433
|
+
* @param {string} layerId
|
|
434
|
+
* @param {[number, number, number, number]} bbox
|
|
435
|
+
* @param {Array<{ bbox: [number, number, number, number], count: number }>} leaves
|
|
436
|
+
* @param {string} outRoot
|
|
437
|
+
* @param {Record<string, any> | null} style
|
|
438
|
+
* @param {{ readMesh?: Function, readMeshes?: Function }} source
|
|
439
|
+
* @param {any} options
|
|
440
|
+
* @returns {Promise<any>}
|
|
441
|
+
*/
|
|
371
442
|
async function exportLayerTiles(layerId, bbox, leaves, outRoot, style, source, options) {
|
|
372
443
|
const outDir = join(outRoot, layerId);
|
|
373
444
|
const tilesDir = join(outDir, 'tiles');
|
|
@@ -395,9 +466,10 @@ async function exportLayerTiles(layerId, bbox, leaves, outRoot, style, source, o
|
|
|
395
466
|
const mesh = entry.mesh;
|
|
396
467
|
const glb = buildGlbFromMesh(mesh, {
|
|
397
468
|
color: entry.color ?? colorFactorForLayer(style, layerId),
|
|
398
|
-
generator: `map-zero 3dtiles ${layerId}${entry.id ? ` ${entry.id}` : ''}
|
|
469
|
+
generator: `map-zero 3dtiles ${layerId}${entry.id ? ` ${entry.id}` : ''}`,
|
|
470
|
+
...glbOptionsForLayer(layerId)
|
|
399
471
|
});
|
|
400
|
-
const b3dm = buildB3dm(glb);
|
|
472
|
+
const b3dm = buildB3dm(glb, { rtcCenter: mesh.rtcCenter });
|
|
401
473
|
const tileName = entry.id === 'main'
|
|
402
474
|
? `tile-${writtenTiles}.b3dm`
|
|
403
475
|
: `tile-${writtenTiles}-${entry.id}.b3dm`;
|
|
@@ -446,10 +518,12 @@ async function exportLayerTiles(layerId, bbox, leaves, outRoot, style, source, o
|
|
|
446
518
|
}
|
|
447
519
|
|
|
448
520
|
/**
|
|
521
|
+
* Recursively split a layer bbox until each leaf is small enough to mesh.
|
|
522
|
+
*
|
|
449
523
|
* @param {import('better-sqlite3').Database} db
|
|
450
524
|
* @param {any} metadata
|
|
451
525
|
* @param {[number, number, number, number]} bbox
|
|
452
|
-
* @param {{ maxFeatures: number, maxDepth: number }} options
|
|
526
|
+
* @param {{ maxFeatures: number, maxDepth: number, count: Function }} options
|
|
453
527
|
* @returns {Array<{ bbox: [number, number, number, number], count: number }>}
|
|
454
528
|
*/
|
|
455
529
|
function buildLeafPlan(db, metadata, bbox, options) {
|
|
@@ -479,6 +553,8 @@ function buildLeafPlan(db, metadata, bbox, options) {
|
|
|
479
553
|
}
|
|
480
554
|
|
|
481
555
|
/**
|
|
556
|
+
* Split a geographic bbox into four quadrants.
|
|
557
|
+
*
|
|
482
558
|
* @param {[number, number, number, number]} bbox
|
|
483
559
|
* @returns {Array<[number, number, number, number]>}
|
|
484
560
|
*/
|
|
@@ -495,6 +571,8 @@ function splitBbox(bbox) {
|
|
|
495
571
|
}
|
|
496
572
|
|
|
497
573
|
/**
|
|
574
|
+
* Merge multiple lon/lat bounding boxes.
|
|
575
|
+
*
|
|
498
576
|
* @param {Array<[number, number, number, number]>} bboxes
|
|
499
577
|
* @returns {[number, number, number, number] | null}
|
|
500
578
|
*/
|
|
@@ -517,12 +595,14 @@ function mergeBboxes(bboxes) {
|
|
|
517
595
|
}
|
|
518
596
|
|
|
519
597
|
/**
|
|
598
|
+
* Normalize CLI layer input and reject unsupported 3D export layers.
|
|
599
|
+
*
|
|
520
600
|
* @param {string | undefined} value
|
|
521
601
|
* @returns {string[]}
|
|
522
602
|
*/
|
|
523
603
|
function normalizeLayers(value) {
|
|
524
604
|
if (!value) {
|
|
525
|
-
return [...
|
|
605
|
+
return [...DEFAULT_3D_LAYERS];
|
|
526
606
|
}
|
|
527
607
|
const layers = Array.isArray(value) ? value : String(value).split(',');
|
|
528
608
|
const normalized = layers.map((layer) => normalizeLayerId(String(layer).trim())).filter(Boolean);
|
|
@@ -531,10 +611,12 @@ function normalizeLayers(value) {
|
|
|
531
611
|
if (unsupported.length > 0) {
|
|
532
612
|
throw new Error(`unsupported 3D layer(s): ${unsupported.join(', ')}`);
|
|
533
613
|
}
|
|
534
|
-
return normalized.length > 0 ? normalized : [...
|
|
614
|
+
return normalized.length > 0 ? normalized : [...DEFAULT_3D_LAYERS];
|
|
535
615
|
}
|
|
536
616
|
|
|
537
617
|
/**
|
|
618
|
+
* Map user-facing layer aliases to canonical exporter layer IDs.
|
|
619
|
+
*
|
|
538
620
|
* @param {string} layerId
|
|
539
621
|
* @returns {string}
|
|
540
622
|
*/
|
|
@@ -543,6 +625,8 @@ function normalizeLayerId(layerId) {
|
|
|
543
625
|
}
|
|
544
626
|
|
|
545
627
|
/**
|
|
628
|
+
* Return whether a layer ID represents aeronautical data.
|
|
629
|
+
*
|
|
546
630
|
* @param {string} layerId
|
|
547
631
|
* @returns {boolean}
|
|
548
632
|
*/
|
|
@@ -551,6 +635,8 @@ function isAipLayer(layerId) {
|
|
|
551
635
|
}
|
|
552
636
|
|
|
553
637
|
/**
|
|
638
|
+
* Validate the manifest fields required by static 3D Tiles export.
|
|
639
|
+
*
|
|
554
640
|
* @param {Record<string, unknown>} manifest
|
|
555
641
|
*/
|
|
556
642
|
function validateManifest(manifest) {
|
|
@@ -564,28 +650,35 @@ function validateManifest(manifest) {
|
|
|
564
650
|
}
|
|
565
651
|
|
|
566
652
|
/**
|
|
653
|
+
* Persist the 3D Tiles entry back to manifest.json.
|
|
654
|
+
*
|
|
567
655
|
* @param {string} manifestPath
|
|
568
656
|
* @param {Record<string, any>} manifest
|
|
569
657
|
* @param {Record<string, string>} tilesets
|
|
658
|
+
* @param {[number, number, number, number]} bbox
|
|
570
659
|
*/
|
|
571
|
-
async function
|
|
572
|
-
manifest.cesium
|
|
573
|
-
...(manifest.cesium ?? {}),
|
|
574
|
-
bbox,
|
|
575
|
-
focusBbox: bbox,
|
|
576
|
-
tilesets
|
|
577
|
-
};
|
|
660
|
+
async function updateManifest3dTiles(manifestPath, manifest, tilesets, bbox) {
|
|
661
|
+
delete manifest.cesium;
|
|
578
662
|
const firstEntry = Object.entries(tilesets)[0];
|
|
579
663
|
manifest.tiles3d = {
|
|
580
664
|
format: '3dtiles',
|
|
581
665
|
url: firstEntry?.[1],
|
|
582
|
-
layers: Object.keys(tilesets)
|
|
583
|
-
bbox,
|
|
584
|
-
focusBbox: bbox
|
|
666
|
+
layers: Object.keys(tilesets)
|
|
585
667
|
};
|
|
668
|
+
if (!sameBbox(bbox, manifest.bbox)) {
|
|
669
|
+
manifest.tiles3d.bbox = bbox;
|
|
670
|
+
}
|
|
586
671
|
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
587
672
|
}
|
|
588
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Load the package default style. Missing or invalid styles are treated as null
|
|
676
|
+
* so exports can still complete with fallback colors and widths.
|
|
677
|
+
*
|
|
678
|
+
* @param {string} packageDir
|
|
679
|
+
* @param {Record<string, any>} manifest
|
|
680
|
+
* @returns {Promise<Record<string, any> | null>}
|
|
681
|
+
*/
|
|
589
682
|
async function readDefaultStyle(packageDir, manifest) {
|
|
590
683
|
const styleUrl = manifest.styles?.default;
|
|
591
684
|
if (typeof styleUrl !== 'string') {
|
|
@@ -599,15 +692,33 @@ async function readDefaultStyle(packageDir, manifest) {
|
|
|
599
692
|
}
|
|
600
693
|
}
|
|
601
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Convert a map-zero style rule to a glTF baseColorFactor.
|
|
697
|
+
*
|
|
698
|
+
* @param {Record<string, any> | null} style
|
|
699
|
+
* @param {string} layerId
|
|
700
|
+
* @returns {[number, number, number, number]}
|
|
701
|
+
*/
|
|
602
702
|
function colorFactorForLayer(style, layerId) {
|
|
603
|
-
const rule = style?.layers?.[layerId] ?? style?.layers?.[
|
|
703
|
+
const rule = style?.layers?.[layerId] ?? style?.layers?.[styleLayerAlias(layerId)] ?? {};
|
|
704
|
+
if (layerId === 'buildings') {
|
|
705
|
+
const color = buildingSolidColor(rule);
|
|
706
|
+
return [...hexToRgb(color), 1];
|
|
707
|
+
}
|
|
604
708
|
const color = rule.fill ?? rule.body?.color ?? rule.stroke ?? '#00ffff';
|
|
605
709
|
const opacity = Number(rule.fillOpacity ?? rule.body?.opacity ?? rule.strokeOpacity ?? 0.8);
|
|
606
710
|
return [...hexToRgb(color), Math.max(0.05, Math.min(1, Number.isFinite(opacity) ? opacity : 0.8))];
|
|
607
711
|
}
|
|
608
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Convert 2D style stroke/body width to an approximate world-space line width.
|
|
715
|
+
*
|
|
716
|
+
* @param {string} layerId
|
|
717
|
+
* @param {Record<string, any> | null} style
|
|
718
|
+
* @returns {number}
|
|
719
|
+
*/
|
|
609
720
|
function lineWidthMeters(layerId, style) {
|
|
610
|
-
const rule = style?.layers?.[layerId] ?? style?.layers?.[
|
|
721
|
+
const rule = style?.layers?.[layerId] ?? style?.layers?.[styleLayerAlias(layerId)] ?? {};
|
|
611
722
|
const width = Number(rule.body?.width ?? rule.strokeWidth);
|
|
612
723
|
if (Number.isFinite(width) && width > 0) {
|
|
613
724
|
return Math.max(1.5, width * 2.2);
|
|
@@ -619,11 +730,64 @@ function lineWidthMeters(layerId, style) {
|
|
|
619
730
|
return 2;
|
|
620
731
|
}
|
|
621
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Return the alternate style key for layers whose data and style IDs differ.
|
|
735
|
+
*
|
|
736
|
+
* @param {string} layerId
|
|
737
|
+
* @returns {string}
|
|
738
|
+
*/
|
|
739
|
+
function styleLayerAlias(layerId) {
|
|
740
|
+
if (layerId === 'aip') return 'aviation';
|
|
741
|
+
if (layerId === 'aviation') return 'aip';
|
|
742
|
+
return LAYER_ALIASES[layerId] ?? layerId;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Roads use their line width as the full surface body width.
|
|
747
|
+
*
|
|
748
|
+
* @param {Record<string, any> | null} style
|
|
749
|
+
* @returns {number}
|
|
750
|
+
*/
|
|
622
751
|
function roadBodyWidthMeters(style) {
|
|
623
752
|
const width = lineWidthMeters('roads', style);
|
|
624
753
|
return Math.max(5, width);
|
|
625
754
|
}
|
|
626
755
|
|
|
756
|
+
/**
|
|
757
|
+
* Assign z offsets for flat polygon surfaces to reduce z-fighting between
|
|
758
|
+
* layers in Cesium.
|
|
759
|
+
*
|
|
760
|
+
* @param {string} layerId
|
|
761
|
+
* @returns {number}
|
|
762
|
+
*/
|
|
763
|
+
function polygonSurfaceHeight(layerId) {
|
|
764
|
+
if (isAipLayer(layerId)) return 8;
|
|
765
|
+
if (layerId === 'water') return 4;
|
|
766
|
+
if (layerId === 'landuse') return 2;
|
|
767
|
+
return 3;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Assign z offsets for line surfaces so roads, railways, boundaries, and AIP
|
|
772
|
+
* remain visually separated.
|
|
773
|
+
*
|
|
774
|
+
* @param {string} layerId
|
|
775
|
+
* @returns {number}
|
|
776
|
+
*/
|
|
777
|
+
function lineSurfaceHeight(layerId) {
|
|
778
|
+
if (isAipLayer(layerId)) return 9;
|
|
779
|
+
if (layerId === 'roads') return 10;
|
|
780
|
+
if (layerId === 'railways') return 11;
|
|
781
|
+
if (layerId === 'boundaries') return 12;
|
|
782
|
+
return 10;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Extract all valid line coordinate arrays from a feature list.
|
|
787
|
+
*
|
|
788
|
+
* @param {Array<{ geometry?: any }>} features
|
|
789
|
+
* @returns {Array<Array<[number, number]>>}
|
|
790
|
+
*/
|
|
627
791
|
function linesFromFeatures(features) {
|
|
628
792
|
const lines = [];
|
|
629
793
|
for (const feature of features) {
|
|
@@ -632,6 +796,12 @@ function linesFromFeatures(features) {
|
|
|
632
796
|
return lines.map(cleanLine).filter((line) => line.length >= 2);
|
|
633
797
|
}
|
|
634
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Extract line coordinate arrays from GeoJSON LineString/MultiLineString.
|
|
801
|
+
*
|
|
802
|
+
* @param {any} geometry
|
|
803
|
+
* @returns {Array<Array<[number, number]>>}
|
|
804
|
+
*/
|
|
635
805
|
function linesFromGeometry(geometry) {
|
|
636
806
|
if (geometry?.type === 'LineString' && Array.isArray(geometry.coordinates)) {
|
|
637
807
|
return [geometry.coordinates];
|
|
@@ -642,20 +812,40 @@ function linesFromGeometry(geometry) {
|
|
|
642
812
|
return [];
|
|
643
813
|
}
|
|
644
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Normalize a line to finite lon/lat pairs.
|
|
817
|
+
*
|
|
818
|
+
* @param {Array<[number, number]>} line
|
|
819
|
+
* @returns {Array<[number, number]>}
|
|
820
|
+
*/
|
|
645
821
|
function cleanLine(line) {
|
|
646
822
|
return line
|
|
647
823
|
.map((point) => [Number(point?.[0]), Number(point?.[1])])
|
|
648
824
|
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
|
|
649
825
|
}
|
|
650
826
|
|
|
827
|
+
/**
|
|
828
|
+
* @param {{ geometry?: any }} feature
|
|
829
|
+
* @returns {boolean}
|
|
830
|
+
*/
|
|
651
831
|
function hasLineGeometry(feature) {
|
|
652
832
|
return feature.geometry?.type === 'LineString' || feature.geometry?.type === 'MultiLineString';
|
|
653
833
|
}
|
|
654
834
|
|
|
835
|
+
/**
|
|
836
|
+
* @param {{ geometry?: any }} feature
|
|
837
|
+
* @returns {boolean}
|
|
838
|
+
*/
|
|
655
839
|
function hasPolygonGeometry(feature) {
|
|
656
840
|
return feature.geometry?.type === 'Polygon' || feature.geometry?.type === 'MultiPolygon';
|
|
657
841
|
}
|
|
658
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Decide which aviation points should become visible 3D disks.
|
|
845
|
+
*
|
|
846
|
+
* @param {{ geometry?: any, properties?: Record<string, unknown> }} feature
|
|
847
|
+
* @returns {boolean}
|
|
848
|
+
*/
|
|
659
849
|
function isVisibleAviationPointFeature(feature) {
|
|
660
850
|
if (feature.geometry?.type !== 'Point' && feature.geometry?.type !== 'MultiPoint') {
|
|
661
851
|
return false;
|
|
@@ -664,6 +854,14 @@ function isVisibleAviationPointFeature(feature) {
|
|
|
664
854
|
return aeroway === 'helipad' || aeroway === 'aerodrome';
|
|
665
855
|
}
|
|
666
856
|
|
|
857
|
+
/**
|
|
858
|
+
* Convert point features into small polygon disks for flat 3D export.
|
|
859
|
+
*
|
|
860
|
+
* @param {Array<{ geometry?: any, properties?: Record<string, unknown> }>} features
|
|
861
|
+
* @param {number} radiusMeters
|
|
862
|
+
* @param {number} segments
|
|
863
|
+
* @returns {Array<{ type: 'Feature', properties: any, geometry: { type: 'Polygon', coordinates: Array<Array<[number, number]>> } }>}
|
|
864
|
+
*/
|
|
667
865
|
function pointDiskFeatures(features, radiusMeters, segments) {
|
|
668
866
|
const out = [];
|
|
669
867
|
for (const feature of features) {
|
|
@@ -684,6 +882,12 @@ function pointDiskFeatures(features, radiusMeters, segments) {
|
|
|
684
882
|
return out;
|
|
685
883
|
}
|
|
686
884
|
|
|
885
|
+
/**
|
|
886
|
+
* Extract point coordinate arrays from GeoJSON Point/MultiPoint.
|
|
887
|
+
*
|
|
888
|
+
* @param {any} geometry
|
|
889
|
+
* @returns {Array<[number, number]>}
|
|
890
|
+
*/
|
|
687
891
|
function pointsFromGeometry(geometry) {
|
|
688
892
|
if (geometry?.type === 'Point' && Array.isArray(geometry.coordinates)) {
|
|
689
893
|
return [geometry.coordinates];
|
|
@@ -694,6 +898,14 @@ function pointsFromGeometry(geometry) {
|
|
|
694
898
|
return [];
|
|
695
899
|
}
|
|
696
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Approximate a lon/lat point as a small circular polygon in local meters.
|
|
903
|
+
*
|
|
904
|
+
* @param {[number, number]} point
|
|
905
|
+
* @param {number} radiusMeters
|
|
906
|
+
* @param {number} segments
|
|
907
|
+
* @returns {Array<[number, number]> | null}
|
|
908
|
+
*/
|
|
697
909
|
function pointDiskPolygon(point, radiusMeters, segments) {
|
|
698
910
|
const lon = Number(point?.[0]);
|
|
699
911
|
const lat = Number(point?.[1]);
|
|
@@ -715,6 +927,12 @@ function pointDiskPolygon(point, radiusMeters, segments) {
|
|
|
715
927
|
return ring;
|
|
716
928
|
}
|
|
717
929
|
|
|
930
|
+
/**
|
|
931
|
+
* Convert a CSS hex color to normalized RGB floats.
|
|
932
|
+
*
|
|
933
|
+
* @param {string} value
|
|
934
|
+
* @returns {[number, number, number]}
|
|
935
|
+
*/
|
|
718
936
|
function hexToRgb(value) {
|
|
719
937
|
const color = /^#?([0-9a-f]{6})$/i.exec(String(value));
|
|
720
938
|
const hex = color?.[1] ?? '00ffff';
|
|
@@ -725,6 +943,48 @@ function hexToRgb(value) {
|
|
|
725
943
|
];
|
|
726
944
|
}
|
|
727
945
|
|
|
946
|
+
/**
|
|
947
|
+
* Pick the stable 3D building color. A style can override it with
|
|
948
|
+
* cesium.color, tiles3d.color, or material.color.
|
|
949
|
+
*
|
|
950
|
+
* @param {Record<string, any>} rule
|
|
951
|
+
* @returns {string}
|
|
952
|
+
*/
|
|
953
|
+
function buildingSolidColor(rule) {
|
|
954
|
+
const explicit = rule?.cesium?.color ?? rule?.tiles3d?.color ?? rule?.material?.color;
|
|
955
|
+
if (typeof explicit === 'string' && isHexColor(explicit)) {
|
|
956
|
+
return explicit;
|
|
957
|
+
}
|
|
958
|
+
return '#8a3f82';
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* @param {string} value
|
|
963
|
+
* @returns {boolean}
|
|
964
|
+
*/
|
|
965
|
+
function isHexColor(value) {
|
|
966
|
+
return /^#[0-9a-f]{6}$/i.test(value);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Return GLB material/attribute options for the layer.
|
|
971
|
+
*
|
|
972
|
+
* Buildings carry normals and use lit, double-sided materials so blocks read as
|
|
973
|
+
* volumes in Cesium. Flat cartographic layers stay unlit and smaller.
|
|
974
|
+
*
|
|
975
|
+
* @param {string} layerId
|
|
976
|
+
* @returns {{ includeNormals: boolean, quantizeNormals: boolean, doubleSided: boolean, unlit: boolean }}
|
|
977
|
+
*/
|
|
978
|
+
function glbOptionsForLayer(layerId) {
|
|
979
|
+
const buildings = layerId === 'buildings';
|
|
980
|
+
return {
|
|
981
|
+
includeNormals: buildings,
|
|
982
|
+
quantizeNormals: buildings,
|
|
983
|
+
doubleSided: buildings,
|
|
984
|
+
unlit: !buildings
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
728
988
|
/**
|
|
729
989
|
* @param {unknown} value
|
|
730
990
|
* @param {number} fallback
|
|
@@ -766,3 +1026,16 @@ function validBbox(value) {
|
|
|
766
1026
|
Number(value[0]) < Number(value[2]) &&
|
|
767
1027
|
Number(value[1]) < Number(value[3]);
|
|
768
1028
|
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* @param {unknown} a
|
|
1032
|
+
* @param {unknown} b
|
|
1033
|
+
* @returns {boolean}
|
|
1034
|
+
*/
|
|
1035
|
+
function sameBbox(a, b) {
|
|
1036
|
+
return Array.isArray(a) &&
|
|
1037
|
+
Array.isArray(b) &&
|
|
1038
|
+
a.length === 4 &&
|
|
1039
|
+
b.length === 4 &&
|
|
1040
|
+
a.every((value, index) => Math.abs(Number(value) - Number(b[index])) < 1e-9);
|
|
1041
|
+
}
|