From 8eaf20d4d34655bbee3c3d3310b6264400bebbd3 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Thu, 23 Apr 2026 17:36:51 +0200 Subject: [PATCH 1/5] Add classes for creating tilesets from S2 cells --- src/tilesets/index.ts | 4 + src/tools/index.ts | 2 + .../tilesetProcessing/TilesetJsonCreatorS2.ts | 332 +++++++++++++++++ .../tilesetProcessing/TilesetSplitter.ts | 351 ++++++++++++++++++ 4 files changed, 689 insertions(+) create mode 100644 src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts create mode 100644 src/tools/tilesetProcessing/TilesetSplitter.ts diff --git a/src/tilesets/index.ts b/src/tilesets/index.ts index f43dea4d..b1d6e5e4 100644 --- a/src/tilesets/index.ts +++ b/src/tilesets/index.ts @@ -65,3 +65,7 @@ export * from "./traversal/TilesetTraverser"; export * from "./traversal/TilesetTraversers"; export * from "./traversal/TraversalCallback"; export * from "./traversal/TraversedTile"; + +export * from "./traversal/cesium/BoundingVolumeDerivation"; +export * from "./traversal/cesium/HilbertOrder"; +export * from "./traversal/cesium/S2Cell"; diff --git a/src/tools/index.ts b/src/tools/index.ts index a879185f..a1b55d0c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -62,6 +62,7 @@ export * from "./tilesetProcessing/TilesetConverter"; export * from "./tilesetProcessing/TilesetDataProcessor"; export * from "./tilesetProcessing/TilesetEntryProcessor"; export * from "./tilesetProcessing/TilesetJsonCreator"; +export * from "./tilesetProcessing/TilesetJsonCreatorS2"; export * from "./tilesetProcessing/TilesetMerger"; export * from "./tilesetProcessing/TilesetMerger3tz"; export * from "./tilesetProcessing/TilesetMergers"; @@ -70,6 +71,7 @@ export * from "./tilesetProcessing/TilesetProcessing"; export * from "./tilesetProcessing/TilesetProcessor"; export * from "./tilesetProcessing/TilesetProcessorContext"; export * from "./tilesetProcessing/TilesetProcessorContexts"; +export * from "./tilesetProcessing/TilesetSplitter"; export * from "./tilesetProcessing/TilesetUpgrader"; export * from "./tilesetProcessing/upgrade/TilesetObjectUpgrader"; diff --git a/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts b/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts new file mode 100644 index 00000000..45461dc7 --- /dev/null +++ b/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts @@ -0,0 +1,332 @@ +import { s2 } from "s2js"; + +import { Tileset } from "../../structure"; +import { Tile } from "../../structure"; +import { Content } from "../../structure"; +import { BoundingVolume } from "../../structure"; + +import { S2Cell } from "../../tilesets"; +import { Extensions } from "../../tilesets"; + +import { DeveloperError, Loggers } from "../../base"; +const logger = Loggers.get("tilesetProcessing"); + +/** + * A class for creating `Tileset` JSON objects for S2 cells + * + * @internal + */ +export class TilesetJsonCreatorS2 { + /** + * A conservative global minimum height in meters for all + * bounding volumes + */ + private static readonly GLOBAL_MIN_HEIGHT = -12000; + + /** + * A conservative global maximum height in meters for all + * bounding volumes + */ + private static readonly GLOBAL_MAX_HEIGHT = -9000; + + /** + * A bounding volume (region) that covers the whole globe + */ + private static readonly GLOBAL_BOUNDING_VOLUME: BoundingVolume = { + region: [ + -3.141592653589793, + -1.5707963267948966, + 3.141592653589793, + 1.5707963267948966, + TilesetJsonCreatorS2.GLOBAL_MIN_HEIGHT, + TilesetJsonCreatorS2.GLOBAL_MAX_HEIGHT, + ], + }; + + /** + * The geometricError that should be used for the tileset + */ + private readonly tilesetGeometricError: number; + + /** + * The geometric error that should be used for the root tile + */ + private readonly rootGeometricError: number; + + /** + * Whether S2 bounding volumes should be used for the tiles. + * + * When this is `true`, then the bounding volumes of the tiles + * will be S2 bounding volumes, and the resulting tileset will + * require the `3DTILES_bounding_volume_S2` extension. + * + * When this is `false`, then bounding regions will be created + * from the S2 cells, and `3DTILES_bounding_volume_S2` will + * not be required. + */ + private readonly useS2BoundingVolumes: boolean; + + /** + * The maximum S2 level (inclusive) for which tiles should + * be created + */ + private maxLevelInclusive: number; + + /** + * A template URI for the content in the leaf tiles. + * + * This may involve the `{cellIdToken}` template that will + * be replaced by the 2D cell ID token + */ + private contentTemplateUri: string; + + /** + * Creates a new instance with unspecified default values + */ + constructor() { + this.tilesetGeometricError = 1048576; + this.rootGeometricError = 524288; + this.useS2BoundingVolumes = true; + this.maxLevelInclusive = 7; + this.contentTemplateUri = "content-{cellIdToken}"; + } + + /** + * Set the maximum S2 level (inclusive) for which tiles should be generated. + * + * + * @param maxLevelInclusive - The maximum level, inclusive + * @throws DeveloperError If the given value is negative + */ + setMaxLevelInclusive(maxLevelInclusive: number) { + if (maxLevelInclusive < 0) { + throw new DeveloperError( + `The maxLevelInclusive may not be negative, ` + + `but is ${maxLevelInclusive}` + ); + } + this.maxLevelInclusive = maxLevelInclusive; + } + + /** + * Set the content template URI that should be used for the content + * in the leaf tiles. + * + * This may involve a template parameter `{cellIdToken}` that will + * be replaced with the S2 cell ID token of the respective S2 cell. + * + * @param contentTemplateUri - The template URI + */ + setContentTemplateUri(contentTemplateUri: string) { + this.contentTemplateUri = contentTemplateUri; + } + + /** + * Create the tileset based on the current configuration. + * + * @returns The tileset + */ + createTileset(): Tileset { + logger.info(`Creating tileset for S2 cells...`); + logger.info(` maxLevelInclusive: ${this.maxLevelInclusive}`); + logger.info(` contentTemplateUri: ${this.contentTemplateUri}`); + + const tilesetGeometricError = this.tilesetGeometricError; + const useS2BoundingVolumes = this.useS2BoundingVolumes; + const root = this.createRoot(); + const tileset: Tileset = { + asset: { + version: "1.1", + }, + geometricError: tilesetGeometricError, + root: root, + }; + if (useS2BoundingVolumes) { + Extensions.addExtensionRequired(tileset, "3DTILES_bounding_volume_S2"); + } + + logger.info(`Creating tileset for S2 cells DONE`); + return tileset; + } + + /** + * Creates the root tile of the tileset (and all its children, + * created via `buildHierarchy`. + * + * The root tile will have 6 children, one for each S2 face. + * Each of these children will contain the S2 quadtree up to + * the `maxLevelInclusive`. + * + * @returns The root tile + */ + private createRoot(): Tile { + const rootGeometricError = this.rootGeometricError; + const rootBoundingVolumeRegion = + TilesetJsonCreatorS2.GLOBAL_BOUNDING_VOLUME; + + const children: Tile[] = []; + const root: Tile = { + refine: "ADD", + boundingVolume: rootBoundingVolumeRegion, + geometricError: rootGeometricError, + children: children, + }; + for (let faceIndex = 0; faceIndex < 6; faceIndex++) { + const cellId = s2.cellid.fromFace(faceIndex); + const child = this.createTile(0, cellId); + children.push(child); + this.buildHierarchy(child, cellId, 0); + } + + return root; + } + + /** + * Recursively build the hierarchy of tiles for S2 cells, + * starting with the given tile. + * + * @param tile - The tile + * @param cellId - The S2 cell ID of the given tile + * @param currentLevel - The current level (0 for the face root tiles) + */ + private buildHierarchy(tile: Tile, cellId: bigint, currentLevel: number) { + if (currentLevel >= this.maxLevelInclusive) { + return; + } + const children: Tile[] = []; + tile.children = children; + const childIds = s2.cellid.children(cellId); + for (const childId of childIds) { + const child = this.createTile(currentLevel, childId); + children.push(child); + this.buildHierarchy(child, childId, currentLevel + 1); + } + } + + /** + * Creates a Tile for the given S2 cell ID. + * + * @param level - The level of the tile (0 for the face root tiles) + * @param cellId - The S2 cell ID to create the tile from + * @returns The tile + */ + private createTile(level: number, cellId: bigint): Tile { + const geometricError = this.computeGeometricError(level); + const boundingVolume = this.createBoundingVolume(cellId); + const content = this.createContent(level, cellId); + const tile: Tile = { + geometricError: geometricError, + boundingVolume: boundingVolume, + content: content, + }; + return tile; + } + + /** + * Creates the content for the specified tile. + * + * This will return `undefined` for all but the leaf tiles of the + * S2 tile hierarchy. + * + * For the leaf tiles, it will return a Content that has a URI that + * was created based on the S2 cell ID. + * + * @param level - The level of the tile (0 for the face root tiles) + * @param cellId - The S2 cell ID of the containing tile + * @returns The content + */ + private createContent(level: number, cellId: bigint): Content | undefined { + if (level != this.maxLevelInclusive - 1) { + return undefined; + } + const contentUri = this.createContentUri(cellId); + const content: Content = { + uri: contentUri, + }; + return content; + } + + /** + * Creates a URI for the specified S2 cell ID. + * + * This will substitute the S2 cell ID token into the + * `contentTemplateUri`. + * + * @param cellId - The S2 cell ID + * @returns The URI + */ + private createContentUri(cellId: bigint): string { + const cellIdToken = S2Cell.getTokenFromId(cellId); + let result = this.contentTemplateUri; + result = result.replace(/{cellIdToken}/g, cellIdToken); + return result; + } + + /** + * Computes the geometric error for a tile at the given level. + * + * @param level - The level (0 for the face root tiles) + * @returns The geometric error + */ + private computeGeometricError(level: number): number { + const rootGeometricError = this.rootGeometricError; + const geometricError = rootGeometricError / Math.pow(2, level); + return geometricError; + } + + /** + * Creates a bounding volume for the tile with the given S2 cell ID + * + * @param cellId - The S2 cell ID + * @returns The bounding volume + */ + private createBoundingVolume(cellId: bigint): BoundingVolume { + if (this.useS2BoundingVolumes) { + return TilesetJsonCreatorS2.createBoundingVolumeS2(cellId); + } + return TilesetJsonCreatorS2.createBoundingVolumeRegion(cellId); + } + + /** + * Creates a bounding volume that uses the `3DTILES_bounding_volume_S2` + * extension with the given S2 cell ID + * + * @param cellId - The S2 cell ID + * @returns The bounding volume + */ + private static createBoundingVolumeS2(cellId: bigint): BoundingVolume { + const boundingVolume: any = { + extensions: { + "3DTILES_bounding_volume_S2": { + token: S2Cell.getTokenFromId(cellId), + minimumHeight: TilesetJsonCreatorS2.GLOBAL_MIN_HEIGHT, + maximumHeight: TilesetJsonCreatorS2.GLOBAL_MAX_HEIGHT, + }, + }, + }; + return boundingVolume; + } + + /** + * Creates a bounding volume (region) that fully encloses the specified + * S2 cell. + * + * @param cellId - The S2 cell ID + * @returns he bounding volume + */ + private static createBoundingVolumeRegion(cellId: bigint): BoundingVolume { + const cell = s2.Cell.fromCellID(cellId); + const rect = cell.rectBound(); + const w = rect.lng.lo; + const s = rect.lat.lo; + const e = rect.lng.hi; + const n = rect.lat.hi; + const minHeight = TilesetJsonCreatorS2.GLOBAL_MIN_HEIGHT; + const maxHeight = TilesetJsonCreatorS2.GLOBAL_MAX_HEIGHT; + const region = [w, s, e, n, minHeight, maxHeight]; + const boundingVolume: BoundingVolume = { + region: region, + }; + return boundingVolume; + } +} diff --git a/src/tools/tilesetProcessing/TilesetSplitter.ts b/src/tools/tilesetProcessing/TilesetSplitter.ts new file mode 100644 index 00000000..8ffac619 --- /dev/null +++ b/src/tools/tilesetProcessing/TilesetSplitter.ts @@ -0,0 +1,351 @@ +import { Tileset } from "../../structure"; +import { Tile } from "../../structure"; +import { Content } from "../../structure"; + +import { DeveloperError, Loggers } from "../../base"; +const logger = Loggers.get("tilesetProcessing"); + +/** + * Internal type definition for the state of traversing the + * tile hierarchy + * + * @internal + */ +type SplittingState = { + /** + * The root tileset where the traversal started + */ + rootTileset: Tileset; + + /** + * The global level of the current traversal, referring to the + * root tileset, where the rootTileset.root has globalLevel of 0. + */ + globalLevel: number; + + /** + * The tileset that contained the tile that is currently + * traversed. This may be a tileset that was created + * by converting one of the original tiles into an external + * tileset. + */ + currentTileset: Tileset; + + /** + * The local level of the traversal, referring to the current + * tileset, where currentTileset.root has a localLevel of 0 + */ + localLevel: number; + + /** + * The currently active 'refine' strategy. + */ + currentRefine: string; + + /** + * A mapping from URls to the external tilesets that have been + * created so far + */ + externalTilesets: Map; +}; + +/** + * An interface for classes that can decide whether a given tile should + * be converted into an external tileset. + * + * @internal + */ +interface SplittingCondition { + /** + * Returns whether the given tile should be converted into an external + * tileset. + * + * @param tile - The tile + * @param splittingState - The splitting state + */ + shouldExternalize(tile: Tile, splittingState: SplittingState): boolean; +} + +/** + * A class that can split a tileset into one that refers to multiple + * external tilesets. + * + * @internal + */ +export class TilesetSplitter { + /** + * The condition that decides whether a certain tile should be converted + * into an external tileset. + */ + private splittingCondition: SplittingCondition; + + /** + * Creates a new instance. + * + * This instance does not have a splitting condition by default, meaning + * that it will not actually split the tileset. Use + * `setGlobalSplittingLevels` to configure the splitting condition. + */ + constructor() { + this.splittingCondition = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldExternalize(tile: Tile, splittingState: SplittingState): boolean { + return false; + }, + }; + } + + /** + * Set the array that indicates at which global levels the tileset should + * be split. + * + * For example, for a tileset with 9 levels, calling this with + * `globalSplittingLevels = [2, 5]` + * will split the tileset as follows: + * ``` + * level input outputs + * 0 root root + * 1 T1 T1 + * 2 T2 T2 root + * 3 T3 T3 + * 4 T4 T4 + * 5 T5 T5 root + * 6 T6 T6 + * 7 T7 T7 + * 8 T8 T8 + * ``` + * + * @param globalSplittingLevels - The splitting levels + * @throws DeveloperError If any of the splitting levels is not positive + * or they are not strictly increasing + */ + setGlobalSplittingLevels(globalSplittingLevels: number[]) { + TilesetSplitter.validateSplittingLevels(globalSplittingLevels); + this.splittingCondition = { + shouldExternalize(tile: Tile, splittingState: SplittingState): boolean { + return globalSplittingLevels.includes(splittingState.globalLevel); + }, + }; + } + + /** + * Ensure that the given array only contains positive, strictly + * increasing values + * + * @param array - The array + * @throws DeveloperError If this is not the case + */ + private static validateSplittingLevels(array: number[]) { + for (let i = 0; i < array.length; i++) { + const nonPositive = array[i] <= 0; + const nonIncreasing = i > 0 && array[i] <= array[i - 1]; + if (nonPositive || nonIncreasing) { + throw new DeveloperError( + `The splitting levels must all be positive ` + + `and strictly increasing, but are ${array}` + ); + } + } + } + + /** + * Split the given tileset, in place. + * + * This will convert tiles of the given tileset into external tilesets, + * based on the splitting condition of this instance. + * + * @param tileset - The tileset + * @returns The result + */ + splitTileset(tileset: Tileset): Map { + logger.info(`Splitting tileset...`); + const refine = tileset.root.refine ?? "ADD"; + const splittingState: SplittingState = { + rootTileset: tileset, + globalLevel: 0, + currentTileset: tileset, + localLevel: 0, + currentRefine: refine, + externalTilesets: new Map(), + }; + this.splitTilesetInternal(splittingState); + const result = splittingState.externalTilesets; + logger.info( + `Splitting tileset DONE, created ${result.size} external tilesets` + ); + return result; + } + + /** + * Internal entry point for the traversal that splits the current tileset + * of the given splitting state. + * + * @param splittingState - The splitting state + */ + private splitTilesetInternal(splittingState: SplittingState) { + const root = splittingState.currentTileset.root; + this.processTile(root, splittingState); + } + + /** + * Recursively process the given tile and its descendants, and convert + * tiles into external tilesets based on the splitting condition. + * + * When the splitting condition indicates that a tile should be + * externalized, then it will be converted into the root of an + * external tileset, and the tiles of this external tileset will + * be processed recursively. + * + * @param tile - The tile + * @param splittingState - The splitting state + */ + private processTile(tile: Tile, splittingState: SplittingState) { + const children = tile.children; + if (!children) { + return; + } + const newChildren: Tile[] = []; + const splittingCondition = this.splittingCondition; + for (const child of children) { + // If a child meets the splitting condition, then it will be + // converted into a new child that refers to a newly created + // external tileset (and the tiles of this external tileset + // will be traversed recursively). Otherwise, the original + // child is kept, and it descendants are traversed. + const childSplittingState: SplittingState = { + rootTileset: splittingState.rootTileset, + globalLevel: splittingState.globalLevel + 1, + currentTileset: splittingState.currentTileset, + localLevel: splittingState.localLevel + 1, + currentRefine: splittingState.currentRefine, + externalTilesets: splittingState.externalTilesets, + }; + const shouldExternalize = splittingCondition.shouldExternalize( + child, + childSplittingState + ); + if (shouldExternalize) { + const newChild = this.convertTileToExternalTileset( + child, + childSplittingState + ); + newChildren.push(newChild); + } else { + this.processTile(child, childSplittingState); + newChildren.push(child); + } + } + tile.children = newChildren; + } + + /** + * Create a new tileset from the given tile, and return a new tile that refers + * to this external tileset. + * + * The given tile will be used for creating the root of the new tileset. + * The resulting tileset will be stored in the `externalTilesets` of the + * splitting state. A new child will be returned that has a content with + * a URI that refers to this newly created tileset. + * + * @param tile - The tile + * @param splittingState - The splitting state + * @returns The new child + */ + private convertTileToExternalTileset( + tile: Tile, + splittingState: SplittingState + ) { + // Create the external tileset from the tile, and store it + // in the map of external tilesets + const newChildTileset = TilesetSplitter.createTilesetFrom( + tile, + splittingState + ); + const uri = TilesetSplitter.deriveExternalTilesetJsonUri( + tile, + splittingState + ); + const externalTilesets = splittingState.externalTilesets; + externalTilesets.set(uri, newChildTileset); + + // Traverse the external tileset, possibly splitting it even further + const externalSplittingState: SplittingState = { + rootTileset: splittingState.rootTileset, + globalLevel: splittingState.globalLevel, + currentTileset: newChildTileset, + localLevel: 0, + currentRefine: splittingState.currentRefine, + externalTilesets: splittingState.externalTilesets, + }; + this.splitTilesetInternal(externalSplittingState); + + // Return the new tile that refers to the external tileset + const newContent: Content = { + uri: uri, + }; + const newChild: Tile = { + refine: splittingState.currentRefine, + content: newContent, + boundingVolume: tile.boundingVolume, + geometricError: tile.geometricError, + }; + return newChild; + } + + /** + * Create a new tileset from the given tile. + * + * This will create a new tileset where the properties of the root tile + * are set based on the properties of the given tile. + * + * @param tile - The tile + * @param splittingState - The splitting state + * @returns The tileset + */ + private static createTilesetFrom( + tile: Tile, + splittingState: SplittingState + ): Tileset { + const refine = tile.refine ?? splittingState.currentRefine; + const newRoot: Tile = { + refine: refine, + content: tile.content, + contents: tile.contents, + boundingVolume: tile.boundingVolume, + geometricError: tile.geometricError, + children: tile.children, + }; + const newChildTileset: Tileset = { + asset: { + version: "1.1", + }, + geometricError: tile.geometricError, + root: newRoot, + extensionsUsed: splittingState.rootTileset.extensionsUsed, + extensionsRequired: splittingState.rootTileset.extensionsRequired, + }; + return newChildTileset; + } + + /** + * Create a URI that should be used for the external tileset that is + * created for the given tile. + * + * @param tile - The tile + * @param splittingState - The splitting state + * @returns The URI + */ + private static deriveExternalTilesetJsonUri( + tile: Tile, + splittingState: SplittingState + ): string { + const extensions = tile.boundingVolume.extensions ?? {}; + const s2 = extensions["3DTILES_bounding_volume_S2"]; + const token = s2?.token; + if (token) { + return "external-" + token + ".json"; + } + const n = splittingState.externalTilesets.size; + return "external-level-" + splittingState.globalLevel + "-" + n + ".json"; + } +} From 4ae60ee3b02a6bb1737d4d524b043a7aba640b55 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Thu, 23 Apr 2026 17:50:43 +0200 Subject: [PATCH 2/5] Formatting --- src/tools/tilesetProcessing/TilesetSplitter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/tilesetProcessing/TilesetSplitter.ts b/src/tools/tilesetProcessing/TilesetSplitter.ts index 8ffac619..c06bf429 100644 --- a/src/tools/tilesetProcessing/TilesetSplitter.ts +++ b/src/tools/tilesetProcessing/TilesetSplitter.ts @@ -69,7 +69,7 @@ interface SplittingCondition { /** * A class that can split a tileset into one that refers to multiple * external tilesets. - * + * * @internal */ export class TilesetSplitter { From 14c072b5c59ecc5fd99e5386915143cd93cbd617 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Thu, 23 Apr 2026 17:58:40 +0200 Subject: [PATCH 3/5] Update package JSON for S2 dependency --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 24933292..6c8ba736 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "node-stream-zip": "^1.15.0", "pino": "^8.15.0", "pino-pretty": "^10.2.0", + "s2js": "^1.44.0", "seedrandom": "^3.0.5", "sharp": "^0.32.1", "yargs": "^17.5.1" @@ -50,9 +51,9 @@ "@microsoft/api-documenter": "^7.19.24", "@microsoft/api-extractor": "^7.33.6", "@types/jasmine": "^4.0.3", + "@types/yargs": "^17.0.22", "@typescript-eslint/eslint-plugin": "^5.38.0", "@typescript-eslint/parser": "^5.38.0", - "@types/yargs": "^17.0.22", "c8": "^8.0.1", "del-cli": "^5.0.0", "eslint": "^8.23.1", @@ -105,4 +106,4 @@ "package-prepare": "npm run eslint && npm run prettier-check && npm run build && npm run test && npm run coverage && npm run docs-generate && npm run generate-third-party", "package": "npm run package-clean && npm run package-prepare && npm pack" } -} \ No newline at end of file +} From 050bd89d7f5a6723f1b2453161a9c152706e99ea Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Mon, 27 Apr 2026 15:48:08 +0200 Subject: [PATCH 4/5] Fixed sign error for maximum height --- src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts b/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts index 45461dc7..29797f0c 100644 --- a/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts +++ b/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts @@ -27,7 +27,7 @@ export class TilesetJsonCreatorS2 { * A conservative global maximum height in meters for all * bounding volumes */ - private static readonly GLOBAL_MAX_HEIGHT = -9000; + private static readonly GLOBAL_MAX_HEIGHT = 9000; /** * A bounding volume (region) that covers the whole globe From 20e6a5a0fcf971cce4e3d9e69ba5ecb4ea3ff4d2 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Sun, 3 May 2026 16:12:19 +0200 Subject: [PATCH 5/5] Add CLI command for S2 tileset creation --- src/cli/ToolsMain.ts | 84 +++++++++- src/cli/main.ts | 44 +++++- .../tilesetProcessing/TilesetJsonCreatorS2.ts | 146 ++++++++++++------ .../tilesetProcessing/TilesetSplitter.ts | 3 + 4 files changed, 229 insertions(+), 48 deletions(-) diff --git a/src/cli/ToolsMain.ts b/src/cli/ToolsMain.ts index 30a5809e..31ae6070 100644 --- a/src/cli/ToolsMain.ts +++ b/src/cli/ToolsMain.ts @@ -6,23 +6,24 @@ import { DeveloperError } from "../base"; import { Buffers } from "../base"; import { Iterables } from "../base"; import { ContentDataTypes } from "../base"; +import { ContentDataTypeRegistry } from "../base"; import { TileFormats } from "../tilesets"; import { TileDataLayouts } from "../tilesets"; import { TileFormatError } from "../tilesets"; import { ContentOps } from "../tools"; +import { TilesetJsonCreatorS2 } from "../tools"; +import { TilesetSplitter } from "../tools"; import { GltfUtilities } from "../tools"; - import { PipelineExecutor } from "../tools"; import { Pipelines } from "../tools"; - import { TilesetOperations } from "../tools"; import { TileFormatsMigration } from "../tools"; import { TilesetConverter } from "../tools"; import { TilesetJsonCreator } from "../tools"; -import { ContentDataTypeRegistry } from "../base"; +import { Tileset } from "../structure"; import { Loggers } from "../base"; const logger = Loggers.get("CLI"); @@ -673,6 +674,83 @@ export class ToolsMain { logger.debug(`Executing createTilesetJson DONE`); } + static async createTilesetJsonS2( + output: string, + maximumLevelInclusive: number, + contentTemplateUri: string, + globalSplittingLevels: number[] | undefined, + force: boolean + ) { + logger.debug(`Executing createTilesetJsonS2`); + logger.debug(` output: ${output}`); + logger.debug(` force: ${force}`); + + ToolsMain.ensureCanWrite(output, force); + + logger.info( + `Creating tileset JSON for maximum S2 level ${maximumLevelInclusive}` + ); + const creator = new TilesetJsonCreatorS2(); + creator.setMaxLevelInclusive(maximumLevelInclusive); + creator.setContentTemplateUri(contentTemplateUri); + const tileset = creator.createTileset(); + logger.info( + `Creating tileset JSON for maximum S2 level ${maximumLevelInclusive} DONE` + ); + + let externalTilesets: Map = new Map(); + if (globalSplittingLevels) { + logger.info( + `Splitting tileset at global levels ${globalSplittingLevels}` + ); + const tilesetSplitter = new TilesetSplitter(); + tilesetSplitter.setGlobalSplittingLevels(globalSplittingLevels); + externalTilesets = tilesetSplitter.splitTileset(tileset); + logger.info( + `Splitting tileset at global levels ${globalSplittingLevels} DONE` + ); + } + + let outputFileName = output; + let outputDirectory: string; + if (Paths.isDirectory(output)) { + outputDirectory = output; + } else { + outputDirectory = path.dirname(output); + outputFileName = path.resolve(outputDirectory, "tileset.json"); + } + + Paths.ensureDirectoryExists(outputDirectory); + + const tilesetJsonString = JSON.stringify(tileset, null, 2); + fs.writeFileSync(outputFileName, Buffer.from(tilesetJsonString)); + + logger.info(`Writing tileset to ${outputFileName}`); + fs.writeFileSync( + outputFileName, + Buffer.from(JSON.stringify(tileset, null, 2)) + ); + logger.info(`Writing tileset to ${outputFileName} DONE`); + + if (externalTilesets.size !== 0) { + logger.info( + `Writing ${externalTilesets.size} external tilesets to ${outputDirectory}` + ); + for (const entry of externalTilesets.entries()) { + const name = entry[0]; + const value = entry[1]; + fs.writeFileSync( + path.resolve(outputDirectory, name), + Buffer.from(JSON.stringify(value, null, 2)) + ); + } + logger.info( + `Writing ${externalTilesets.size} external tilesets to ${outputDirectory} DONE` + ); + } + logger.debug(`Executing createTilesetJsonS2 DONE`); + } + /** * Creates a function that can resolve URIs relative to * the given input file. diff --git a/src/cli/main.ts b/src/cli/main.ts index a41ae3cf..0e9345fa 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -359,6 +359,37 @@ function parseToolArgs(a: string[]) { }, } ) + .command( + "createTilesetJsonS2", + "Creates one or more tileset JSON files that refer to content that is identified " + + "with S2 cell tokens. ", + { + o: outputStringDefinition, + maximumLevelInclusive: { + description: + "The maximum S2 level, inclusive, for which the tilesets should be created", + type: "number", + demandOption: true, + }, + contentTemplateUri: { + description: + "A string that will be used to define the content URIs for the leaf tiles " + + "of of the resulting tileset. This URI can contain a template '{cellIdToken}' " + + "that will be replaced with the actual S2 cell ID token", + type: "string", + demandOption: true, + }, + globalSplittingLevels: { + description: + "An array of numbers that indicate at which global levels the tileset " + + "should be split into multiple external tilesets. If this is not given, " + + "then a single tileset JSON file will be created. Otherwise, the main " + + "tileset JSON file will refer to external tilesets (with unspecified names) " + + "that start at the respective global levels", + type: "array", + }, + } + ) .demandCommand(1) .strict(); @@ -503,7 +534,7 @@ async function runCommand(command: string, toolArgs: any, optionArgs: any) { logger.trace(` optionArgs: ${optionArgs}`); logger.trace(` parsedOptionArgs: ${JSON.stringify(parsedOptionArgs)}`); - const input = inputs[inputs.length - 1]; + const input = inputs?.length > 0 ? inputs[inputs.length - 1] : ""; if (command === "b3dmToGlb") { await ToolsMain.b3dmToGlb(input, output, force); @@ -591,6 +622,17 @@ async function runCommand(command: string, toolArgs: any, optionArgs: any) { rotationDegrees, force ); + } else if (command === "createTilesetJsonS2") { + const maximumLevelInclusive = toolArgs.maximumLevelInclusive; + const contentTemplateUri = toolArgs.contentTemplateUri; + const globalSplittingLevels = toolArgs.globalSplittingLevels; + await ToolsMain.createTilesetJsonS2( + output, + maximumLevelInclusive, + contentTemplateUri, + globalSplittingLevels, + force + ); } else { throw new DeveloperError(`Invalid command: ${command}`); } diff --git a/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts b/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts index 29797f0c..0e65d972 100644 --- a/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts +++ b/src/tools/tilesetProcessing/TilesetJsonCreatorS2.ts @@ -18,53 +18,37 @@ const logger = Loggers.get("tilesetProcessing"); */ export class TilesetJsonCreatorS2 { /** - * A conservative global minimum height in meters for all - * bounding volumes + * Whether S2 bounding volumes should be used for the tiles. + * + * When this is `true`, then the bounding volumes of the tiles + * will be S2 bounding volumes, and the resulting tileset will + * require the `3DTILES_bounding_volume_S2` extension. + * + * When this is `false`, then bounding regions will be created + * from the S2 cells, and `3DTILES_bounding_volume_S2` will + * not be required. */ - private static readonly GLOBAL_MIN_HEIGHT = -12000; + private readonly useS2BoundingVolumes: boolean; /** - * A conservative global maximum height in meters for all - * bounding volumes + * The minimum height in meters for all bounding volumes */ - private static readonly GLOBAL_MAX_HEIGHT = 9000; + private globalMinHeightMeters: number; /** - * A bounding volume (region) that covers the whole globe + * The maximum height in meters for all bounding volumes */ - private static readonly GLOBAL_BOUNDING_VOLUME: BoundingVolume = { - region: [ - -3.141592653589793, - -1.5707963267948966, - 3.141592653589793, - 1.5707963267948966, - TilesetJsonCreatorS2.GLOBAL_MIN_HEIGHT, - TilesetJsonCreatorS2.GLOBAL_MAX_HEIGHT, - ], - }; + private globalMaxHeightMeters: number; /** * The geometricError that should be used for the tileset */ - private readonly tilesetGeometricError: number; + private tilesetGeometricError: number; /** * The geometric error that should be used for the root tile */ - private readonly rootGeometricError: number; - - /** - * Whether S2 bounding volumes should be used for the tiles. - * - * When this is `true`, then the bounding volumes of the tiles - * will be S2 bounding volumes, and the resulting tileset will - * require the `3DTILES_bounding_volume_S2` extension. - * - * When this is `false`, then bounding regions will be created - * from the S2 cells, and `3DTILES_bounding_volume_S2` will - * not be required. - */ - private readonly useS2BoundingVolumes: boolean; + private rootGeometricError: number; /** * The maximum S2 level (inclusive) for which tiles should @@ -84,16 +68,72 @@ export class TilesetJsonCreatorS2 { * Creates a new instance with unspecified default values */ constructor() { + this.useS2BoundingVolumes = true; + this.globalMaxHeightMeters = 9000; + this.globalMinHeightMeters = -12000; this.tilesetGeometricError = 1048576; this.rootGeometricError = 524288; - this.useS2BoundingVolumes = true; this.maxLevelInclusive = 7; this.contentTemplateUri = "content-{cellIdToken}"; } /** - * Set the maximum S2 level (inclusive) for which tiles should be generated. + * Set the minimum and maximum height in meters that should be used for + * all bounding volumes. + * + * @param globalMinHeightMeters - The global minimum height in meters + * @param globalMaxHeightMeters - The global maximum height in meters + * @throws DeveloperError If the minimum height is larger than the + * maximum height + */ + setGlobalHeightMeters( + globalMinHeightMeters: number, + globalMaxHeightMeters: number + ) { + if (globalMinHeightMeters > globalMaxHeightMeters) { + throw new DeveloperError( + `The globalMinHeightMeters may not be larger than the ` + + `globalMaxHeightMeters, but the globalMinHeightMeters ` + + `is ${globalMinHeightMeters} and the ` + + `globalMaxHeightMeters is ${globalMaxHeightMeters}` + ); + } + this.globalMinHeightMeters = globalMinHeightMeters; + this.globalMaxHeightMeters = globalMaxHeightMeters; + } + + /** + * Set the geometric error that should be used for the tileset and + * the root tile. * + * The geometric error of each inner tile will be half of the geometric + * error of its parent. + * + * @param tilesetGeometricError - The tileset geometric error + * @param rootGeometricError - The geometric error of the root tile + */ + setGeometricErrors( + tilesetGeometricError: number, + rootGeometricError: number + ) { + if (tilesetGeometricError < 0) { + throw new DeveloperError( + `The tilesetGeometricError may not be negative, ` + + `but is ${tilesetGeometricError}` + ); + } + if (rootGeometricError < 0) { + throw new DeveloperError( + `The rootGeometricError may not be negative, ` + + `but is ${rootGeometricError}` + ); + } + this.tilesetGeometricError = tilesetGeometricError; + this.rootGeometricError = rootGeometricError; + } + + /** + * Set the maximum S2 level (inclusive) for which tiles should be generated. * * @param maxLevelInclusive - The maximum level, inclusive * @throws DeveloperError If the given value is negative @@ -149,6 +189,25 @@ export class TilesetJsonCreatorS2 { return tileset; } + /** + * Create a bounding volume (region) that covers the whole globe + * + * @returns The bounding volume + */ + private createGlobalBoundingVolume(): BoundingVolume { + const boundingVolume = { + region: [ + -3.141592653589793, + -1.5707963267948966, + 3.141592653589793, + 1.5707963267948966, + this.globalMinHeightMeters, + this.globalMaxHeightMeters, + ], + }; + return boundingVolume; + } + /** * Creates the root tile of the tileset (and all its children, * created via `buildHierarchy`. @@ -161,8 +220,7 @@ export class TilesetJsonCreatorS2 { */ private createRoot(): Tile { const rootGeometricError = this.rootGeometricError; - const rootBoundingVolumeRegion = - TilesetJsonCreatorS2.GLOBAL_BOUNDING_VOLUME; + const rootBoundingVolumeRegion = this.createGlobalBoundingVolume(); const children: Tile[] = []; const root: Tile = { @@ -282,9 +340,9 @@ export class TilesetJsonCreatorS2 { */ private createBoundingVolume(cellId: bigint): BoundingVolume { if (this.useS2BoundingVolumes) { - return TilesetJsonCreatorS2.createBoundingVolumeS2(cellId); + return this.createBoundingVolumeS2(cellId); } - return TilesetJsonCreatorS2.createBoundingVolumeRegion(cellId); + return this.createBoundingVolumeRegion(cellId); } /** @@ -294,13 +352,13 @@ export class TilesetJsonCreatorS2 { * @param cellId - The S2 cell ID * @returns The bounding volume */ - private static createBoundingVolumeS2(cellId: bigint): BoundingVolume { + private createBoundingVolumeS2(cellId: bigint): BoundingVolume { const boundingVolume: any = { extensions: { "3DTILES_bounding_volume_S2": { token: S2Cell.getTokenFromId(cellId), - minimumHeight: TilesetJsonCreatorS2.GLOBAL_MIN_HEIGHT, - maximumHeight: TilesetJsonCreatorS2.GLOBAL_MAX_HEIGHT, + minimumHeight: this.globalMinHeightMeters, + maximumHeight: this.globalMaxHeightMeters, }, }, }; @@ -314,15 +372,15 @@ export class TilesetJsonCreatorS2 { * @param cellId - The S2 cell ID * @returns he bounding volume */ - private static createBoundingVolumeRegion(cellId: bigint): BoundingVolume { + private createBoundingVolumeRegion(cellId: bigint): BoundingVolume { const cell = s2.Cell.fromCellID(cellId); const rect = cell.rectBound(); const w = rect.lng.lo; const s = rect.lat.lo; const e = rect.lng.hi; const n = rect.lat.hi; - const minHeight = TilesetJsonCreatorS2.GLOBAL_MIN_HEIGHT; - const maxHeight = TilesetJsonCreatorS2.GLOBAL_MAX_HEIGHT; + const minHeight = this.globalMinHeightMeters; + const maxHeight = this.globalMaxHeightMeters; const region = [w, s, e, n, minHeight, maxHeight]; const boundingVolume: BoundingVolume = { region: region, diff --git a/src/tools/tilesetProcessing/TilesetSplitter.ts b/src/tools/tilesetProcessing/TilesetSplitter.ts index c06bf429..a36e4247 100644 --- a/src/tools/tilesetProcessing/TilesetSplitter.ts +++ b/src/tools/tilesetProcessing/TilesetSplitter.ts @@ -154,6 +154,9 @@ export class TilesetSplitter { * This will convert tiles of the given tileset into external tilesets, * based on the splitting condition of this instance. * + * The result will be a mapping from (unspecified) JSON file names + * to the respective tileset objects. + * * @param tileset - The tileset * @returns The result */