map-zero 0.1.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +220 -0
  4. package/docs/api.md +66 -0
  5. package/docs/architecture.md +87 -0
  6. package/docs/cartography.md +77 -0
  7. package/docs/cesium.md +107 -0
  8. package/docs/openlayers.md +98 -0
  9. package/docs/styles.md +103 -0
  10. package/package.json +51 -0
  11. package/packages/cesium/package.json +13 -0
  12. package/packages/cesium/src/index.js +405 -0
  13. package/packages/ol/package.json +14 -0
  14. package/packages/ol/src/index.js +1705 -0
  15. package/packages/ol/src/labels.js +977 -0
  16. package/src/3dtiles/b3dm.js +38 -0
  17. package/src/3dtiles/clipper-surfaces.js +317 -0
  18. package/src/3dtiles/export.js +768 -0
  19. package/src/3dtiles/extrude.js +301 -0
  20. package/src/3dtiles/flat.js +531 -0
  21. package/src/3dtiles/glb.js +178 -0
  22. package/src/3dtiles/gpkg-buildings.js +240 -0
  23. package/src/3dtiles/gpkg-features.js +157 -0
  24. package/src/3dtiles/tileset.js +75 -0
  25. package/src/build.js +134 -0
  26. package/src/cli.js +656 -0
  27. package/src/export-pmtiles.js +962 -0
  28. package/src/geometry-read.js +50 -0
  29. package/src/gpkg-read.js +460 -0
  30. package/src/gpkg.js +567 -0
  31. package/src/html.js +593 -0
  32. package/src/layers.js +357 -0
  33. package/src/manifest.js +29 -0
  34. package/src/mvt.js +2593 -0
  35. package/src/ol.js +5 -0
  36. package/src/osm.js +2110 -0
  37. package/src/pmtiles-worker.js +70 -0
  38. package/src/pmtiles.js +260 -0
  39. package/src/server.js +720 -0
  40. package/src/style-command.js +78 -0
  41. package/src/style-filters.js +76 -0
  42. package/src/style-presets.js +93 -0
  43. package/src/style-themes.js +235 -0
  44. package/src/style.js +13 -0
  45. package/src/tile-cache.js +59 -0
  46. package/src/utils.js +222 -0
  47. package/styles/presets/light.json +4655 -0
  48. package/styles/presets/monochrome.json +4655 -0
  49. package/styles/presets/neon-dark-3d.json +90 -0
  50. package/styles/presets/neon-dark.json +4690 -0
  51. package/styles/presets/tactical.json +4690 -0
  52. package/styles/themes/neon-dark.theme.json +20 -0
