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.
@@ -1,4 +1,12 @@
1
1
  import earcut from 'earcut';
2
+ import { localizeEcefPositions } from './precision.js';
3
+
4
+ /**
5
+ * Converts 2D building footprints in lon/lat coordinates into localized ECEF
6
+ * meshes suitable for Cesium 3D Tiles. This module owns the building-specific
7
+ * extrusion path: roof/bottom triangulation, wall generation, normals, and
8
+ * WGS84 coordinate conversion.
9
+ */
2
10
 
3
11
  const WGS84_A = 6378137.0;
4
12
  const WGS84_F = 1 / 298.257223563;
@@ -12,6 +20,7 @@ const WGS84_E2 = WGS84_F * (2 - WGS84_F);
12
20
  * indices: Uint16Array | Uint32Array,
13
21
  * min: [number, number, number],
14
22
  * max: [number, number, number],
23
+ * rtcCenter: [number, number, number],
15
24
  * bbox: [number, number, number, number],
16
25
  * maxHeight: number,
17
26
  * featureCount: number
@@ -19,7 +28,11 @@ const WGS84_E2 = WGS84_F * (2 - WGS84_F);
19
28
  */
20
29
 
21
30
  /**
22
- * Build one merged mesh from building footprints.
31
+ * Build one localized mesh from many independent building footprints.
32
+ *
33
+ * Each footprint is extruded separately, then all vertices are merged before
34
+ * being shifted around a shared RTC center. The shared center keeps float32
35
+ * positions precise enough for Cesium at city scale.
23
36
  *
24
37
  * @param {Footprint[]} footprints
25
38
  * @param {{ baseHeight?: number }} [options]
@@ -55,19 +68,19 @@ export function buildMergedExtrudedPolygonMesh(footprints, options = {}) {
55
68
  return null;
56
69
  }
57
70
 
58
- const positionArray = new Float32Array(positions);
71
+ const localized = localizeEcefPositions(positions);
59
72
  const normalArray = new Float32Array(normals);
60
- const indexArray = positionArray.length / 3 > 65535
73
+ const indexArray = localized.positions.length / 3 > 65535
61
74
  ? new Uint32Array(indices)
62
75
  : new Uint16Array(indices);
63
- const bounds = minMaxVec3(positionArray);
64
76
 
65
77
  return {
66
- positions: positionArray,
78
+ positions: localized.positions,
67
79
  normals: normalArray,
68
80
  indices: indexArray,
69
- min: bounds.min,
70
- max: bounds.max,
81
+ min: localized.min,
82
+ max: localized.max,
83
+ rtcCenter: localized.rtcCenter,
71
84
  bbox: mergeBboxes(bboxes),
72
85
  maxHeight,
73
86
  featureCount
@@ -75,6 +88,12 @@ export function buildMergedExtrudedPolygonMesh(footprints, options = {}) {
75
88
  }
76
89
 
77
90
  /**
91
+ * Extrude one footprint ring into roof, bottom, and wall triangles.
92
+ *
93
+ * The input ring is expected in lon/lat degrees. Heights are meters above the
94
+ * ellipsoid. The returned positions remain absolute ECEF numbers; localization
95
+ * happens in buildMergedExtrudedPolygonMesh.
96
+ *
78
97
  * @param {Array<[number, number]>} ring
79
98
  * @param {number} baseHeight
80
99
  * @param {number} topHeight
@@ -137,6 +156,9 @@ export function buildExtrudedPolygonMesh(ring, baseHeight, topHeight) {
137
156
  }
138
157
 
139
158
  /**
159
+ * Normalize a polygon ring to finite lon/lat pairs and remove the repeated
160
+ * closing coordinate when present.
161
+ *
140
162
  * @param {Array<[number, number]>} ring
141
163
  * @returns {Array<[number, number]>}
142
164
  */
@@ -162,6 +184,9 @@ export function cleanRing(ring) {
162
184
  }
163
185
 
