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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/docs/api.md +66 -0
- package/docs/architecture.md +87 -0
- package/docs/cartography.md +77 -0
- package/docs/cesium.md +107 -0
- package/docs/openlayers.md +98 -0
- package/docs/styles.md +103 -0
- package/package.json +51 -0
- package/packages/cesium/package.json +13 -0
- package/packages/cesium/src/index.js +405 -0
- package/packages/ol/package.json +14 -0
- package/packages/ol/src/index.js +1705 -0
- package/packages/ol/src/labels.js +977 -0
- package/src/3dtiles/b3dm.js +38 -0
- package/src/3dtiles/clipper-surfaces.js +317 -0
- package/src/3dtiles/export.js +768 -0
- package/src/3dtiles/extrude.js +301 -0
- package/src/3dtiles/flat.js +531 -0
- package/src/3dtiles/glb.js +178 -0
- package/src/3dtiles/gpkg-buildings.js +240 -0
- package/src/3dtiles/gpkg-features.js +157 -0
- package/src/3dtiles/tileset.js +75 -0
- package/src/build.js +134 -0
- package/src/cli.js +656 -0
- package/src/export-pmtiles.js +962 -0
- package/src/geometry-read.js +50 -0
- package/src/gpkg-read.js +460 -0
- package/src/gpkg.js +567 -0
- package/src/html.js +593 -0
- package/src/layers.js +357 -0
- package/src/manifest.js +29 -0
- package/src/mvt.js +2593 -0
- package/src/ol.js +5 -0
- package/src/osm.js +2110 -0
- package/src/pmtiles-worker.js +70 -0
- package/src/pmtiles.js +260 -0
- package/src/server.js +720 -0
- package/src/style-command.js +78 -0
- package/src/style-filters.js +76 -0
- package/src/style-presets.js +93 -0
- package/src/style-themes.js +235 -0
- package/src/style.js +13 -0
- package/src/tile-cache.js +59 -0
- package/src/utils.js +222 -0
- package/styles/presets/light.json +4655 -0
- package/styles/presets/monochrome.json +4655 -0
- package/styles/presets/neon-dark-3d.json +90 -0
- package/styles/presets/neon-dark.json +4690 -0
- package/styles/presets/tactical.json +4690 -0
- 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
|
+
}
|