diff --git a/specs/data/createTilesetJson/golden/batchedColors.json b/specs/data/createTilesetJson/golden/batchedColors.json index ce832b80..250b042d 100644 --- a/specs/data/createTilesetJson/golden/batchedColors.json +++ b/specs/data/createTilesetJson/golden/batchedColors.json @@ -6,18 +6,18 @@ "root": { "boundingVolume": { "box": [ - 1215017.1239874638, - -4736316.080057698, - 4081603.1264158455, - -1.2755385681689153, - 4.9722508120558055, - -4.3138044394725314, - 23.12108928345635, - -52.382444478338165, - -67.21456600138885, - -82.75933184818228, - -27.40170140970006, - -7.11330633281961 + 1215020.962196599, + -4736314.431739063, + 4081600.5689328797, + 83.39198781945653, + 25.09669489341931, + 3.497625570651054, + -1.4049290385910531, + 5.351828760253799, + -4.904295184843724, + -19.867408658008266, + 56.61289181944477, + 67.47040900152847 ] }, "geometricError": 512, diff --git a/specs/data/createTilesetJson/golden/compositeOfComposite.json b/specs/data/createTilesetJson/golden/compositeOfComposite.json index 71cc33a3..1f0e540a 100644 --- a/specs/data/createTilesetJson/golden/compositeOfComposite.json +++ b/specs/data/createTilesetJson/golden/compositeOfComposite.json @@ -6,18 +6,18 @@ "root": { "boundingVolume": { "box": [ - 1215015.740633026, - -4736317.709331602, - 4081606.5029129568, - 91.34493746005317, - 25.73468456069652, - 2.891651051729513, - -3.4712154541846187, - 13.441215114981711, - -9.969208596733905, - -17.541319642226654, - 53.4750698059166, - 78.20677126109172 + 1215014.7674845206, + -4736317.496050191, + 4081607.1679102723, + 90.85283629182183, + 26.12990540945053, + 3.22218827509456, + -3.4086480439178746, + 13.055111922193284, + -9.758429440398935, + -18.098582106397203, + 53.34766432454173, + 77.69195131959206 ] }, "geometricError": 512, diff --git a/specs/tools/tilesetProcessing/BoundingVolumesContainmentSpec.ts b/specs/tools/tilesetProcessing/BoundingVolumesContainmentSpec.ts new file mode 100644 index 00000000..15914e30 --- /dev/null +++ b/specs/tools/tilesetProcessing/BoundingVolumesContainmentSpec.ts @@ -0,0 +1,287 @@ +import { + Cartesian3, + Math as CesiumMath, + Matrix3, + Matrix4, + OrientedBoundingBox, + Quaternion, +} from "cesium"; + +import { BoundingVolumesContainment } from "../../../src/tools/tilesetProcessing/BoundingVolumesContainment"; + +/** + * Converts the given values from degrees to radians, and passes them + * to BoundingVolumesContainment.longitudeRangeContainsInclusive, + * returning whether the actual containment matches the expected one. + * + * @param westDeg - The west (leftmost) point, in degrees + * @param eastDeg - The east (rightmost) point, in degrees + * @param pointDeg - The point, in degrees + * @param expected - The expected containment + * @returns Whether the actual containment matches the expected one + */ +const check = ( + westDeg: number, + eastDeg: number, + pointDeg: number, + expected: boolean +) => { + const epsilon = 1e-12; + const westRad = CesiumMath.toRadians(westDeg); + const eastRad = CesiumMath.toRadians(eastDeg); + const pointRad = CesiumMath.toRadians(pointDeg); + const actual = BoundingVolumesContainment.longitudeRangeContainsInclusive( + westRad, + eastRad, + pointRad, + epsilon + ); + return expected == actual; +}; + +/** + * Calls 'check' with the given arguments, with all combinations of + * adding/subtracting 360 degrees, to check the wrapping behavior. + * + * @param westDeg - The west (leftmost) point, in degrees + * @param eastDeg - The east (rightmost) point, in degrees + * @param pointDeg - The point, in degrees + * @param expected - The expected containment + * @returns Whether the actual containment matches the expected one + */ +const checkAll = ( + westDeg: number, + eastDeg: number, + pointDeg: number, + expected: boolean +) => { + let a = true; + a = a && check(westDeg, eastDeg, pointDeg, expected); + + a = a && check(westDeg + 360, eastDeg, pointDeg, expected); + a = a && check(westDeg - 360, eastDeg, pointDeg, expected); + a = a && check(westDeg, eastDeg + 360, pointDeg, expected); + a = a && check(westDeg, eastDeg - 360, pointDeg, expected); + a = a && check(westDeg + 360, eastDeg + 360, pointDeg, expected); + a = a && check(westDeg - 360, eastDeg - 360, pointDeg, expected); + + a = a && check(westDeg + 360, eastDeg, pointDeg + 360, expected); + a = a && check(westDeg - 360, eastDeg, pointDeg + 360, expected); + a = a && check(westDeg, eastDeg + 360, pointDeg + 360, expected); + a = a && check(westDeg, eastDeg - 360, pointDeg + 360, expected); + a = a && check(westDeg + 360, eastDeg + 360, pointDeg + 360, expected); + a = a && check(westDeg - 360, eastDeg - 360, pointDeg + 360, expected); + + a = a && check(westDeg + 360, eastDeg, pointDeg - 360, expected); + a = a && check(westDeg - 360, eastDeg, pointDeg - 360, expected); + a = a && check(westDeg, eastDeg + 360, pointDeg - 360, expected); + a = a && check(westDeg, eastDeg - 360, pointDeg - 360, expected); + a = a && check(westDeg + 360, eastDeg + 360, pointDeg - 360, expected); + a = a && check(westDeg - 360, eastDeg - 360, pointDeg - 360, expected); + + return a; +}; + +/** + * Transforms the given oriented bounding box with the given matrix + * + * @param orientedBoundingBox The oriented bounding box + * @param transform The transform matrix + * @returns The result + */ +const transformOrientedBoundingBox = ( + orientedBoundingBox: OrientedBoundingBox, + transform: Matrix4 +) => { + const result = new OrientedBoundingBox(); + Matrix4.multiplyByPoint(transform, orientedBoundingBox.center, result.center); + const rotationScaleTransform = Matrix4.getMatrix3(transform, new Matrix3()); + Matrix3.multiply( + rotationScaleTransform, + orientedBoundingBox.halfAxes, + result.halfAxes + ); + return result; +}; + +/** + * Tests for the BoundingVolumesContainment class + */ +describe("BoundingVolumesContainment", function () { + it("boxContains works", function () { + const epsilon = 1e-12; + + // Create an arbitrary matrix to be applied to the OBB and points, + // involving translation, rotation, and scale + const transform = Matrix4.fromTranslationQuaternionRotationScale( + new Cartesian3(1.0, 2.0, 3.0), + Quaternion.fromAxisAngle( + new Cartesian3(2.0, 4.0, 6.0), + CesiumMath.toRadians(45) + ), + new Cartesian3(2.0, 4.0, 6.0) + ); + + // Create a "unit OBB", transform it, and convert the result into a "box" array + const unitObb = new OrientedBoundingBox(Cartesian3.ZERO, Matrix3.IDENTITY); + const obb = transformOrientedBoundingBox(unitObb, transform); + const box = OrientedBoundingBox.pack(obb, Array(12), 0); + + // Transform cartesians that are relative to the unit OBB + // with the matrix, convert them into point[] arrays, + // and perform the containment checks + + const cartesianIn = new Cartesian3(0.9, 0.9, 0.9); + Matrix4.multiplyByPoint(transform, cartesianIn, cartesianIn); + const pointIn = Cartesian3.pack(cartesianIn, Array(3), 0); + const actualIn = BoundingVolumesContainment.boxContains( + box, + pointIn, + epsilon + ); + const expectedIn = true; + expect(actualIn).toBe(expectedIn); + + const cartesianOn = new Cartesian3(1.0, 1.0, 1.0); + Matrix4.multiplyByPoint(transform, cartesianOn, cartesianOn); + const pointOn = Cartesian3.pack(cartesianOn, Array(3), 0); + const actualOn = BoundingVolumesContainment.boxContains( + box, + pointOn, + epsilon + ); + const expectedOn = true; + expect(actualOn).toBe(expectedOn); + + const cartesianOut = new Cartesian3(1.0, 1.0, 1.01); + Matrix4.multiplyByPoint(transform, cartesianOut, cartesianOut); + const pointOut = Cartesian3.pack(cartesianOut, Array(3), 0); + const actualOut = BoundingVolumesContainment.boxContains( + box, + pointOut, + epsilon + ); + const expectedOut = false; + expect(actualOut).toBe(expectedOut); + }); + + it("sphereContains works", function () { + const epsilon = 1e-12; + const sphere = [1.0, 2.0, 3.0, 10.0]; + + const pointIn = [1.0 + 9.99, 2.0, 3.0]; + const actualIn = BoundingVolumesContainment.sphereContains( + sphere, + pointIn, + epsilon + ); + const expectedIn = true; + expect(actualIn).toBe(expectedIn); + + const pointOn = [1.0, 2.0 + 10.0, 3.0]; + const actualOn = BoundingVolumesContainment.sphereContains( + sphere, + pointOn, + epsilon + ); + const expectedOn = true; + expect(actualOn).toBe(expectedOn); + + const pointOut = [1.0, 2.0, 3.0 + 10.01]; + const actualOut = BoundingVolumesContainment.sphereContains( + sphere, + pointOut, + epsilon + ); + const expectedOut = false; + expect(actualOut).toBe(expectedOut); + }); + + it("regionContains works", function () { + const epsilon = 1e-6; + + const westRad = CesiumMath.toRadians(20); + const southRad = CesiumMath.toRadians(10); + const eastRad = CesiumMath.toRadians(30); + const northRad = CesiumMath.toRadians(40); + const minHeightMeters = 50; + const maxHeightMeters = 60; + const region = [ + westRad, + southRad, + eastRad, + northRad, + minHeightMeters, + maxHeightMeters, + ]; + + const cartesianIn = Cartesian3.fromDegrees(25, 20, 55); + const pointIn = Cartesian3.pack(cartesianIn, Array(3), 0); + const actualIn = BoundingVolumesContainment.regionContains( + region, + pointIn, + epsilon + ); + const expectedIn = true; + expect(actualIn).toBe(expectedIn); + + const cartesianOn = Cartesian3.fromDegrees(20, 30, 50); + const pointOn = Cartesian3.pack(cartesianOn, Array(3), 0); + const actualOn = BoundingVolumesContainment.regionContains( + region, + pointOn, + epsilon + ); + const expectedOn = true; + expect(actualOn).toBe(expectedOn); + + const cartesianOut = Cartesian3.fromDegrees(19, 20, 55); + const pointOut = Cartesian3.pack(cartesianOut, Array(3), 0); + const actualOut = BoundingVolumesContainment.regionContains( + region, + pointOut, + epsilon + ); + const expectedOut = false; + expect(actualOut).toBe(expectedOut); + }); + + it("longitudeRangeContainsInclusive works", function () { + // Both positive, left, in, right + expect(checkAll(20, 40, 10, false)).toBeTrue(); + expect(checkAll(20, 40, 20, true)).toBeTrue(); + expect(checkAll(20, 40, 30, true)).toBeTrue(); + expect(checkAll(20, 40, 40, true)).toBeTrue(); + expect(checkAll(20, 40, 50, false)).toBeTrue(); + + // Both negative, left, in, right + expect(checkAll(-40, -20, -50, false)).toBeTrue(); + expect(checkAll(-40, -20, -40, true)).toBeTrue(); + expect(checkAll(-40, -20, -30, true)).toBeTrue(); + expect(checkAll(-40, -20, -20, true)).toBeTrue(); + expect(checkAll(-40, -20, -10, false)).toBeTrue(); + + // Crossing meridian, left, negative in, positive in, right + expect(checkAll(-20, 20, -30, false)).toBeTrue(); + expect(checkAll(-20, 20, -20, true)).toBeTrue(); + expect(checkAll(-20, 20, -10, true)).toBeTrue(); + expect(checkAll(-20, 20, 10, true)).toBeTrue(); + expect(checkAll(-20, 20, 20, true)).toBeTrue(); + expect(checkAll(-20, 20, 30, false)).toBeTrue(); + + // Crossing antimeridian, left, positive in, negative in, right + expect(checkAll(160, -160, 150, false)).toBeTrue(); + expect(checkAll(160, -160, 160, true)).toBeTrue(); + expect(checkAll(160, -160, 170, true)).toBeTrue(); + expect(checkAll(160, -160, -160, true)).toBeTrue(); + expect(checkAll(160, -160, -150, false)).toBeTrue(); + + // Special cases + expect(checkAll(180, -180, 180, true)).toBeTrue(); + expect(checkAll(180, -180, -180, true)).toBeTrue(); + expect(checkAll(0, 0, 0, true)).toBeTrue(); + expect(checkAll(0, 0, 360, true)).toBeTrue(); + expect(checkAll(0, 360, 0, true)).toBeTrue(); + expect(checkAll(0, 360, 360, true)).toBeTrue(); + }); +}); diff --git a/src/tools/contentProcessing/VertexProcessing.ts b/src/tools/contentProcessing/VertexProcessing.ts new file mode 100644 index 00000000..6215cfa3 --- /dev/null +++ b/src/tools/contentProcessing/VertexProcessing.ts @@ -0,0 +1,452 @@ +import { Node } from "@gltf-transform/core"; +import { Scene } from "@gltf-transform/core"; +import { PropertyType } from "@gltf-transform/core"; + +import { ContentDataTypeRegistry } from "../../base"; +import { ContentDataTypes } from "../../base"; + +import { BatchTable } from "../../structure"; +import { B3dmFeatureTable } from "../../structure"; +import { I3dmFeatureTable } from "../../structure"; +import { PntsFeatureTable } from "../../structure"; + +import { TileData } from "../../tilesets"; +import { TileFormatError } from "../../tilesets"; +import { TileFormats } from "../../tilesets"; +import { TileTableData } from "../../tilesets"; +import { TileTableDataI3dm } from "../../tilesets"; + +import { PntsPointClouds } from "../pointClouds/PntsPointClouds"; + +import { BoundingVolumes } from "../tilesetProcessing/BoundingVolumes"; + +import { GltfUpgrade } from "../migration/GltfUpgrade"; + +/** + * Methods to process the vertices that are contained in tile content. + * + * @internal + */ +export class VertexProcessing { + /** + * Process all vertices from the specified tile content. + * + * This will examine the given content data, and process it based on + * its type, passing the positions of all vertices of the tile data + * to the given consumer. + * + * The consumer may always receive the same array instance. The + * consumer should not store or modify this instance. + * + * The consumer will receive the positions of + * - points in PNTS data + * - vertices of mesh primitives of GLB data + * - vertices of mesh primitives of GLB in B3DM data + * - vertices from I3DM data (see notes below) + * - the points/vertices from CMPT data, recursively + * + * For I3DM data, there are two options: + * + * When `processInstancePoints` is `true`, then this will process + * all points of the instanced GLB model, transformed with EACH + * of the instancing matrices. When `processInstancePoints` is + * `false`, then it will compute the bounding box of the GLB model, + * and process only the CORNERS of the bounding box, transformed + * with each of the instancing matrices. + * (Note that `processInstancePoints` may be slow when there are + * many instances) + * + * @param contentUri - The content URI + * @param data - The content data + * @param externalGlbResolver - The resolver for external GLBs in I3DMs + * @param processInstancePoints - Whether the points from instances should + * be processed individually + * @param consumer - The consumer that will receive the vertices + * @throws TileFormatError if the I3DM referred to a GLB that could not be + * resolved + */ + static async fromContent( + contentUri: string, + data: Buffer, + externalGlbResolver: (glbUri: string) => Promise, + processInstancePoints: boolean, + consumer: (p: number[]) => void + ): Promise { + const contentDataType = await ContentDataTypeRegistry.findType( + contentUri, + data + ); + if (contentDataType === ContentDataTypes.CONTENT_TYPE_GLB) { + await VertexProcessing.fromGlb(data, consumer); + } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_PNTS) { + await VertexProcessing.fromPnts(data, consumer); + } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_B3DM) { + await VertexProcessing.fromB3dm(data, consumer); + } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_I3DM) { + await VertexProcessing.fromI3dm( + data, + externalGlbResolver, + processInstancePoints, + consumer + ); + } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_CMPT) { + await VertexProcessing.fromCmpt( + data, + externalGlbResolver, + processInstancePoints, + consumer + ); + } + } + + /** + * Implementation of `fromContent` for PNTS (see `fromContent`) + * + * @param pntsBuffer - The PNTS data buffer + * @param consumer - The consumer that will receive the vertices + */ + private static async fromPnts( + pntsBuffer: Buffer, + consumer: (p: number[]) => void + ): Promise { + // Read the tile data from the input data + const tileData = TileFormats.readTileData(pntsBuffer); + const batchTable = tileData.batchTable.json as BatchTable; + const featureTable = tileData.featureTable.json as PntsFeatureTable; + const featureTableBinary = tileData.featureTable.binary; + + // Create a `ReadablePointCloud` that allows accessing + // the PNTS data + const pntsPointCloud = await PntsPointClouds.create( + featureTable, + featureTableBinary, + batchTable + ); + + // Compute the positions, taking the global position + // into account + const globalPosition = pntsPointCloud.getGlobalPosition() ?? [0, 0, 0]; + const localPositions = pntsPointCloud.getPositions(); + const vertexPosition = Array(3); + for (const localPosition of localPositions) { + vertexPosition[0] = localPosition[0] + globalPosition[0]; + vertexPosition[1] = localPosition[1] + globalPosition[1]; + vertexPosition[2] = localPosition[2] + globalPosition[2]; + consumer(vertexPosition); + } + } + + /** + * Implementation of `fromContent` for B3DM (see `fromContent`) + * + * @param b3dmBuffer - The B3DM data buffer + * @param consumer - The consumer that will receive the vertices + */ + private static async fromB3dm( + b3dmBuffer: Buffer, + consumer: (p: number[]) => void + ): Promise { + // Compute the bounding volume box from the payload (GLB data) + const tileData = TileFormats.readTileData(b3dmBuffer); + const glbBuffer = tileData.payload; + + const positionForTileset = Array(3); + + // If the feature table defines an `RTC_CENTER`, then + // translate the bounding volume box by this amount + const featureTable = tileData.featureTable.json as B3dmFeatureTable; + if (featureTable.RTC_CENTER) { + const featureTableBinary = tileData.featureTable.binary; + const rtcCenter = TileTableData.obtainRtcCenter( + featureTable.RTC_CENTER, + featureTableBinary + ); + await VertexProcessing.fromGlb(glbBuffer, (position) => { + // Apply RTC_CENTER + positionForTileset[0] = position[0] + rtcCenter[0]; + positionForTileset[1] = position[1] + rtcCenter[1]; + positionForTileset[2] = position[2] + rtcCenter[2]; + + consumer(positionForTileset); + }); + } else { + await VertexProcessing.fromGlb(glbBuffer, (position) => { + consumer(position); + }); + } + } + + /** + * Implementation of `fromContent` for I3DM (see `fromContent`) + * + * @param i3dmBuffer - The I3DM data buffer + * @param externalGlbResolver - The resolver for external GLB data from I3DMs + * @param processInstancePoints - Whether the points from instances should + * be processed individually + * @param consumer - The consumer that will receive the vertices + * @throws TileFormatError if the I3DM referred to a GLB that could not be + * resolved + */ + private static async fromI3dm( + i3dmBuffer: Buffer, + externalGlbResolver: (glbUri: string) => Promise, + processInstancePoints: boolean, + consumer: (p: number[]) => void + ): Promise { + // Obtain the GLB buffer for the tile data. With `gltfFormat===1`, it + // is stored directly as the payload. Otherwise (with `gltfFormat===0`) + // the payload is a URI that has to be resolved. + const tileData = TileFormats.readTileData(i3dmBuffer); + const glbBuffer = await TileFormats.obtainGlbPayload( + tileData, + externalGlbResolver + ); + if (!glbBuffer) { + throw new TileFormatError( + `Could not resolve external GLB from I3DM file` + ); + } + if (processInstancePoints) { + await VertexProcessing.fromI3dmPoints(tileData, glbBuffer, consumer); + } else { + await VertexProcessing.fromI3dmCorners(tileData, glbBuffer, consumer); + } + } + + /** + * Implementation of `fromContent` for I3DM (see `fromContent`), + * for the case that `processInstancePoints` was `false`. + * + * This will compute the bounding volume box corners of the GLB, + * and then transform these corners with each instancing + * transform, passing the results to the given consumer. + * + * @param tileData - The tile data + * @param glbBuffer - The GLB data buffer + * @param consumer - The consumer that will receive the vertices + */ + private static async fromI3dmCorners( + tileData: TileData, + glbBuffer: Buffer, + consumer: (p: number[]) => void + ): Promise { + const positions: number[][] = []; + await VertexProcessing.fromGlb(glbBuffer, (p: number[]) => { + positions.push(p.slice()); + }); + const gltfBoundingVolumeBox = + BoundingVolumes.createBoundingVolumeBoxFromPoints(positions); + + const gltfCorners = BoundingVolumes.computeBoundingVolumeBoxCorners( + gltfBoundingVolumeBox + ); + + // Compute the instance matrices of the I3DM data + const featureTable = tileData.featureTable.json as I3dmFeatureTable; + const featureTableBinary = tileData.featureTable.binary; + const numInstances = featureTable.INSTANCES_LENGTH; + const instanceMatrices = TileTableDataI3dm.createInstanceMatrices( + featureTable, + featureTableBinary, + numInstances + ); + + // Compute the set of all corner points of the glTF bounding volume box + // when they are transformed with the instancing transforms. + const transformedCorner = Array(3); + for (const matrix of instanceMatrices) { + for (const gltfCorner of gltfCorners) { + VertexProcessing.transformPoint3D( + matrix, + gltfCorner, + transformedCorner + ); + consumer(transformedCorner); + } + } + } + + /** + * Implementation of `fromContent` for I3DM (see `fromContent`), + * for the case that `processInstancePoints` was `true`. + * + * This will process all points of the GLB data, transformed + * with each instancing transform. + * + * Note that this may be slow when there are many instances. + * + * @param tileData - The tile data + * @param glbBuffer - The GLB data buffer + * @param consumer - The consumer that will receive the vertices + */ + private static async fromI3dmPoints( + tileData: TileData, + glbBuffer: Buffer, + consumer: (p: number[]) => void + ): Promise { + const positions: number[][] = []; + await VertexProcessing.fromGlb(glbBuffer, (p: number[]) => { + positions.push(p.slice()); + }); + + // Compute the instance matrices of the I3DM data + const featureTable = tileData.featureTable.json as I3dmFeatureTable; + const featureTableBinary = tileData.featureTable.binary; + const numInstances = featureTable.INSTANCES_LENGTH; + const instanceMatrices = TileTableDataI3dm.createInstanceMatrices( + featureTable, + featureTableBinary, + numInstances + ); + + // Transform all positions with the instancing transforms, and + // pass the resulting positions to the consumer + const transformedPosition = Array(3); + for (const matrix of instanceMatrices) { + for (const position of positions) { + VertexProcessing.transformPoint3D( + matrix, + position, + transformedPosition + ); + consumer(transformedPosition); + } + } + } + + /** + * Implementation of `fromContent` for CMPT (see `fromContent`) + * + * @param cmptBuffer - The CMPT data buffer + * @param externalGlbResolver - The resolver for external GLB data from I3DMs + * @param processInstancePoints - Whether the points from instances should + * be processed individually + * @param consumer - The consumer that will receive the vertices + */ + private static async fromCmpt( + cmptBuffer: Buffer, + externalGlbResolver: (glbUri: string) => Promise, + processInstancePoints: boolean, + consumer: (p: number[]) => void + ): Promise { + const compositeTileData = TileFormats.readCompositeTileData(cmptBuffer); + const buffers = compositeTileData.innerTileBuffers; + for (const buffer of buffers) { + await VertexProcessing.fromContent( + "[inner tile of CMPT]", + buffer, + externalGlbResolver, + processInstancePoints, + consumer + ); + } + } + + /** + * Implementation of `fromContent` for GLB (see `fromContent`) + * + * This will pass the vertices of all meshes that are contained in + * the default scene (or the first scene, if there is no default) + * to the given consumer. + * + * @param glbBuffer - The buffer containing GLB data + * @param consumer - The consumer that will receive the vertices + */ + private static async fromGlb( + glbBuffer: Buffer, + consumer: (p: number[]) => void + ): Promise { + //const io = await GltfTransform.getIO(); + //const document = await io.readBinary(glbBuffer); + // TODO Obtaining document, INCLUDING possible upgrades - OK? + const document = await GltfUpgrade.obtainDocument(glbBuffer, "Y"); + const root = document.getRoot(); + let scene = root.getDefaultScene(); + if (!scene) { + const scenes = root.listScenes(); + if (scenes.length > 0) { + scene = scenes[0]; + } + } + if (scene) { + VertexProcessing.fromGltfNode(scene, consumer); + } + } + + /** + * Process all vertex positions from the given glTF. + * + * This will traverse the node hierarchy of the given glTF, fetch + * the POSITION attribute of all primitives, fetch the 3D vertices + * from the POSITION attribute, transform them with the global + * transform of the respective node, and pass that transformed + * position to the given consumer. + * + * @param root The root scene or note of the glTF + * @param consumer The consumer that will receive the points as 3-element arrays + */ + private static fromGltfNode( + root: Node | Scene, + consumer: (p: number[]) => void + ): void { + const position = [0, 0, 0]; + const positionZup = [0, 0, 0]; + const rootNodes = + root.propertyType === PropertyType.NODE ? [root] : root.listChildren(); + for (const rootNode of rootNodes) { + rootNode.traverse((node: Node) => { + const mesh = node.getMesh(); + if (!mesh) { + return; + } + const worldMatrix = node.getWorldMatrix(); + const primitives = mesh.listPrimitives(); + for (const primitive of primitives) { + const positionAccessor = primitive.getAttribute("POSITION"); + if (!positionAccessor) { + continue; + } + for (let i = 0; i < positionAccessor.getCount(); i++) { + positionAccessor.getElement(i, position); + VertexProcessing.transformPoint3D(worldMatrix, position, position); + // Take y-up-to-z-up into account + positionZup[0] = position[0]; + positionZup[1] = -position[2]; + positionZup[2] = position[1]; + consumer(positionZup); + } + } + }); + } + } + + /** + * Transforms the given 3D point with the given 4x4 matrix, writes + * the result into the given target, and returns it. If no target + * is given, then a new point will be created and returned. + * + * @param matrix - The 4x4 matrix + * @param point - The 3D point + * @param target - The target + * @returns The result + */ + private static transformPoint3D( + matrix: number[], + point: number[], + target?: number[] + ): number[] { + const px = point[0]; + const py = point[1]; + const pz = point[2]; + const x = matrix[0] * px + matrix[4] * py + matrix[8] * pz + matrix[12]; + const y = matrix[1] * px + matrix[5] * py + matrix[9] * pz + matrix[13]; + const z = matrix[2] * px + matrix[6] * py + matrix[10] * pz + matrix[14]; + if (!target) { + return [x, y, z]; + } + target[0] = x; + target[1] = y; + target[2] = z; + return target; + } +} diff --git a/src/tools/tilesetProcessing/BoundingVolumesContainment.ts b/src/tools/tilesetProcessing/BoundingVolumesContainment.ts new file mode 100644 index 00000000..e513f15c --- /dev/null +++ b/src/tools/tilesetProcessing/BoundingVolumesContainment.ts @@ -0,0 +1,248 @@ +import { Matrix3 } from "cesium"; +import { Cartographic } from "cesium"; +import { Cartesian3 } from "cesium"; +import { Ellipsoid } from "cesium"; +import { Math as CesiumMath } from "cesium"; + +import { BoundingVolume } from "../../structure"; + +import { Loggers } from "../../base"; +const logger = Loggers.get("tilesetProcessing"); + +/** + * A class offering methods for containment checks of bounding volumes + */ +export class BoundingVolumesContainment { + // Scratch variable for all containment methods + private static readonly positionScratch = new Cartesian3(); + + // Scratch variables for boxContains + private static readonly halfAxesScratch = new Matrix3(); + private static readonly halfAxesInverseScratch = new Matrix3(); + private static readonly centerScratch = new Cartesian3(); + + // Scratch variable for regionContains + private static readonly cartographicScratch = new Cartographic(); + + /** + * Returns whether the given bounding volume contains the given point. + * + * If the given bounding volume is neither a `box` nor a `sphere` + * nor a `region`, then a warning will be printed, and `false` + * will be returned + * + * @param boundingVolume - The bounding volume + * @param point - The point, as a 3-element array + * @param epsilon - The absolute epsilon + * @returns Whether the box contains the point + */ + static contains( + boundingVolume: BoundingVolume, + point: number[], + epsilon: number + ): boolean { + if (boundingVolume.box) { + return BoundingVolumesContainment.boxContains( + boundingVolume.box, + point, + epsilon + ); + } + if (boundingVolume.sphere) { + return BoundingVolumesContainment.sphereContains( + boundingVolume.sphere, + point, + epsilon + ); + } + if (boundingVolume.region) { + return BoundingVolumesContainment.regionContains( + boundingVolume.region, + point, + epsilon + ); + } + logger.warn("Unknown bounding volume type: ", boundingVolume); + return false; + } + + /** + * Returns whether the given bounding box contains the given point. + * + * @param box - The box, as a 12-element array in center-halfAxes + * representation, as defined in the 3D Tiles specification + * @param point - The point, as a 3-element array + * @param epsilon - The absolute epsilon + * @returns Whether the box contains the point + */ + static boxContains(box: number[], point: number[], epsilon: number): boolean { + const halfAxes = BoundingVolumesContainment.halfAxesScratch; + const halfAxesInverse = BoundingVolumesContainment.halfAxesInverseScratch; + const position = BoundingVolumesContainment.positionScratch; + const center = BoundingVolumesContainment.centerScratch; + + Matrix3.fromArray(box, 3, halfAxes); + Matrix3.inverse(halfAxes, halfAxesInverse); + Cartesian3.fromArray(point, 0, position); + Cartesian3.fromArray(box, 0, center); + + Cartesian3.subtract(position, center, position); + Matrix3.multiplyByVector(halfAxesInverse, position, position); + + const containedX = Math.abs(position.x) <= 1.0 + epsilon; + const containedY = Math.abs(position.y) <= 1.0 + epsilon; + const containedZ = Math.abs(position.z) <= 1.0 + epsilon; + const contained = containedX && containedY && containedZ; + return contained; + } + + /** + * Returns whether the given bounding sphere contains the given point. + * + * @param sphere - The sphere, as a 4-element array in center-radius + * representation, as defined in the 3D Tiles specification + * @param point - The point as a 3-element array + * @param epsilon - The absolute epsilon + * @returns Whether the sphere contains the given point + */ + static sphereContains( + sphere: number[], + point: number[], + epsilon: number + ): boolean { + const center = BoundingVolumesContainment.centerScratch; + const position = BoundingVolumesContainment.positionScratch; + + Cartesian3.fromArray(sphere, 0, center); + const radius = sphere[3]; + Cartesian3.fromArray(point, 0, position); + + const limit = (radius + epsilon) * (radius + epsilon); + const distanceSquared = Cartesian3.distanceSquared(center, position); + const contained = distanceSquared <= limit; + return contained; + } + + /** + * Returns whether the given bounding region contains the given point. + * + * This converts the given point into its cartographic representation + * based on the WGS84 ellipsoid, and checks the resulting point for + * containment in the given bounding region. + * + * @param region - The bounding region, in [westRad, southRad, eastRad, northRad, + * minHeightMeters, maxHeightMeters] representation, as defined in the 3D Tiles + * specification + * @param point - The point as a 3-element array + * @param epsilon - The (absolute) epsilon + * @returns Whether the region contains the given point + */ + static regionContains( + region: number[], + point: number[], + epsilon: number + ): boolean { + const westRad = region[0]; + const southRad = region[1]; + const eastRad = region[2]; + const northRad = region[3]; + const minHeightMeters = region[4]; + const maxHeightMeters = region[5]; + + const position = BoundingVolumesContainment.positionScratch; + const cartographic = BoundingVolumesContainment.cartographicScratch; + + Cartesian3.fromArray(point, 0, position); + Cartographic.fromCartesian(position, Ellipsoid.WGS84, cartographic); + + const lonRad = cartographic.longitude; + const latRad = cartographic.latitude; + const heightMeters = cartographic.height; + + const containedLat = + latRad >= southRad - epsilon && latRad <= northRad + epsilon; + const containedLon = + BoundingVolumesContainment.normalizedLongitudeRangeContainsInclusive( + westRad, + eastRad, + lonRad, + epsilon + ); + const containedHeight = + heightMeters >= minHeightMeters - epsilon && + heightMeters <= maxHeightMeters + epsilon; + + const contained = containedLat && containedLon && containedHeight; + return contained; + } + + /** + * Returns whether the given west-east range contains the given point. + * + * This method performs a normalization of the given range and the + * point, bringing them into the range [-PI, PI). + * + * This check is inclusive, meaning that this method returns true + * when the point is exactly at the border (including the epsilon) + * of the given range. + * + * @param westRad - The west (leftmost) point in radians + * @param eastRad - The east (rightmost) point in radians + * @param pointRad - The point in radians + * @param epsilon - The (absolute) epsilon + * @returns Whether the range contains the point + */ + static longitudeRangeContainsInclusive( + westRad: number, + eastRad: number, + pointRad: number, + epsilon: number + ) { + westRad = CesiumMath.convertLongitudeRange(westRad); + eastRad = CesiumMath.convertLongitudeRange(eastRad); + pointRad = CesiumMath.convertLongitudeRange(pointRad); + return BoundingVolumesContainment.normalizedLongitudeRangeContainsInclusive( + westRad, + eastRad, + pointRad, + epsilon + ); + } + + /** + * Returns whether the given west-east range contains the given point. + * + * This method assumes that the given range and point are normalized, + * meaning that they are in the range [-PI, PI). + * + * This check is inclusive, meaning that this method returns true + * when the point is exactly at the border (including the epsilon) + * of the given range. + * + * @param westRad - The west (leftmost) point in radians + * @param eastRad - The east (rightmost) point in radians + * @param pointRad - The point in radians + * @param epsilon - The (absolute) epsilon + * @returns Whether the range contains the point + */ + private static normalizedLongitudeRangeContainsInclusive( + westRad: number, + eastRad: number, + pointRad: number, + epsilon: number + ) { + if (eastRad < westRad) { + eastRad += CesiumMath.TWO_PI; + if (pointRad < 0.0) { + pointRad += CesiumMath.TWO_PI; + } + } + if (pointRad < westRad - epsilon) { + return false; + } + if (pointRad > eastRad + epsilon) { + return false; + } + return true; + } +} diff --git a/src/tools/tilesetProcessing/ContentBoundingVolumes.ts b/src/tools/tilesetProcessing/ContentBoundingVolumes.ts index 8131fa58..f8640492 100644 --- a/src/tools/tilesetProcessing/ContentBoundingVolumes.ts +++ b/src/tools/tilesetProcessing/ContentBoundingVolumes.ts @@ -1,26 +1,7 @@ -import { Node, PropertyType, Scene } from "@gltf-transform/core"; - -import { ContentDataTypeRegistry } from "../../base"; -import { ContentDataTypes } from "../../base"; - -import { B3dmFeatureTable } from "../../structure"; -import { BatchTable } from "../../structure"; -import { I3dmFeatureTable } from "../../structure"; -import { PntsFeatureTable } from "../../structure"; - -import { TileFormats } from "../../tilesets"; -import { TileFormatError } from "../../tilesets"; -import { TileTableData } from "../../tilesets"; -import { TileTableDataI3dm } from "../../tilesets"; - -import { GltfTransform } from "../contentProcessing/GltfTransform"; -import { PntsPointClouds } from "../pointClouds/PntsPointClouds"; +import { VertexProcessing } from "../contentProcessing/VertexProcessing"; import { BoundingVolumes } from "./BoundingVolumes"; -import { Loggers } from "../../base"; -const logger = Loggers.get("tilesetProcessing"); - /** * Methods to compute bounding volumes from tile content data. * @@ -40,7 +21,7 @@ export class ContentBoundingVolumes { * @param externalGlbResolver - The resolver for external GLBs in I3DMs * @returns The bounding volume box, or undefined if no bounding * volume box could be computed from the given content. - * @throws Error if the I3DM referred to a GLB that could not be + * @throws TileFormatError if the I3DM referred to a GLB that could not be * resolved */ static async computeContentDataBoundingVolumeBox( @@ -48,318 +29,20 @@ export class ContentBoundingVolumes { data: Buffer, externalGlbResolver: (glbUri: string) => Promise ): Promise { - const contentDataType = await ContentDataTypeRegistry.findType( + const processInstancePoints = false; + const points: number[][] = []; + const consumer = (p: number[]) => { + points.push(p.slice()); + }; + await VertexProcessing.fromContent( contentUri, - data - ); - if (contentDataType === ContentDataTypes.CONTENT_TYPE_GLB) { - return ContentBoundingVolumes.computeBoundingVolumeBoxFromGlb(data); - } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_PNTS) { - return ContentBoundingVolumes.computeBoundingBoxFromPnts(data); - } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_B3DM) { - return ContentBoundingVolumes.computeBoundingVolumeBoxFromB3dm(data); - } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_I3DM) { - return ContentBoundingVolumes.computeBoundingVolumeBoxFromI3dm( - data, - externalGlbResolver - ); - } else if (contentDataType === ContentDataTypes.CONTENT_TYPE_CMPT) { - return ContentBoundingVolumes.computeBoundingVolumeBoxFromCmpt( - data, - externalGlbResolver - ); - } - return undefined; - } - - /** - * Computes the bounding volume box of the given PNTS data - * - * @param pntsBuffer - The PNTS data buffer - * @returns A promise to the bounding volume box - */ - private static async computeBoundingBoxFromPnts( - pntsBuffer: Buffer - ): Promise { - // Read the tile data from the input data - const tileData = TileFormats.readTileData(pntsBuffer); - const batchTable = tileData.batchTable.json as BatchTable; - const featureTable = tileData.featureTable.json as PntsFeatureTable; - const featureTableBinary = tileData.featureTable.binary; - - // Create a `ReadablePointCloud` that allows accessing - // the PNTS data - const pntsPointCloud = await PntsPointClouds.create( - featureTable, - featureTableBinary, - batchTable - ); - - // Compute the positions, taking the global position - // into account - const globalPosition = pntsPointCloud.getGlobalPosition() ?? [0, 0, 0]; - const localPositions = pntsPointCloud.getPositions(); - const positions: number[][] = []; - for (const localPosition of localPositions) { - const position: number[] = [ - localPosition[0] + globalPosition[0], - localPosition[1] + globalPosition[1], - localPosition[2] + globalPosition[2], - ]; - positions.push(position); - } - return BoundingVolumes.createBoundingVolumeBoxFromPoints(positions); - } - - /** - * Computes the bounding volume box of the given B3DM data - * - * @param b3dmBuffer - The B3DM data buffer - * @returns A promise to the bounding volume box - */ - private static async computeBoundingVolumeBoxFromB3dm( - b3dmBuffer: Buffer - ): Promise { - // Compute the bounding volume box from the payload (GLB data) - const tileData = TileFormats.readTileData(b3dmBuffer); - const glbBuffer = tileData.payload; - const gltfBoundingVolumeBox = - await ContentBoundingVolumes.computeBoundingVolumeBoxFromGlb(glbBuffer); - - // If the feature table defines an `RTC_CENTER`, then - // translate the bounding volume box by this amount - const featureTable = tileData.featureTable.json as B3dmFeatureTable; - if (featureTable.RTC_CENTER) { - const featureTableBinary = tileData.featureTable.binary; - const rtcCenter = TileTableData.obtainRtcCenter( - featureTable.RTC_CENTER, - featureTableBinary - ); - const b3dmBoundingVolumeBox = BoundingVolumes.translateBoundingVolumeBox( - gltfBoundingVolumeBox, - rtcCenter - ); - return b3dmBoundingVolumeBox; - } - return gltfBoundingVolumeBox; - } - - /** - * Computes the bounding volume box of the given I3DM data - * - * @param i3dmBuffer - The I3DM data buffer - * @param externalGlbResolver - The resolver for external GLB data from I3DMs - * @returns A promise to the bounding volume box - * @throws Error if the I3DM referred to a GLB that could not be - * resolved - */ - private static async computeBoundingVolumeBoxFromI3dm( - i3dmBuffer: Buffer, - externalGlbResolver: (glbUri: string) => Promise - ): Promise { - // Obtain the GLB buffer for the tile data. With `gltfFormat===1`, it - // is stored directly as the payload. Otherwise (with `gltfFormat===0`) - // the payload is a URI that has to be resolved. - const tileData = TileFormats.readTileData(i3dmBuffer); - const glbBuffer = await TileFormats.obtainGlbPayload( - tileData, - externalGlbResolver - ); - if (!glbBuffer) { - throw new TileFormatError( - `Could not resolve external GLB from I3DM file` - ); - } - - // Note: The approach here is to compute the bounding volume box - // corners of the GLB, and then compute a bounding volume box - // from these corners when they are transformed with each - // instancing transform. A tighter bounding volume MIGHT be - // achievable by computing the bounding volume from all - // points of the GLB after the transformation. But this would - // be VERY inefficient for MANY instances (and defeat the - // purpose of instancing itself...) - - // Compute the bounding volume box from the payload (GLB data) - const gltfBoundingVolumeBox = - await ContentBoundingVolumes.computeBoundingVolumeBoxFromGlb(glbBuffer); - const gltfCorners = BoundingVolumes.computeBoundingVolumeBoxCorners( - gltfBoundingVolumeBox - ); - - // Compute the instance matrices of the I3DM data - const featureTable = tileData.featureTable.json as I3dmFeatureTable; - const featureTableBinary = tileData.featureTable.binary; - const numInstances = featureTable.INSTANCES_LENGTH; - const instanceMatrices = TileTableDataI3dm.createInstanceMatrices( - featureTable, - featureTableBinary, - numInstances - ); - - // Compute the set of all corner points of the glTF bounding volume box - // when they are transformed with the instancing transforms. - const transformedCorners: number[][] = []; - for (const matrix of instanceMatrices) { - for (const gltfCorner of gltfCorners) { - const transformedCorner = ContentBoundingVolumes.transformPoint3D( - matrix, - gltfCorner - ); - transformedCorners.push(transformedCorner); - } - } - return BoundingVolumes.createBoundingVolumeBoxFromPoints( - transformedCorners - ); - } - - /** - * Computes the bounding volume box of the given CMPT data - * - * @param cmptBuffer - The CMPT data buffer - * @returns A promise to the bounding volume box - */ - private static async computeBoundingVolumeBoxFromCmpt( - cmptBuffer: Buffer, - externalGlbResolver: (glbUri: string) => Promise - ): Promise { - const compositeTileData = TileFormats.readCompositeTileData(cmptBuffer); - const buffers = compositeTileData.innerTileBuffers; - const innerBoundingVolumeBoxes: number[][] = []; - for (const buffer of buffers) { - const innerBoundingVolumeBox = - await ContentBoundingVolumes.computeContentDataBoundingVolumeBox( - "[inner tile of CMPT]", - buffer, - externalGlbResolver - ); - if (innerBoundingVolumeBox) { - innerBoundingVolumeBoxes.push(innerBoundingVolumeBox); - } - } - return BoundingVolumes.computeUnionBoundingVolumeBox( - innerBoundingVolumeBoxes - ); - } - - /** - * Computes the bounding volume box of the given glTF asset. - * - * This will compute the bounding volume box of the default scene - * (or the first scene of the asset). If there is no scene, - * then a warning will be printed, and a unit cube bounding - * box will be returned. - * - * @param glbBuffer - The buffer containing GLB data - * @returns A promise to the bounding volume box - */ - private static async computeBoundingVolumeBoxFromGlb( - glbBuffer: Buffer - ): Promise { - return ContentBoundingVolumes.computeOrientedBoundingVolumeBoxFromGlb( - glbBuffer - ); - } - - /** - * Computes the bounding volume box of the given glTF asset. - * - * This will compute the bounding volume box of the default scene - * (or the first scene of the asset). If there is no scene, - * then a warning will be printed, and a unit cube bounding - * box will be returned. - * - * @param glbBuffer - The buffer containing GLB data - * @returns A promise to the bounding volume box - */ - static async computeOrientedBoundingVolumeBoxFromGlb( - glbBuffer: Buffer - ): Promise { - const io = await GltfTransform.getIO(); - const document = await io.readBinary(glbBuffer); - const root = document.getRoot(); - let scene = root.getDefaultScene(); - if (!scene) { - const scenes = root.listScenes(); - if (scenes.length > 0) { - scene = scenes[0]; - } - } - if (scene) { - const positions: number[][] = []; - ContentBoundingVolumes.processVertexPositions(scene, (p: number[]) => { - // take y-up-to-z-up into account - const q = [p[0], -p[2], p[1]]; - positions.push(q); - }); - return BoundingVolumes.createBoundingVolumeBoxFromPoints(positions); - } - logger.warn("No scenes found in glTF - using unit bounding box"); - return BoundingVolumes.createUnitCubeBoundingVolumeBox(); - } - - private static processVertexPositions( - root: Node | Scene, - consumer: (p: number[]) => void - ): void { - const position = [0, 0, 0]; - const rootNodes = - root.propertyType === PropertyType.NODE ? [root] : root.listChildren(); - for (const rootNode of rootNodes) { - rootNode.traverse((node: Node) => { - const mesh = node.getMesh(); - if (!mesh) { - return; - } - const worldMatrix = node.getWorldMatrix(); - const primitives = mesh.listPrimitives(); - for (const primitive of primitives) { - const positionAccessor = primitive.getAttribute("POSITION"); - if (!positionAccessor) { - continue; - } - for (let i = 0; i < positionAccessor.getCount(); i++) { - positionAccessor.getElement(i, position); - ContentBoundingVolumes.transformPoint3D( - worldMatrix, - position, - position - ); - consumer(position); - } - } - }); - } - } - - /** - * Transforms the given 3D point with the given 4x4 matrix, writes - * the result into the given target, and returns it. If no target - * is given, then a new point will be created and returned. - * - * @param matrix - The 4x4 matrix - * @param point - The 3D point - * @param target - The target - * @returns The result - */ - private static transformPoint3D( - matrix: number[], - point: number[], - target?: number[] - ): number[] { - const px = point[0]; - const py = point[1]; - const pz = point[2]; - const x = matrix[0] * px + matrix[4] * py + matrix[8] * pz + matrix[12]; - const y = matrix[1] * px + matrix[5] * py + matrix[9] * pz + matrix[13]; - const z = matrix[2] * px + matrix[6] * py + matrix[10] * pz + matrix[14]; - if (!target) { - return [x, y, z]; - } - target[0] = x; - target[1] = y; - target[2] = z; - return target; + data, + externalGlbResolver, + processInstancePoints, + consumer + ); + const boundingVolumeBox = + BoundingVolumes.createBoundingVolumeBoxFromPoints(points); + return boundingVolumeBox; } }