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.
@@ -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 updateManifestCesium(manifestPath, manifest, exportedTilesets, /** @type {[number, number, number, number]} */ (manifest.bbox));
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: isAipLayer(layerId) ? 1.4 : 1.2,
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: isAipLayer(layerId) ? 1.1 : 0.25
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: 0.9,
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 === 'railways' ? 0.82 : 0.7,
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 [...SUPPORTED_3D_LAYERS];
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 : [...SUPPORTED_3D_LAYERS];
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 updateManifestCesium(manifestPath, manifest, tilesets, bbox) {
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?.[LAYER_ALIASES[layerId]] ?? {};
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?.[LAYER_ALIASES[layerId]] ?? {};
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
+ }