164
186
  /**
187
+ * Project lon/lat points around a local centroid into an approximate meter
188
+ * plane for earcut triangulation.
189
+ *
165
190
  * @param {Array<[number, number]>} points
166
191
  * @param {[number, number]} centroid
167
192
  * @returns {{ flat: number[] }}
@@ -178,6 +203,8 @@ function projectRing(points, centroid) {
178
203
  }
179
204
 
180
205
  /**
206
+ * Return a simple arithmetic lon/lat centroid for small local rings.
207
+ *
181
208
  * @param {Array<[number, number]>} points
182
209
  * @returns {[number, number]}
183
210
  */
@@ -192,6 +219,9 @@ function polygonCentroid(points) {
192
219
  }
193
220
 
194
221
  /**
222
+ * Compute a lon/lat bbox for a footprint and pad it slightly so child tile
223
+ * bounding volumes are not exactly coplanar with feature edges.
224
+ *
195
225
  * @param {Array<[number, number]>} points
196
226
  * @returns {[number, number, number, number]}
197
227
  */
@@ -211,6 +241,8 @@ function polygonBbox(points) {
211
241
  }
212
242
 
213
243
  /**
244
+ * Merge multiple lon/lat bounding boxes.
245
+ *
214
246
  * @param {Array<[number, number, number, number]>} bboxes
215
247
  * @returns {[number, number, number, number]}
216
248
  */
@@ -229,31 +261,31 @@ function mergeBboxes(bboxes) {
229
261
  }
230
262
 
231
263
  /**
232
- * @param {Float32Array} positions
233
- * @returns {{ min: [number, number, number], max: [number, number, number] }}
264
+ * Compute a unit normal from three ECEF triangle vertices.
265
+ *
266
+ * @param {[number, number, number]} a
267
+ * @param {[number, number, number]} b
268
+ * @param {[number, number, number]} c
269
+ * @returns {[number, number, number]}
234
270
  */
235
- function minMaxVec3(positions) {
236
- const min = [Infinity, Infinity, Infinity];
237
- const max = [-Infinity, -Infinity, -Infinity];
238
- for (let i = 0; i < positions.length; i += 3) {
239
- min[0] = Math.min(min[0], positions[i]);
240
- min[1] = Math.min(min[1], positions[i + 1]);
241
- min[2] = Math.min(min[2], positions[i + 2]);
242
- max[0] = Math.max(max[0], positions[i]);
243
- max[1] = Math.max(max[1], positions[i + 1]);
244
- max[2] = Math.max(max[2], positions[i + 2]);
245
- }
246
- return { min, max };
247
- }
248
-
249
271
  function triangleNormal(a, b, c) {
250
272
  return normalize(cross(subtract(b, a), subtract(c, a)));
251
273
  }
252
274
 
275
+ /**
276
+ * @param {[number, number, number]} a
277
+ * @param {[number, number, number]} b
278
+ * @returns {[number, number, number]}
279
+ */
253
280
  function subtract(a, b) {
254
281
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
255
282
  }
256
283
 