@@ -0,0 +1,768 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+
4
+ import { buildB3dm } from './b3dm.js';
5
+ import { buildClipperLineSurfaceMesh } from './clipper-surfaces.js';
6
+ import { buildMergedExtrudedPolygonMesh } from './extrude.js';
7
+ import { buildFlatLayerMesh, buildPolygonSurfaceMesh } from './flat.js';
8
+ import { buildGlbFromMesh } from './glb.js';
9
+ import {
10
+ countLayerFeatures,
11
+ readLayerFeatures,
12
+ readLayerMetadata
13
+ } from './gpkg-features.js';
14
+ import {
15
+ countBuildings,
16
+ openReadonlyGeoPackage,
17
+ readBuildingFootprints,
18
+ readBuildingsMetadata
19
+ } from './gpkg-buildings.js';
20
+ import { buildContentNode, buildTileset } from './tileset.js';
21
+
22
+ const DEFAULT_BUILDING_HEIGHT = 9;
23
+ const DEFAULT_MAX_FEATURES = 2500;
24
+ const DEFAULT_MAX_DEPTH = 4;
25
+ const SUPPORTED_3D_LAYERS = ['buildings', 'landuse', 'water', 'aip', 'railways', 'roads', 'boundaries'];
26
+ const LAYER_ALIASES = {
27
+ aviation: 'aip',
28
+ aip: 'aviation'
29
+ };
30
+
31
+ /**
32
+ * Export extruded buildings from a map-zero package to Cesium 3D Tiles.
33
+ *
34
+ * @param {{
35
+ * packageDir: string,
36
+ * out?: string,
37
+ * layers?: string[],
38
+ * maxDepth?: number,
39
+ * maxFeatures?: number,
40
+ * defaultHeight?: number,
41
+ * onProgress?: (event: {
42
+ * phase: 'estimate' | 'leaf' | 'done',
43
+ * layerId?: string,
44
+ * leafIndex?: number,
45
+ * leafCount?: number,
46
+ * featureCount?: number,
47
+ * writtenTiles?: number,
48
+ * skippedTiles?: number,
49
+ * outputBytes?: number
50
+ * }) => void
51
+ * }} options
52
+ * @returns {Promise<{
53
+ * outDir: string,
54
+ * tilesetPath: string,
55
+ * leafCount: number,
56
+ * writtenTiles: number,
57
+ * skippedTiles: number,
58
+ * outputBytes: number
59
+ * }>}
60
+ */
61
+ export async function export3dTiles(options) {
62
+ const layers = normalizeLayers(options.layers);
63
+ const packageDir = resolve(options.packageDir);
64
+ const manifestPath = join(packageDir, 'manifest.json');
65
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
66
+ const gpkgPath = join(packageDir, String(manifest.data ?? 'data.gpkg'));
67
+ const outRoot = resolve(options.out ?? join(packageDir, '3dtiles'));
68
+ const defaultHeight = positiveNumber(options.defaultHeight, DEFAULT_BUILDING_HEIGHT);
69
+ const maxFeatures = positiveInteger(options.maxFeatures, DEFAULT_MAX_FEATURES);
70
+ const maxDepth = nonNegativeInteger(options.maxDepth, DEFAULT_MAX_DEPTH);
71
+
72
+ validateManifest(manifest);
73
+ await fs.rm(outRoot, { recursive: true, force: true });
74
+ await fs.mkdir(outRoot, { recursive: true });
75
+ const style = await readDefaultStyle(packageDir, manifest);
76
+
77
+ const db = openReadonlyGeoPackage(gpkgPath);
78
+ try {
79
+ const exportedTilesets = {};
80
+ let totalLeaves = 0;
81
+ let writtenTiles = 0;
82
+ let skippedTiles = 0;
83
+ let outputBytes = 0;
84
+
85
+ for (const layerId of layers) {
86
+ const result = layerId === 'buildings'
87
+ ? await exportBuildingLayer(db, manifest, outRoot, {
88
+ defaultHeight,
89
+ maxFeatures,
90
+ maxDepth,
91
+ style,
92
+ onProgress: options.onProgress,
93
+ progressOffset: totalLeaves,
94
+ writtenTiles,
95
+ skippedTiles
96
+ })
97
+ : layerId === 'roads'
98
+ ? await exportRoadLayer(db, manifest, outRoot, {
99
+ maxFeatures,
100
+ maxDepth,
101
+ style,
102
+ onProgress: options.onProgress,
103
+ progressOffset: totalLeaves,
104
+ writtenTiles,
105
+ skippedTiles
106
+ })
107
+ : layerId === 'boundaries' || isAipLayer(layerId)
108
+ ? await exportMixedSurfaceLayer(db, manifest, outRoot, layerId, {
109
+ maxFeatures,
110
+ maxDepth,
111
+ style,
112
+ onProgress: options.onProgress,
113
+ progressOffset: totalLeaves,
114
+ writtenTiles,
115
+ skippedTiles
116
+ })
117
+ : layerId === 'railways'
118
+ ? await exportLineSurfaceLayer(db, manifest, outRoot, layerId, {
119
+ maxFeatures,
120
+ maxDepth,
121
+ style,
122
+ onProgress: options.onProgress,
123
+ progressOffset: totalLeaves,
124
+ writtenTiles,
125
+ skippedTiles
126
+ })
127
+ : await exportFlatLayer(db, manifest, outRoot, layerId, {
128
+ maxFeatures,
129
+ maxDepth,
130
+ style,
131
+ onProgress: options.onProgress,
132
+ progressOffset: totalLeaves,
133
+ writtenTiles,
134
+ skippedTiles
135
+ });
136
+
137
+ if (!result) {
138
+ continue;
139
+ }
140
+
141
+ exportedTilesets[layerId] = relative(packageDir, result.tilesetPath).replaceAll('\\', '/');
142
+ totalLeaves += result.leafCount;
143
+ writtenTiles += result.writtenTiles;
144
+ skippedTiles += result.skippedTiles;
145
+ outputBytes += result.outputBytes;
146
+ }
147
+
148
+ if (Object.keys(exportedTilesets).length === 0) {
149
+ throw new Error('no 3D Tiles were generated');
150
+ }
151
+
152
+ await updateManifestCesium(manifestPath, manifest, exportedTilesets, /** @type {[number, number, number, number]} */ (manifest.bbox));
153
+ options.onProgress?.({
154
+ phase: 'done',
155
+ leafCount: totalLeaves,
156
+ writtenTiles,
157
+ skippedTiles,
158
+ outputBytes
159
+ });
160
+
161
+ return {
162
+ outDir: outRoot,
163
+ tilesetPath: join(outRoot, layers[0], 'tileset.json'),
164
+ leafCount: totalLeaves,
165
+ writtenTiles,
166
+ skippedTiles,
167
+ outputBytes
168
+ };
169
+ } finally {
170
+ db.close();
171
+ }
172
+ }
173
+
174
+ async function exportBuildingLayer(db, manifest, outRoot, options) {
175
+ const metadata = readBuildingsMetadata(db, /** @type {[number, number, number, number]} */ (manifest.bbox));
176
+ const featureCount = countBuildings(db, metadata, metadata.bbox);
177
+ const leaves = buildLeafPlan(db, metadata, metadata.bbox, {
178
+ maxFeatures: options.maxFeatures,
179
+ maxDepth: options.maxDepth,
180
+ count: countBuildings
181
+ });
182
+ options.onProgress?.({ phase: 'estimate', layerId: 'buildings', leafCount: leaves.length, featureCount });
183
+
184
+ return exportLayerTiles('buildings', metadata.bbox, leaves, outRoot, options.style, {
185
+ readMesh: (leaf) => {
186
+ const { footprints, skipped } = readBuildingFootprints(db, metadata, leaf.bbox, {
187
+ defaultHeight: options.defaultHeight
188
+ });
189
+ return {
190
+ mesh: buildMergedExtrudedPolygonMesh(footprints),
191
+ featureCount: footprints.length,
192
+ skipped
193
+ };
194
+ }
195
+ }, options);
196
+ }
197
+
198
+ async function exportMixedSurfaceLayer(db, manifest, outRoot, layerId, options) {
199
+ let metadata;
200
+ try {
201
+ metadata = readLayerMetadata(db, manifest, layerId);
202
+ } catch (error) {
203
+ console.warn(`3D Tiles: skipping ${layerId}: ${error instanceof Error ? error.message : String(error)}`);
204
+ return null;
205
+ }
206
+
207
+ const featureCount = countLayerFeatures(db, metadata, metadata.bbox);
208
+ const leaves = buildLeafPlan(db, metadata, metadata.bbox, {
209
+ maxFeatures: options.maxFeatures,
210
+ maxDepth: options.maxDepth,
211
+ count: countLayerFeatures
212
+ });
213
+ options.onProgress?.({ phase: 'estimate', layerId, leafCount: leaves.length, featureCount });
214
+
215
+ return exportLayerTiles(layerId, metadata.bbox, leaves, outRoot, options.style, {
216
+ readMeshes: async (leaf) => {
217
+ const features = readLayerFeatures(db, metadata, leaf.bbox, {
218
+ limit: options.maxFeatures * 2
219
+ });
220
+ const polygonFeatures = features.filter(hasPolygonGeometry);
221
+ const pointFeatures = isAipLayer(layerId)
222
+ ? pointDiskFeatures(features.filter(isVisibleAviationPointFeature), 14, 14)
223
+ : [];
224
+ const lineFeatures = features.filter(hasLineGeometry);
225
+ const outlineFeatures = layerId === 'boundaries' || isAipLayer(layerId)
226
+ ? polygonFeatures
227
+ : [];
228
+ const lines = linesFromFeatures([...lineFeatures, ...outlineFeatures]);
229
+ const lineMesh = await buildClipperLineSurfaceMesh(lines, {
230
+ widthMeters: lineWidthMeters(layerId, options.style),
231
+ height: isAipLayer(layerId) ? 1.4 : 1.2,
232
+ scale: 100,
233
+ arcToleranceMeters: isAipLayer(layerId) ? 0.35 : 0.25,
234
+ cleanDistanceMeters: 0.05,
235
+ minSegmentMeters: isAipLayer(layerId) ? 0.5 : 0.35
236
+ });
237
+ const polygonMesh = layerId === 'boundaries'
238
+ ? null
239
+ : buildPolygonSurfaceMesh([...polygonFeatures, ...pointFeatures], {
240
+ height: isAipLayer(layerId) ? 1.1 : 0.25
241
+ });
242
+
243
+ return {
244
+ meshes: [
245
+ { id: 'fill', mesh: polygonMesh, color: colorFactorForLayer(options.style, layerId) },
246
+ { id: 'line', mesh: lineMesh, color: colorFactorForLayer(options.style, layerId) }
247
+ ].filter((entry) => entry.mesh),
248
+ featureCount: features.length,
249
+ skipped: 0
250
+ };
251
+ }
252
+ }, options);
253
+ }
254
+
255
+ async function exportFlatLayer(db, manifest, outRoot, layerId, options) {
256
+ let metadata;
257
+ try {
258
+ metadata = readLayerMetadata(db, manifest, layerId);
259
+ } catch (error) {
260
+ console.warn(`3D Tiles: skipping ${layerId}: ${error instanceof Error ? error.message : String(error)}`);
261
+ return null;
262
+ }
263
+
264
+ const featureCount = countLayerFeatures(db, metadata, metadata.bbox);
265
+ const leaves = buildLeafPlan(db, metadata, metadata.bbox, {
266
+ maxFeatures: options.maxFeatures,
267
+ maxDepth: options.maxDepth,
268
+ count: countLayerFeatures
269
+ });
270
+ options.onProgress?.({ phase: 'estimate', layerId, leafCount: leaves.length, featureCount });
271
+
272
+ return exportLayerTiles(layerId, metadata.bbox, leaves, outRoot, options.style, {
273
+ readMesh: (leaf) => {
274
+ const features = readLayerFeatures(db, metadata, leaf.bbox, {
275
+ limit: options.maxFeatures * 2
276
+ });
277
+ return {
278
+ mesh: buildFlatLayerMesh(layerId, features, {
279
+ lineWidthMeters: lineWidthMeters(layerId, options.style)
280
+ }),
281
+ featureCount: features.length,
282
+ skipped: 0
283
+ };
284
+ }
285
+ }, options);
286
+ }
287
+
288
+ async function exportRoadLayer(db, manifest, outRoot, options) {
289
+ let metadata;
290
+ try {
291
+ metadata = readLayerMetadata(db, manifest, 'roads');
292
+ } catch (error) {
293
+ console.warn(`3D Tiles: skipping roads: ${error instanceof Error ? error.message : String(error)}`);
294
+ return null;
295
+ }
296
+
297
+ const featureCount = countLayerFeatures(db, metadata, metadata.bbox);
298
+ const leaves = buildLeafPlan(db, metadata, metadata.bbox, {
299
+ maxFeatures: options.maxFeatures,
300
+ maxDepth: options.maxDepth,
301
+ count: countLayerFeatures
302
+ });
303
+ options.onProgress?.({ phase: 'estimate', layerId: 'roads', leafCount: leaves.length, featureCount });
304
+
305
+ return exportLayerTiles('roads', metadata.bbox, leaves, outRoot, options.style, {
306
+ readMeshes: async (leaf) => {
307
+ const features = readLayerFeatures(db, metadata, leaf.bbox, {
308
+ limit: options.maxFeatures * 2
309
+ });
310
+ const lines = linesFromFeatures(features);
311
+ const bodyWidth = roadBodyWidthMeters(options.style);
312
+ const body = await buildClipperLineSurfaceMesh(lines, {
313
+ widthMeters: bodyWidth,
314
+ height: 0.9,
315
+ scale: 100,
316
+ arcToleranceMeters: 0.45,
317
+ cleanDistanceMeters: 0.05,
318
+ minSegmentMeters: 0.75
319
+ });
320
+
321
+ return {
322
+ meshes: [{ id: 'main', mesh: body, color: colorFactorForLayer(options.style, 'roads') }].filter((entry) => entry.mesh),
323
+ featureCount: features.length,
324
+ skipped: features.length - lines.length
325
+ };
326
+ }
327
+ }, options);
328
+ }
329
+
330
+ async function exportLineSurfaceLayer(db, manifest, outRoot, layerId, options) {
331
+ let metadata;
332
+ try {
333
+ metadata = readLayerMetadata(db, manifest, layerId);
334
+ } catch (error) {
335
+ console.warn(`3D Tiles: skipping ${layerId}: ${error instanceof Error ? error.message : String(error)}`);
336
+ return null;
337
+ }
338
+
339
+ const featureCount = countLayerFeatures(db, metadata, metadata.bbox);
340
+ const leaves = buildLeafPlan(db, metadata, metadata.bbox, {
341
+ maxFeatures: options.maxFeatures,
342
+ maxDepth: options.maxDepth,
343
+ count: countLayerFeatures
344
+ });
345
+ options.onProgress?.({ phase: 'estimate', layerId, leafCount: leaves.length, featureCount });
346
+
347
+ return exportLayerTiles(layerId, metadata.bbox, leaves, outRoot, options.style, {
348
+ readMeshes: async (leaf) => {
349
+ const features = readLayerFeatures(db, metadata, leaf.bbox, {
350
+ limit: options.maxFeatures * 2
351
+ });
352
+ const lines = linesFromFeatures(features);
353
+ const mesh = await buildClipperLineSurfaceMesh(lines, {
354
+ widthMeters: lineWidthMeters(layerId, options.style),
355
+ height: layerId === 'railways' ? 0.82 : 0.7,
356
+ scale: 100,
357
+ arcToleranceMeters: layerId === 'railways' ? 0.4 : 0.25,
358
+ cleanDistanceMeters: 0.05,
359
+ minSegmentMeters: layerId === 'railways' ? 0.5 : 0.35
360
+ });
361
+
362
+ return {
363
+ meshes: [{ id: 'main', mesh, color: colorFactorForLayer(options.style, layerId) }].filter((entry) => entry.mesh),
364
+ featureCount: features.length,
365
+ skipped: features.length - lines.length
366
+ };
367
+ }
368
+ }, options);
369
+ }
370
+
371
+ async function exportLayerTiles(layerId, bbox, leaves, outRoot, style, source, options) {
372
+ const outDir = join(outRoot, layerId);
373
+ const tilesDir = join(outDir, 'tiles');
374
+ await fs.rm(outDir, { recursive: true, force: true });
375
+ await fs.mkdir(tilesDir, { recursive: true });
376
+
377
+ const children = [];
378
+ const tileBboxes = [];
379
+ let writtenTiles = 0;
380
+ let skippedTiles = 0;
381
+ let outputBytes = 0;
382
+ let maxHeight = layerId === 'buildings' ? options.defaultHeight : 1;
383
+
384
+ for (let i = 0; i < leaves.length; i++) {
385
+ const leaf = leaves[i];
386
+ const result = source.readMeshes ? await source.readMeshes(leaf) : source.readMesh(leaf);
387
+ const meshes = result.meshes ?? [{ id: 'main', mesh: result.mesh, color: colorFactorForLayer(style, layerId) }];
388
+ const validMeshes = meshes.filter((entry) => entry.mesh);
389
+ if (validMeshes.length === 0) {
390
+ skippedTiles++;
391
+ continue;
392
+ }
393
+
394
+ for (const entry of validMeshes) {
395
+ const mesh = entry.mesh;
396
+ const glb = buildGlbFromMesh(mesh, {
397
+ color: entry.color ?? colorFactorForLayer(style, layerId),
398
+ generator: `map-zero 3dtiles ${layerId}${entry.id ? ` ${entry.id}` : ''}`
399
+ });
400
+ const b3dm = buildB3dm(glb);
401
+ const tileName = entry.id === 'main'
402
+ ? `tile-${writtenTiles}.b3dm`
403
+ : `tile-${writtenTiles}-${entry.id}.b3dm`;
404
+ await fs.writeFile(join(tilesDir, tileName), b3dm);
405
+ outputBytes += b3dm.length;
406
+ writtenTiles++;
407
+ maxHeight = Math.max(maxHeight, mesh.maxHeight);
408
+ tileBboxes.push(mesh.bbox);
409
+ children.push(buildContentNode({
410
+ bbox: mesh.bbox,
411
+ maxHeight: mesh.maxHeight,
412
+ uri: `tiles/${tileName}`
413
+ }));
414
+ }
415
+
416
+ if (result.skipped > 0) {
417
+ console.warn(`3D Tiles: skipped ${result.skipped} invalid ${layerId} geometries in leaf ${i + 1}`);
418
+ }
419
+ options.onProgress?.({
420
+ phase: 'leaf',
421
+ layerId,
422
+ leafIndex: options.progressOffset + i + 1,
423
+ leafCount: options.progressOffset + leaves.length,
424
+ featureCount: result.featureCount,
425
+ writtenTiles: options.writtenTiles + writtenTiles,
426
+ skippedTiles: options.skippedTiles + skippedTiles
427
+ });
428
+ }
429
+
430
+ if (children.length === 0) {
431
+ return null;
432
+ }
433
+
434
+ const tileset = buildTileset({ bbox: mergeBboxes(tileBboxes) ?? bbox, maxHeight, children });
435
+ const tilesetPath = join(outDir, 'tileset.json');
436
+ await fs.writeFile(tilesetPath, `${JSON.stringify(tileset, null, 2)}\n`);
437
+ outputBytes += Buffer.byteLength(JSON.stringify(tileset));
438
+
439
+ return {
440
+ tilesetPath,
441
+ leafCount: leaves.length,
442
+ writtenTiles,
443
+ skippedTiles,
444
+ outputBytes
445
+ };
446
+ }
447
+
448
+ /**
449
+ * @param {import('better-sqlite3').Database} db
450
+ * @param {any} metadata
451
+ * @param {[number, number, number, number]} bbox
452
+ * @param {{ maxFeatures: number, maxDepth: number }} options
453
+ * @returns {Array<{ bbox: [number, number, number, number], count: number }>}
454
+ */
455
+ function buildLeafPlan(db, metadata, bbox, options) {
456
+ const leaves = [];
457
+ splitNode(bbox, 0);
458
+ return leaves;
459
+
460
+ /**
461
+ * @param {[number, number, number, number]} nodeBbox
462
+ * @param {number} depth
463
+ */
464
+ function splitNode(nodeBbox, depth) {
465
+ const count = options.count(db, metadata, nodeBbox);
466
+ if (count === 0) {
467
+ return;
468
+ }
469
+
470
+ if (count <= options.maxFeatures || depth >= options.maxDepth) {
471
+ leaves.push({ bbox: nodeBbox, count });
472
+ return;
473
+ }
474
+
475
+ for (const child of splitBbox(nodeBbox)) {
476
+ splitNode(child, depth + 1);
477
+ }
478
+ }
479
+ }
480
+
481
+ /**
482
+ * @param {[number, number, number, number]} bbox
483
+ * @returns {Array<[number, number, number, number]>}
484
+ */
485
+ function splitBbox(bbox) {
486
+ const [minLon, minLat, maxLon, maxLat] = bbox;
487
+ const midLon = (minLon + maxLon) / 2;
488
+ const midLat = (minLat + maxLat) / 2;
489
+ return [
490
+ [minLon, minLat, midLon, midLat],
491
+ [midLon, minLat, maxLon, midLat],
492
+ [minLon, midLat, midLon, maxLat],
493
+ [midLon, midLat, maxLon, maxLat]
494
+ ];
495
+ }
496
+
497
+ /**
498
+ * @param {Array<[number, number, number, number]>} bboxes
499
+ * @returns {[number, number, number, number] | null}
500
+ */
501
+ function mergeBboxes(bboxes) {
502
+ if (bboxes.length === 0) {
503
+ return null;
504
+ }
505
+
506
+ let minLon = Infinity;
507
+ let minLat = Infinity;
508
+ let maxLon = -Infinity;
509
+ let maxLat = -Infinity;
510
+ for (const bbox of bboxes) {
511
+ minLon = Math.min(minLon, bbox[0]);
512
+ minLat = Math.min(minLat, bbox[1]);
513
+ maxLon = Math.max(maxLon, bbox[2]);
514
+ maxLat = Math.max(maxLat, bbox[3]);
515
+ }
516
+ return [minLon, minLat, maxLon, maxLat];
517
+ }
518
+
519
+ /**
520
+ * @param {string | undefined} value
521
+ * @returns {string[]}
522
+ */
523
+ function normalizeLayers(value) {
524
+ if (!value) {
525
+ return [...SUPPORTED_3D_LAYERS];
526
+ }
527
+ const layers = Array.isArray(value) ? value : String(value).split(',');
528
+ const normalized = layers.map((layer) => normalizeLayerId(String(layer).trim())).filter(Boolean);
529
+ const supported = new Set(SUPPORTED_3D_LAYERS);
530
+ const unsupported = normalized.filter((layer) => !supported.has(layer));
531
+ if (unsupported.length > 0) {
532
+ throw new Error(`unsupported 3D layer(s): ${unsupported.join(', ')}`);
533
+ }
534
+ return normalized.length > 0 ? normalized : [...SUPPORTED_3D_LAYERS];
535
+ }
536
+
537
+ /**
538
+ * @param {string} layerId
539
+ * @returns {string}
540
+ */
541
+ function normalizeLayerId(layerId) {
542
+ return LAYER_ALIASES[layerId] ?? layerId;
543
+ }
544
+
545
+ /**
546
+ * @param {string} layerId
547
+ * @returns {boolean}
548
+ */
549
+ function isAipLayer(layerId) {
550
+ return layerId === 'aip' || layerId === 'aviation';
551
+ }
552
+
553
+ /**
554
+ * @param {Record<string, unknown>} manifest
555
+ */
556
+ function validateManifest(manifest) {
557
+ if (manifest.format !== 'mapzero') {
558
+ throw new Error('manifest format must be mapzero');
559
+ }
560
+
561
+ if (!validBbox(manifest.bbox)) {
562
+ throw new Error('manifest bbox is required for 3D Tiles export');
563
+ }
564
+ }
565
+
566
+ /**
567
+ * @param {string} manifestPath
568
+ * @param {Record<string, any>} manifest
569
+ * @param {Record<string, string>} tilesets
570
+ */
571
+ async function updateManifestCesium(manifestPath, manifest, tilesets, bbox) {
572
+ manifest.cesium = {
573
+ ...(manifest.cesium ?? {}),
574
+ bbox,
575
+ focusBbox: bbox,
576
+ tilesets
577
+ };
578
+ const firstEntry = Object.entries(tilesets)[0];
579
+ manifest.tiles3d = {
580
+ format: '3dtiles',
581
+ url: firstEntry?.[1],
582
+ layers: Object.keys(tilesets),
583
+ bbox,
584
+ focusBbox: bbox
585
+ };
586
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
587
+ }
588
+
589
+ async function readDefaultStyle(packageDir, manifest) {
590
+ const styleUrl = manifest.styles?.default;
591
+ if (typeof styleUrl !== 'string') {
592
+ return null;
593
+ }
594
+
595
+ try {
596
+ return JSON.parse(await fs.readFile(join(packageDir, styleUrl), 'utf8'));
597
+ } catch {
598
+ return null;
599
+ }
600
+ }
601
+
602
+ function colorFactorForLayer(style, layerId) {
603
+ const rule = style?.layers?.[layerId] ?? style?.layers?.[LAYER_ALIASES[layerId]] ?? {};
604
+ const color = rule.fill ?? rule.body?.color ?? rule.stroke ?? '#00ffff';
605
+ const opacity = Number(rule.fillOpacity ?? rule.body?.opacity ?? rule.strokeOpacity ?? 0.8);
606
+ return [...hexToRgb(color), Math.max(0.05, Math.min(1, Number.isFinite(opacity) ? opacity : 0.8))];
607
+ }
608
+
609
+ function lineWidthMeters(layerId, style) {
610
+ const rule = style?.layers?.[layerId] ?? style?.layers?.[LAYER_ALIASES[layerId]] ?? {};
611
+ const width = Number(rule.body?.width ?? rule.strokeWidth);
612
+ if (Number.isFinite(width) && width > 0) {
613
+ return Math.max(1.5, width * 2.2);
614
+ }
615
+ if (layerId === 'roads') return 6;
616
+ if (isAipLayer(layerId)) return 14;
617
+ if (layerId === 'railways') return 3;
618
+ if (layerId === 'boundaries') return 20;
619
+ return 2;
620
+ }
621
+
622
+ function roadBodyWidthMeters(style) {
623
+ const width = lineWidthMeters('roads', style);
624
+ return Math.max(5, width);
625
+ }
626
+
627
+ function linesFromFeatures(features) {
628
+ const lines = [];
629
+ for (const feature of features) {
630
+ lines.push(...linesFromGeometry(feature.geometry));
631
+ }
632
+ return lines.map(cleanLine).filter((line) => line.length >= 2);
633
+ }
634
+
635
+ function linesFromGeometry(geometry) {
636
+ if (geometry?.type === 'LineString' && Array.isArray(geometry.coordinates)) {
637
+ return [geometry.coordinates];
638
+ }
639
+ if (geometry?.type === 'MultiLineString' && Array.isArray(geometry.coordinates)) {
640
+ return geometry.coordinates;
641
+ }
642
+ return [];
643
+ }
644
+
645
+ function cleanLine(line) {
646
+ return line
647
+ .map((point) => [Number(point?.[0]), Number(point?.[1])])
648
+ .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
649
+ }
650
+
651
+ function hasLineGeometry(feature) {
652
+ return feature.geometry?.type === 'LineString' || feature.geometry?.type === 'MultiLineString';
653
+ }
654
+
655
+ function hasPolygonGeometry(feature) {
656
+ return feature.geometry?.type === 'Polygon' || feature.geometry?.type === 'MultiPolygon';
657
+ }
658
+
659
+ function isVisibleAviationPointFeature(feature) {
660
+ if (feature.geometry?.type !== 'Point' && feature.geometry?.type !== 'MultiPoint') {
661
+ return false;
662
+ }
663
+ const aeroway = String(feature.properties?.aeroway ?? '').toLowerCase();
664
+ return aeroway === 'helipad' || aeroway === 'aerodrome';
665
+ }
666
+
667
+ function pointDiskFeatures(features, radiusMeters, segments) {
668
+ const out = [];
669
+ for (const feature of features) {
670
+ for (const point of pointsFromGeometry(feature.geometry)) {
671
+ const disk = pointDiskPolygon(point, radiusMeters, segments);
672
+ if (disk) {
673
+ out.push({
674
+ type: 'Feature',
675
+ properties: feature.properties,
676
+ geometry: {
677
+ type: 'Polygon',
678
+ coordinates: [disk]
679
+ }
680
+ });
681
+ }
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+
687
+ function pointsFromGeometry(geometry) {
688
+ if (geometry?.type === 'Point' && Array.isArray(geometry.coordinates)) {
689
+ return [geometry.coordinates];
690
+ }
691
+ if (geometry?.type === 'MultiPoint' && Array.isArray(geometry.coordinates)) {
692
+ return geometry.coordinates;
693
+ }
694
+ return [];
695
+ }
696
+
697
+ function pointDiskPolygon(point, radiusMeters, segments) {
698
+ const lon = Number(point?.[0]);
699
+ const lat = Number(point?.[1]);
700
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
701
+ return null;
702
+ }
703
+ const clampedSegments = Math.max(8, Math.min(32, Math.floor(segments)));
704
+ const metersPerLon = Math.max(1, 111320 * Math.cos(lat * Math.PI / 180));
705
+ const metersPerLat = 110540;
706
+ const ring = [];
707
+ for (let i = 0; i < clampedSegments; i++) {
708
+ const angle = i / clampedSegments * Math.PI * 2;
709
+ ring.push([
710
+ lon + Math.cos(angle) * radiusMeters / metersPerLon,
711
+ lat + Math.sin(angle) * radiusMeters / metersPerLat
712
+ ]);
713
+ }
714
+ ring.push(ring[0]);
715
+ return ring;
716
+ }
717
+
718
+ function hexToRgb(value) {
719
+ const color = /^#?([0-9a-f]{6})$/i.exec(String(value));
720
+ const hex = color?.[1] ?? '00ffff';
721
+ return [
722
+ Number.parseInt(hex.slice(0, 2), 16) / 255,
723
+ Number.parseInt(hex.slice(2, 4), 16) / 255,
724
+ Number.parseInt(hex.slice(4, 6), 16) / 255
725
+ ];
726
+ }
727
+
728
+ /**
729
+ * @param {unknown} value
730
+ * @param {number} fallback
731
+ * @returns {number}
732
+ */
733
+ function positiveNumber(value, fallback) {
734
+ const number = Number(value);
735
+ return Number.isFinite(number) && number > 0 ? number : fallback;
736
+ }
737
+
738
+ /**
739
+ * @param {unknown} value
740
+ * @param {number} fallback
741
+ * @returns {number}
742
+ */
743
+ function positiveInteger(value, fallback) {
744
+ const number = Number(value);
745
+ return Number.isInteger(number) && number > 0 ? number : fallback;
746
+ }
747
+
748
+ /**
749
+ * @param {unknown} value
750
+ * @param {number} fallback
751
+ * @returns {number}
752
+ */
753
+ function nonNegativeInteger(value, fallback) {
754
+ const number = Number(value);
755
+ return Number.isInteger(number) && number >= 0 ? number : fallback;
756
+ }
757
+
758
+ /**
759
+ * @param {unknown} value
760
+ * @returns {boolean}
761
+ */
762
+ function validBbox(value) {
763
+ return Array.isArray(value) &&
764
+ value.length === 4 &&
765
+ value.every((part) => Number.isFinite(Number(part))) &&
766
+ Number(value[0]) < Number(value[2]) &&
767
+ Number(value[1]) < Number(value[3]);
768
+ }