284
+ /**
285
+ * @param {[number, number, number]} a
286
+ * @param {[number, number, number]} b
287
+ * @returns {[number, number, number]}
288
+ */
257
289
  function cross(a, b) {
258
290
  return [
259
291
  a[1] * b[2] - a[2] * b[1],
@@ -262,6 +294,10 @@ function cross(a, b) {
262
294
  ];
263
295
  }
264
296
 
297
+ /**
298
+ * @param {[number, number, number]} vector
299
+ * @returns {[number, number, number]}
300
+ */
265
301
  function normalize(vector) {
266
302
  const length = Math.hypot(vector[0], vector[1], vector[2]);
267
303
  if (!Number.isFinite(length) || length === 0) {
@@ -270,6 +306,13 @@ function normalize(vector) {
270
306
  return [vector[0] / length, vector[1] / length, vector[2] / length];
271
307
  }
272
308
 
309
+ /**
310
+ * Return the geodetic up vector for a lon/lat coordinate.
311
+ *
312
+ * @param {number} lonDeg
313
+ * @param {number} latDeg
314
+ * @returns {[number, number, number]}
315
+ */
273
316
  export function wgs84SurfaceNormal(lonDeg, latDeg) {
274
317
  const lonRad = degToRad(lonDeg);
275
318
  const latRad = degToRad(latDeg);
@@ -281,6 +324,14 @@ export function wgs84SurfaceNormal(lonDeg, latDeg) {
281
324
  ];
282
325
  }
283
326
 
327
+ /**
328
+ * Convert lon/lat/height to Earth-Centered, Earth-Fixed coordinates.
329
+ *
330
+ * @param {number} lonDeg
331
+ * @param {number} latDeg
332
+ * @param {number} h
333
+ * @returns {[number, number, number]}
334
+ */
284
335
  export function wgs84ToEcef(lonDeg, latDeg, h) {
285
336
  const lonRad = degToRad(lonDeg);
286
337
  const latRad = degToRad(latDeg);
@@ -296,6 +347,10 @@ export function wgs84ToEcef(lonDeg, latDeg, h) {
296
347
  ];
297
348
  }
298
349
 
350
+ /**
351
+ * @param {number} value
352
+ * @returns {number}
353
+ */
299
354
  function degToRad(value) {
300
355
  return value * Math.PI / 180;
301
356
  }
@@ -1,6 +1,7 @@
1
1
  import earcut from 'earcut';
2
2
 
3
3
  import { cleanRing, wgs84SurfaceNormal, wgs84ToEcef } from './extrude.js';
4
+ import { localizeEcefPositions } from './precision.js';
4
5
 
5
6
  /**
6
7
  * @typedef {{
@@ -9,6 +10,7 @@ import { cleanRing, wgs84SurfaceNormal, wgs84ToEcef } from './extrude.js';
9
10
  * indices: Uint16Array,
10
11
  * min: [number, number, number],
11
12
  * max: [number, number, number],
13
+ * rtcCenter: [number, number, number],
12
14
  * bbox: [number, number, number, number],
13
15
  * maxHeight: number,
14
16
  * featureCount: number
@@ -176,20 +178,20 @@ function finishMesh(positions, normals, bboxes, height, featureCount, indices =
176
178
  return null;
177
179
  }
178
180
 
179
- const positionArray = new Float32Array(positions);
181
+ const localized = localizeEcefPositions(positions);
180
182
  const normalArray = new Float32Array(normals);
181
183
  const indexArray = indices.length > 0
182
- ? positionArray.length / 3 > 65535
184
+ ? localized.positions.length / 3 > 65535
183
185
  ? new Uint32Array(indices)
184
186
  : new Uint16Array(indices)
185
187
  : new Uint16Array(0);
186
- const bounds = minMaxVec3(positionArray);
187
188
  return {
188
- positions: positionArray,
189
+ positions: localized.positions,
189
190
  normals: normalArray,
190
191
  indices: indexArray,
191
- min: bounds.min,
192
- max: bounds.max,
192
+ min: localized.min,
193
+ max: localized.max,
194
+ rtcCenter: localized.rtcCenter,
193
195
  bbox: mergeBboxes(bboxes),
194
196
  maxHeight: height,
195
197
  featureCount
@@ -515,17 +517,3 @@ function mergeBboxes(bboxes) {
515
517
  }
516
518
  return [minLon, minLat, maxLon, maxLat];
517
519
  }
518
-
519
- function minMaxVec3(positions) {
520
- const min = [Infinity, Infinity, Infinity];
521
- const max = [-Infinity, -Infinity, -Infinity];
522
- for (let i = 0; i < positions.length; i += 3) {
523
- min[0] = Math.min(min[0], positions[i]);
524
- min[1] = Math.min(min[1], positions[i + 1]);
525
- min[2] = Math.min(min[2], positions[i + 2]);
526
- max[0] = Math.max(max[0], positions[i]);
527
- max[1] = Math.max(max[1], positions[i + 1]);
528
- max[2] = Math.max(max[2], positions[i + 2]);
529
- }
530
- return { min, max };
531
- }
@@ -1,4 +1,11 @@
1
1
  /**
2
+ * Minimal glTF/GLB writer used by the 3D Tiles exporter.
3
+ *
4
+ * It receives already-built mesh buffers and writes a single-scene, single-mesh
5
+ * GLB. The writer intentionally stays small: no animations, textures, feature
6
+ * tables, or material variants. Cesium styling is applied outside the GLB when
7
+ * possible.
8
+ *
2
9
  * @typedef {{
3
10
  * positions: Float32Array,
4
11
  * normals?: Float32Array,
@@ -9,19 +16,19 @@
9
16
  */
10
17
 
11
18
  /**
12
- * Build a minimal unlit GLB for one merged mesh.
19
+ * Build a minimal GLB for one merged mesh.
13
20
  *
14
21
  * @param {Mesh} mesh
15
- * @param {{ color?: [number, number, number, number], generator?: string, includeNormals?: boolean }} [options]
22
+ * @param {{ color?: [number, number, number, number], generator?: string, includeNormals?: boolean, quantizeNormals?: boolean, doubleSided?: boolean, unlit?: boolean }} [options]
16
23
  * @returns {Buffer}
17
24
  */
18
25
  export function buildGlbFromMesh(mesh, options = {}) {
19
- const chunks = [];
26
+ const binBuilder = { chunks: [], byteLength: 0 };
20
27
  const bufferViews = [];
21
28
  const accessors = [];
22
29
  const attributes = {};
23
30
 
24
- const positionView = appendBuffer(chunks, bufferFromTypedArray(mesh.positions), 4);
31
+ const positionView = appendBuffer(binBuilder, bufferFromTypedArray(mesh.positions), 4);
25
32
  bufferViews.push({ buffer: 0, byteOffset: positionView.byteOffset, byteLength: positionView.byteLength, target: 34962 });
26
33
  attributes.POSITION = accessors.length;
27
34
  accessors.push({
@@ -38,22 +45,26 @@ export function buildGlbFromMesh(mesh, options = {}) {
38
45
  && mesh.normals
39
46
  && mesh.normals.length === mesh.positions.length;
40
47
  if (includeNormals) {
41
- const normalView = appendBuffer(chunks, bufferFromTypedArray(mesh.normals), 4);
48
+ const normals = options.quantizeNormals === false
49
+ ? mesh.normals
50
+ : quantizeNormals(mesh.normals);
51
+ const normalView = appendBuffer(binBuilder, bufferFromTypedArray(normals), 4);
42
52
  bufferViews.push({ buffer: 0, byteOffset: normalView.byteOffset, byteLength: normalView.byteLength, target: 34962 });
43
53
  attributes.NORMAL = accessors.length;
44
54
  accessors.push({
45
55
  bufferView: bufferViews.length - 1,
46
56
  byteOffset: 0,
47
- componentType: 5126,
48
- count: mesh.normals.length / 3,
49
- type: 'VEC3'
57
+ componentType: normals instanceof Float32Array ? 5126 : 5120,
58
+ count: normals.length / 3,
59
+ type: 'VEC3',
60
+ ...(normals instanceof Float32Array ? {} : { normalized: true })
50
61
  });
51
62
  }
52
63
 
53
64
  const hasIndices = mesh.indices.length > 0;
54
65
  let indexAccessor = null;
55
66
  if (hasIndices) {
56
- const indexView = appendBuffer(chunks, bufferFromTypedArray(mesh.indices), 4);
67
+ const indexView = appendBuffer(binBuilder, bufferFromTypedArray(mesh.indices), 4);
57
68
  bufferViews.push({ buffer: 0, byteOffset: indexView.byteOffset, byteLength: indexView.byteLength, target: 34963 });
58
69
  indexAccessor = accessors.length;
59
70
  accessors.push({
@@ -65,7 +76,7 @@ export function buildGlbFromMesh(mesh, options = {}) {
65
76
  });
66
77
  }
67
78
 
68
- const bin = Buffer.concat(chunks);
79
+ const bin = Buffer.concat(binBuilder.chunks);
69
80
  const primitive = {
70
81
  attributes,
71
82
  material: 0
@@ -74,38 +85,47 @@ export function buildGlbFromMesh(mesh, options = {}) {
74
85
  primitive.indices = indexAccessor;
75
86
  }
76
87
 
88
+ const unlit = options.unlit !== false;
89
+ const material = {
90
+ pbrMetallicRoughness: {
91
+ baseColorFactor: options.color ?? [0, 1, 1, 1],
92
+ metallicFactor: 0,
93
+ roughnessFactor: 1
94
+ },
95
+ doubleSided: options.doubleSided === true
96
+ };
97
+ if (unlit) {
98
+ material.extensions = {
99
+ KHR_materials_unlit: {}
100
+ };
101
+ }
102
+
77
103
  const gltf = {
78
104
  asset: {
79
105
  version: '2.0',
80
106
  generator: options.generator ?? 'map-zero'
81
107
  },
82
- extensionsUsed: ['KHR_materials_unlit'],
83
108
  scene: 0,
84
109
  scenes: [{ nodes: [0] }],
85
110
  nodes: [{ mesh: 0 }],
86
111
  meshes: [{
87
112
  primitives: [primitive]
88
113
  }],
89
- materials: [{
90
- pbrMetallicRoughness: {
91
- baseColorFactor: options.color ?? [0, 1, 1, 1],
92
- metallicFactor: 0,
93
- roughnessFactor: 1
94
- },
95
- extensions: {
96
- KHR_materials_unlit: {}
97
- },
98
- doubleSided: true
99
- }],
114
+ materials: [material],
100
115
  buffers: [{ byteLength: bin.length }],
101
116
  bufferViews,
102
117
  accessors
103
118
  };
119
+ if (unlit) {
120
+ gltf.extensionsUsed = ['KHR_materials_unlit'];
121
+ }
104
122
 
105
123
  return buildGlb(gltf, bin);
106
124
  }
107
125
 
108
126
  /**
127
+ * Convert any typed array view into a Buffer over the same underlying memory.
128
+ *
109
129
  * @param {ArrayBufferView} typedArray
110
130
  * @returns {Buffer}
111
131
  */
@@ -114,6 +134,25 @@ function bufferFromTypedArray(typedArray) {
114
134
  }
115
135
 
116
136
  /**
137
+ * Store unit normals as normalized signed bytes. This keeps the lighting
138
+ * direction while cutting the normal attribute from 12 bytes to 3 bytes per
139
+ * vertex, which matters for dense building tiles.
140
+ *
141
+ * @param {Float32Array} normals
142
+ * @returns {Int8Array}
143
+ */
144
+ function quantizeNormals(normals) {
145
+ const quantized = new Int8Array(normals.length);
146
+ for (let i = 0; i < normals.length; i++) {
147
+ const value = Math.max(-1, Math.min(1, Number.isFinite(normals[i]) ? normals[i] : 0));
148
+ quantized[i] = Math.round(value * 127);
149
+ }
150
+ return quantized;
151
+ }
152
+
153
+ /**
154
+ * Serialize a glTF JSON document and binary payload into GLB v2 layout.
155
+ *
117
156
  * @param {Record<string, unknown>} gltf
118
157
  * @param {Buffer} binBuffer
119
158
  * @returns {Buffer}
@@ -136,19 +175,27 @@ export function buildGlb(gltf, binBuffer) {
136
175
  }
137
176
 
138
177
  /**
139
- * @param {Buffer[]} chunks
178
+ * Append one binary buffer to the BIN chunk with the requested alignment.
179
+ *
180
+ * Tracking byteLength avoids repeatedly scanning all previous chunks while
181
+ * assembling large city tiles.
182
+ *
183
+ * @param {{ chunks: Buffer[], byteLength: number }} builder
140
184
  * @param {Buffer} buffer
141
185
  * @param {number} alignment
142
186
  * @returns {{ byteOffset: number, byteLength: number }}
143
187
  */
144
- function appendBuffer(chunks, buffer, alignment) {
145
- const byteOffset = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
188
+ function appendBuffer(builder, buffer, alignment) {
189
+ const byteOffset = builder.byteLength;
146
190
  const paddedOffset = align(byteOffset, alignment);
147
191
  if (paddedOffset > byteOffset) {
148
- chunks.push(Buffer.alloc(paddedOffset - byteOffset));
192
+ const padding = Buffer.alloc(paddedOffset - byteOffset);
193
+ builder.chunks.push(padding);
194
+ builder.byteLength += padding.length;
149
195
  }
150
196
 
151
- chunks.push(buffer);
197
+ builder.chunks.push(buffer);
198
+ builder.byteLength += buffer.length;
152
199
  return {
153
200
  byteOffset: paddedOffset,
154
201
  byteLength: buffer.length
@@ -156,6 +203,8 @@ function appendBuffer(chunks, buffer, alignment) {
156
203
  }
157
204
 
158
205
  /**
206
+ * Pad a GLB chunk to the required byte alignment.
207
+ *
159
208
  * @param {Buffer} buffer
160
209
  * @param {number} alignment
161
210
  * @param {number} fill
@@ -169,6 +218,8 @@ function padBuffer(buffer, alignment, fill) {
169
218
  }
170
219
 
171
220
  /**
221
+ * Round a byte length up to the next multiple of alignment.
222
+ *
172
223
  * @param {number} value
173
224
  * @param {number} alignment
174
225
  * @returns {number}
@@ -24,14 +24,14 @@ const LAYER_ALIASES = {
24
24
  */
25
25
  export function readLayerMetadata(db, manifest, layerId) {
26
26
  const manifestLayer = Array.isArray(manifest.layers)
27
- ? manifest.layers.find((layer) => layer?.id === layerId) ??
28
- manifest.layers.find((layer) => layer?.id === LAYER_ALIASES[layerId])
27
+ ? manifest.layers.find((layer) => layer === layerId) ??
28
+ manifest.layers.find((layer) => layer === LAYER_ALIASES[layerId])
29
29
  : null;
30
- if (!manifestLayer?.table) {
30
+ if (!manifestLayer) {
31
31
  throw new Error(`manifest does not contain layer: ${layerId}`);
32
32
  }
33
33
 
34
- const table = String(manifestLayer.table);
34
+ const table = String(manifestLayer);
35
35
  const geometry = db.prepare(`
36
36
  SELECT column_name
37
37
  FROM gpkg_geometry_columns
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Convert absolute ECEF positions to local tile coordinates before storing them
3
+ * as Float32. Absolute ECEF values are around millions of meters, where Float32
4
+ * precision is too coarse for flat terrain meshes.
5
+ *
6
+ * @param {number[]} positions
7
+ * @returns {{ positions: Float32Array, min: [number, number, number], max: [number, number, number], rtcCenter: [number, number, number] }}
8
+ */
9
+ export function localizeEcefPositions(positions) {
10
+ const absoluteBounds = minMaxVec3(positions);
11
+ const rtcCenter = [
12
+ (absoluteBounds.min[0] + absoluteBounds.max[0]) / 2,
13
+ (absoluteBounds.min[1] + absoluteBounds.max[1]) / 2,
14
+ (absoluteBounds.min[2] + absoluteBounds.max[2]) / 2
15
+ ];
16
+ const local = new Float32Array(positions.length);
17
+ for (let i = 0; i < positions.length; i += 3) {
18
+ local[i] = positions[i] - rtcCenter[0];
19
+ local[i + 1] = positions[i + 1] - rtcCenter[1];
20
+ local[i + 2] = positions[i + 2] - rtcCenter[2];
21
+ }
22
+ const localBounds = minMaxVec3(local);
23
+ return {
24
+ positions: local,
25
+ min: localBounds.min,
26
+ max: localBounds.max,
27
+ rtcCenter
28
+ };
29
+ }
30
+
31
+ /**
32
+ * @param {ArrayLike<number>} positions
33
+ * @returns {{ min: [number, number, number], max: [number, number, number] }}
34
+ */
35
+ export function minMaxVec3(positions) {
36
+ const min = [Infinity, Infinity, Infinity];
37
+ const max = [-Infinity, -Infinity, -Infinity];
38
+ for (let i = 0; i < positions.length; i += 3) {
39
+ min[0] = Math.min(min[0], positions[i]);
40
+ min[1] = Math.min(min[1], positions[i + 1]);
41
+ min[2] = Math.min(min[2], positions[i + 2]);
42
+ max[0] = Math.max(max[0], positions[i]);
43
+ max[1] = Math.max(max[1], positions[i + 1]);
44
+ max[2] = Math.max(max[2], positions[i + 2]);
45
+ }
46
+ return { min, max };
47
+ }
package/src/cli.js CHANGED
@@ -6,7 +6,9 @@ import { Command, InvalidArgumentError } from 'commander';
6
6
  import { buildPackage } from './build.js';
7
7
  import { export3dTiles } from './3dtiles/export.js';
8
8
  import { exportPmtiles } from './export-pmtiles.js';
9
+ import { createPackageFromBbox } from './from-bbox.js';
9
10
  import { LAYER_ALIASES, SUPPORTED_LAYERS } from './layers.js';
11
+ import { packageMapZero } from './package.js';
10
12
  import { serveMapZero } from './server.js';
11
13
  import { availableStylePresets, availableStyleThemes, writePackageStyle } from './style-command.js';
12
14
  import { parseBbox, parseLayerList } from './utils.js';
@@ -55,6 +57,77 @@ program
55
57
  }
56
58
  });
57
59
 
60
+ program
61
+ .command('from-bbox')
62
+ .description('Download an OSM extract and build a complete map-zero package for a bbox.')
63
+ .requiredOption('--bbox <bbox>', 'minLon,minLat,maxLon,maxLat', parseBboxOption)
64
+ .requiredOption('--out <output.mapzero>', 'output package folder')
65
+ .option('--layers <layers>', 'comma-separated logical layers; defaults to all supported layers', parseLayersOption)
66
+ .option('--minzoom <zoom>', 'minimum PMTiles zoom to export', parseZoomOption, 8)
67
+ .option('--maxzoom <zoom>', 'maximum PMTiles zoom to export', parseZoomOption, 16)
68
+ .option('--workers <count>', 'parallel PMTiles tile generation workers', parsePositiveIntegerOption, 1)
69
+ .option('--force-pmtiles', 'allow very large PMTiles exports')
70
+ .option('--cache-dir <dir>', 'OSM extract cache directory; defaults to ~/.cache/map-zero/osm')
71
+ .option('--provider-index-url <url>', 'Geofabrik-compatible index URL')
72
+ .option('--force-download', 're-download the selected OSM extract even if cached')
73
+ .option('--batch-size <count>', 'geometry build batch size', parsePositiveIntegerOption, 5000)
74
+ .option('--keep-temp', 'keep the temporary SQLite build database')
75
+ .option('--debug-build', 'show build memory usage in progress logs')
76
+ .option('--no-pmtiles', 'skip PMTiles export')
77
+ .option('--no-3dtiles', 'skip 3D Tiles export')
78
+ .option('--no-zip', 'skip portable zip creation')
79
+ .option('--include-gpkg', 'include data.gpkg in the portable zip')
80
+ .action(async (options) => {
81
+ const buildProgress = createBuildProgressReporter();
82
+ try {
83
+ const result = await createPackageFromBbox({
84
+ bbox: options.bbox,
85
+ out: options.out,
86
+ layers: options.layers ?? [...SUPPORTED_LAYERS],
87
+ minZoom: options.minzoom,
88
+ maxZoom: options.maxzoom,
89
+ workers: options.workers,
90
+ forcePmtiles: Boolean(options.forcePmtiles),
91
+ cacheDir: options.cacheDir,
92
+ providerIndexUrl: options.providerIndexUrl,
93
+ forceDownload: Boolean(options.forceDownload),
94
+ batchSize: options.batchSize,
95
+ keepTemp: Boolean(options.keepTemp),
96
+ debugBuild: Boolean(options.debugBuild),
97
+ pmtiles: options.pmtiles,
98
+ tiles3d: options.tiles3d,
99
+ zip: options.zip,
100
+ includeGpkg: Boolean(options.includeGpkg),
101
+ onStage(message) {
102
+ console.log(message);
103
+ },
104
+ onBuildProgress: buildProgress.update,
105
+ onPmtilesProgress: reportPmtilesProgress,
106
+ on3dTilesProgress: report3dTilesProgress
107
+ });
108
+ buildProgress.finish();
109
+
110
+ console.log(`Built ${result.outDir}`);
111
+ console.log(` source: ${result.source.name} (${result.source.path})`);
112
+ for (const [layer, count] of Object.entries(result.counts)) {
113
+ console.log(` ${layer}: ${count}`);
114
+ }
115
+ if (result.pmtiles) {
116
+ console.log(` PMTiles: ${result.pmtiles.outPath} (${formatBytes(result.pmtiles.outputBytes)})`);
117
+ }
118
+ if (result.tiles3d) {
119
+ console.log(` 3D Tiles: ${result.tiles3d.tilesetPath} (${formatBytes(result.tiles3d.outputBytes)})`);
120
+ }
121
+ if (result.zip) {
122
+ console.log(` ZIP: ${result.zip.outPath} (${formatBytes(result.zip.outputBytes)})`);
123
+ }
124
+ } catch (error) {
125
+ buildProgress.finish();
126
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
127
+ process.exitCode = 1;
128
+ }
129
+ });
130
+
58
131
  program
59
132
  .command('pmtiles')
60
133
  .description('Export a .mapzero package to a static vector PMTiles archive.')
@@ -92,8 +165,8 @@ program
92
165
  .argument('<package.mapzero>', 'map-zero package folder')
93
166
  .option('--out <dir>', 'output 3D Tiles folder; defaults to <package>/3dtiles')
94
167
  .option('--layers <layers>', 'comma-separated 3D layers; defaults to all supported 3D layers', parse3dTilesLayersOption)
95
- .option('--max-depth <count>', 'quadtree depth for building tiles', parseNonNegativeIntegerOption, 4)
96
- .option('--max-features <count>', 'maximum features per leaf tile before subdivision', parsePositiveIntegerOption, 2500)
168
+ .option('--max-depth <count>', 'quadtree depth for building tiles', parseNonNegativeIntegerOption, 8)
169
+ .option('--max-features <count>', 'maximum features per leaf tile before subdivision', parsePositiveIntegerOption, 1500)
97
170
  .option('--default-height <meters>', 'fallback building height in meters', parsePositiveNumberOption, 8)
98
171
  .action(async (packageDir, options) => {
99
172
  try {
@@ -152,6 +225,31 @@ program
152
225
  }
153
226
  });
154
227
 
228
+ program
229
+ .command('package')
230
+ .description('Create a portable zip with manifest, styles, PMTiles, and 3D Tiles.')
231
+ .argument('<package.mapzero>', 'map-zero package folder')
232
+ .option('--out <file.zip>', 'output zip path; defaults to <package>.zip')
233
+ .option('--include-gpkg', 'include the source GeoPackage in the zip')
234
+ .action(async (packageDir, options) => {
235
+ try {
236
+ const result = await packageMapZero({
237
+ packageDir,
238
+ out: options.out,
239
+ includeGpkg: Boolean(options.includeGpkg)
240
+ });
241
+
242
+ console.log(`Packaged ${result.outPath}`);
243
+ console.log(` files: ${formatInteger(result.fileCount)}`);
244
+ console.log(` input size: ${formatBytes(result.inputBytes)}`);
245
+ console.log(` zip size: ${formatBytes(result.outputBytes)}`);
246
+ console.log(` data.gpkg: ${result.includedGpkg ? 'included' : 'excluded'}`);
247
+ } catch (error) {
248
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
249
+ process.exitCode = 1;
250
+ }
251
+ });
252
+
155
253
  program
156
254
  .command('serve')
157
255
  .description('Serve a .mapzero package with a readonly HTTP API and OpenLayers viewer.')