diff --git a/package.json b/package.json index ba3ef15c..ca3a9fa8 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,12 @@ "3d-tiles-validator": "./build/main" }, "dependencies": { - "3d-tiles-tools": "^0.3.0", + "3d-tiles-tools": "file:../3d-tiles-tools", "cesium": "^1.97.0", "gltf-validator": "^2.0.0-dev.3.9", "minimatch": "^5.1.0", "node-stream-zip": "^1.10.1", + "sharp": "^0.32.1", "yargs": "^17.5.1" }, "devDependencies": { diff --git a/src/issues/GltfExtensionValidationIssues.ts b/src/issues/GltfExtensionValidationIssues.ts index 541741b5..c28a93a9 100644 --- a/src/issues/GltfExtensionValidationIssues.ts +++ b/src/issues/GltfExtensionValidationIssues.ts @@ -20,4 +20,71 @@ export class GltfExtensionValidationIssues { const issue = new ValidationIssue(type, path, message, severity); return issue; } + + /** + * Indicates that the extension referred to an element in the + * glTF assset that did not match the requirements of the + * extension specification. (For example, a 'texCoord' that + * referred to a VEC3 accessor). Further details should be + * encoded in the 'message'. + * + * @param path - The path for the `ValidationIssue` + * @param message - The message for the `ValidationIssue` + * @returns The `ValidationIssue` + */ + static INVALID_GLTF_STRUCTURE(path: string, message: string) { + const type = "INVALID_GLTF_STRUCTURE"; + const severity = ValidationIssueSeverity.ERROR; + const issue = new ValidationIssue(type, path, message, severity); + return issue; + } + + /** + * Indicates that the feature ID texture 'channels' property + * had a structure that did not match the actual image data, + * meaning that the `channels` array contained an element + * that was not smaller than the number of actual channels + * in the image. + * + * @param path - The path for the `ValidationIssue` + * @param message - The message for the `ValidationIssue` + * @returns The `ValidationIssue` + */ + static TEXTURE_CHANNELS_OUT_OF_RANGE(path: string, message: string) { + const type = "TEXTURE_CHANNELS_OUT_OF_RANGE"; + const severity = ValidationIssueSeverity.ERROR; + const issue = new ValidationIssue(type, path, message, severity); + return issue; + } + + /** + * Indicates that the featureCount of a feature ID did not + * match the actual number of IDs + * + * @param path - The path for the `ValidationIssue` + * @param message - The message for the `ValidationIssue` + * @returns The `ValidationIssue` + */ + static FEATURE_COUNT_MISMATCH(path: string, message: string) { + const type = "FEATURE_COUNT_MISMATCH"; + const severity = ValidationIssueSeverity.ERROR; + const issue = new ValidationIssue(type, path, message, severity); + return issue; + } + + /** + * Indicates that a feature ID texture sampler used a filtering + * method (`minFilter` or `magFilter`) that was not `undefined` + * and not 9728 (`NEAREST`). + * + * @param path - The path for the `ValidationIssue` + * @param message - The message for the `ValidationIssue` + * @returns The `ValidationIssue` + */ + static INVALID_FILTER_MODE(path: string, message: string) { + const type = "INVALID_FILTER_MODE"; + const severity = ValidationIssueSeverity.ERROR; + const issue = new ValidationIssue(type, path, message, severity); + return issue; + } } diff --git a/src/validation/gltfExtensions/Accessors.ts b/src/validation/gltfExtensions/Accessors.ts new file mode 100644 index 00000000..440221f2 --- /dev/null +++ b/src/validation/gltfExtensions/Accessors.ts @@ -0,0 +1,142 @@ +import { BinaryBufferData } from "3d-tiles-tools"; + +/** + * Utility methods related to glTF accessors. + * + * NOTE: These methods are only intended for the use in the + * glTF extension validation. They make assumptions about + * the validity of the glTF asset (as established by the + * glTF Validator), and the structure of the glTF asset + * (as established by the extension validator). + * + * @internal + */ +export class Accessors { + /** + * Read the values of the given accessor into an array of numbers. + * + * This assumes that the accessor has the type SCALAR, and is + * valid (in terms of its bufferView etc), as validated with + * the glTF Validator. + * + * @param accessor - The glTF accessor + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @returns The accessor values (or undefined if the input + * glTF data was invalid) + */ + static readScalarAccessorValues( + accessor: any, + gltf: any, + binaryBufferData: BinaryBufferData + ): number[] | undefined { + const bufferViewIndex = accessor.bufferView; + const bufferViews = gltf.bufferViews || []; + const bufferView = bufferViews[bufferViewIndex]; + const componentType = accessor.componentType; + let byteStride = bufferView.byteStride; + if (byteStride === undefined) { + byteStride = Accessors.sizeForComponentType(componentType); + if (byteStride === undefined) { + // Invalid component type, should have been + // detected by the glTF-Validator + return undefined; + } + } + const bufferViewData = binaryBufferData.bufferViewsData[bufferViewIndex]; + const count = accessor.count; + const byteOffset = accessor.byteOffset; + const byteLength = count * byteStride; + const accessorData = bufferViewData.subarray( + byteOffset, + byteOffset + byteLength + ); + + const values = []; + for (let i = 0; i < count; i++) { + const value = Accessors.readValue( + accessorData, + i, + byteStride, + componentType + ); + if (value === undefined) { + // Invalid component type, should have been + // detected by the glTF-Validator + return undefined; + } + values.push(value); + } + return values; + } + + /** + * Returns the size in bytes for the given accessor component type, + * or undefined if the given component type is not valid. + * + * @param componentType - The component type + * @returns The size in bytes + */ + private static sizeForComponentType( + componentType: number + ): number | undefined { + const BYTE = 5120; + const UNSIGNED_BYTE = 5121; + const SHORT = 5122; + const UNSIGNED_SHORT = 5123; + const UNSIGNED_INT = 5125; + const FLOAT = 5126; + switch (componentType) { + case BYTE: + case UNSIGNED_BYTE: + return 1; + case SHORT: + case UNSIGNED_SHORT: + return 2; + case UNSIGNED_INT: + case FLOAT: + return 4; + } + return undefined; + } + + /** + * Read a single numeric value from a buffer that contains + * the accessor data + * + * @param buffer - The buffer + * @param index - The index + * @param byteStide - The byte stride + * @param componentType - The component type + * @returns The value + */ + private static readValue( + buffer: Buffer, + index: number, + byteStide: number, + componentType: number + ): number | undefined { + const BYTE = 5120; + const UNSIGNED_BYTE = 5121; + const SHORT = 5122; + const UNSIGNED_SHORT = 5123; + const UNSIGNED_INT = 5125; + const FLOAT = 5126; + const byteOffset = index * byteStide; + switch (componentType) { + case BYTE: + return buffer.readInt8(byteOffset); + case UNSIGNED_BYTE: + return buffer.readUint8(byteOffset); + case SHORT: + return buffer.readInt16LE(byteOffset); + case UNSIGNED_SHORT: + return buffer.readUInt16LE(byteOffset); + case UNSIGNED_INT: + return buffer.readUInt32LE(byteOffset); + case FLOAT: + return buffer.readFloatLE(byteOffset); + } + return undefined; + } +} diff --git a/src/validation/gltfExtensions/ExtMeshFeaturesValidator.ts b/src/validation/gltfExtensions/ExtMeshFeaturesValidator.ts index 4dc5ec83..27ba031b 100644 --- a/src/validation/gltfExtensions/ExtMeshFeaturesValidator.ts +++ b/src/validation/gltfExtensions/ExtMeshFeaturesValidator.ts @@ -1,12 +1,28 @@ import { defined } from "3d-tiles-tools"; +import { defaultValue } from "3d-tiles-tools"; +import { BinaryBufferData } from "3d-tiles-tools"; +import { BinaryBufferDataResolver } from "3d-tiles-tools"; +import { BinaryBufferStructure } from "3d-tiles-tools"; import { ValidationContext } from "./../ValidationContext"; import { BasicValidator } from "./../BasicValidator"; +import { GltfData } from "./GltfData"; +import { ImageData } from "./ImageData"; +import { ImageDataReader } from "./ImageDataReader"; + +import { GltfExtensionValidationIssues } from "../../issues/GltfExtensionValidationIssues"; +import { StructureValidationIssues } from "../../issues/StructureValidationIssues"; +import { IoValidationIssues } from "../../issues/IoValidationIssue"; +import { ValidationIssues } from "../../issues/ValidationIssues"; +import { Accessors } from "./Accessors"; /** * A class for validating the `EXT_mesh_features` extension in * glTF assets. * + * This class assumes that the structure of the glTF asset itself + * has already been validated (e.g. with the glTF Validator). + * * @internal */ export class ExtMeshFeaturesValidator { @@ -15,15 +31,33 @@ export class ExtMeshFeaturesValidator { * extensions in the given glTF are valid * * @param path - The path for validation issues - * @param gltf - The object to validate + * @param gltfData - The glTF data, containing the parsed JSON and the + * (optional) binary buffer * @param context - The `ValidationContext` that any issues will be added to * @returns Whether the object was valid */ - static validateGltf( + static async validateGltf( path: string, - gltf: any, + gltfData: GltfData, context: ValidationContext - ): boolean { + ): Promise { + // Resolve the data of the bufferView/buffer structure + // of the glTF + const gltf = gltfData.gltf; + const binaryBufferStructure: BinaryBufferStructure = { + buffers: gltf.buffers, + bufferViews: gltf.bufferViews, + }; + const resourceResolver = context.getResourceResolver(); + const binaryBufferData = await BinaryBufferDataResolver.resolve( + binaryBufferStructure, + gltfData.binary, + resourceResolver + ); + + // Dig into the (untyped) JSON representation of the + // glTF, to find the mesh primtives that carry the + // EXT_mesh_features extension const meshes = gltf.meshes; if (!meshes) { return true; @@ -54,9 +88,12 @@ export class ExtMeshFeaturesValidator { if (extensionName === "EXT_mesh_features") { const extensionObject = extensions[extensionName]; const objectIsValid = - ExtMeshFeaturesValidator.validateExtMeshFeatures( + await ExtMeshFeaturesValidator.validateExtMeshFeatures( path, extensionObject, + primitive, + gltf, + binaryBufferData, context ); if (!objectIsValid) { @@ -69,11 +106,26 @@ export class ExtMeshFeaturesValidator { return result; } - private static validateExtMeshFeatures( + /** + * Validate the given EXT_mesh_features extension object that was + * found in the given mesh primitive. + * + * @param path - The path for validation issues + * @param meshFeatures - The EXT_mesh_features extension object + * @param meshPrimitive - The mesh primitive that contains the extension + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static async validateExtMeshFeatures( path: string, meshFeatures: any, + meshPrimitive: any, + gltf: any, + binaryBufferData: BinaryBufferData, context: ValidationContext - ) { + ): Promise { // Make sure that the given value is an object if ( !BasicValidator.validateObject( @@ -110,13 +162,16 @@ export class ExtMeshFeaturesValidator { for (let i = 0; i < featureIds.length; i++) { const featureId = featureIds[i]; const featureIdPath = featureIdsPath + "/" + i; - if ( - !ExtMeshFeaturesValidator.validateFeatureId( + const featureIdValid = + await ExtMeshFeaturesValidator.validateFeatureId( featureIdPath, featureId, + meshPrimitive, + gltf, + binaryBufferData, context - ) - ) { + ); + if (!featureIdValid) { result = false; } } @@ -125,11 +180,26 @@ export class ExtMeshFeaturesValidator { return result; } - private static validateFeatureId( + /** + * Validate the given feature ID object that was found in the + * `featureIds` array of an EXT_mesh_features extension object + * + * @param path - The path for validation issues + * @param featureId - The feature ID + * @param meshPrimitive - The mesh primitive that contains the extension + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static async validateFeatureId( path: string, featureId: any, + meshPrimitive: any, + gltf: any, + binaryBufferData: BinaryBufferData, context: ValidationContext - ) { + ): Promise { // Make sure that the given value is an object if (!BasicValidator.validateObject(path, "featureId", featureId, context)) { return false; @@ -157,6 +227,770 @@ export class ExtMeshFeaturesValidator { result = false; } + // Validate the nullFeatureId + // The nullFeatureId MUST be an integer of at least 0 + const nullFeatureId = featureId.nullFeatureId; + const nullFeatureIdPath = path + "/nullFeatureId"; + if (defined(nullFeatureId)) { + if ( + !BasicValidator.validateIntegerRange( + nullFeatureIdPath, + "nullFeatureId", + nullFeatureId, + 0, + true, + undefined, + false, + context + ) + ) { + result = false; + } + } + + // Validate the label + // The label MUST be a string + // The label MUST match the ID regex + const label = featureId.label; + const labelPath = path + "/label"; + if (defined(label)) { + if (!BasicValidator.validateString(labelPath, "label", label, context)) { + result = false; + } else { + if ( + !BasicValidator.validateIdentifierString( + labelPath, + "label", + label, + context + ) + ) { + result = false; + } + } + } + + // Validate the attribute + const attribute = featureId.attribute; + const attributePath = path + "/attribute"; + if (defined(attribute)) { + const attributeValid = + await ExtMeshFeaturesValidator.validateFeatureIdAttribute( + attributePath, + attribute, + featureCount, + meshPrimitive, + gltf, + binaryBufferData, + context + ); + if (!attributeValid) { + result = false; + } + } + + // Validate the texture + const texture = featureId.texture; + const texturePath = path + "/texture"; + if (defined(texture)) { + const textureValid = + await ExtMeshFeaturesValidator.validateFeatureIdTexture( + texturePath, + texture, + featureCount, + meshPrimitive, + gltf, + binaryBufferData, + context + ); + if (!textureValid) { + result = false; + } + } + + // Validate the propertyTable + // The propertyTable MUST be an integer of at least 0 + const propertyTable = featureId.propertyTable; + const propertyTablePath = path + "/propertyTable"; + if (defined(propertyTable)) { + if ( + !BasicValidator.validateIntegerRange( + propertyTablePath, + "propertyTable", + propertyTable, + 0, + true, + undefined, + false, + context + ) + ) { + result = false; + } + } + + // TODO Validate propertyTable value to be in [0, numPropertyTables]!!! + // Connection to `EXT_structural_metadata` here! + console.log("Property Table value is not validated yet"); + + return result; + } + + /** + * Validate the given feature ID `attribute` value that was found in + * a feature ID definition + * + * @param path - The path for validation issues + * @param attribute - The attribute (i.e. the supposed number that + * will be used for the `_FEATURE_ID_${attribute}` attribute name) + * @param featureCount - The `featureCount` value from the feature ID definition + * @param meshPrimitive - The mesh primitive that contains the extension + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static async validateFeatureIdAttribute( + path: string, + attribute: any, + featureCount: number, + meshPrimitive: any, + gltf: any, + binaryBufferData: BinaryBufferData, + context: ValidationContext + ): Promise { + // Validate the attribute + // The attribute MUST be an integer of at least 0 + if ( + !BasicValidator.validateIntegerRange( + path, + "attribute", + attribute, + 0, + true, + undefined, + false, + context + ) + ) { + return false; + } + + let result = true; + + // For a given attribute value, the attribute + // with the name `_FEATURE_ID_${attribute}` must + // appear as an attribute in the mesh primitive + const featureIdAttributeName = `_FEATURE_ID_${attribute}`; + const featureIdAccessorIndex = ExtMeshFeaturesValidator.findAccessorIndex( + meshPrimitive, + featureIdAttributeName + ); + if (featureIdAccessorIndex === undefined) { + const message = + `The feature ID defines the attribute ${attribute}, ` + + `but the attribute ${featureIdAttributeName} was not ` + + `found in the mesh primitive attributes`; + const issue = StructureValidationIssues.IDENTIFIER_NOT_FOUND( + path, + message + ); + context.addIssue(issue); + result = false; + } else { + const accessors = gltf.accessors || []; + const accessor = accessors[featureIdAccessorIndex]; + const accessorValid = + await ExtMeshFeaturesValidator.validateFeatureIdAccessor( + path, + accessor, + featureCount, + gltf, + binaryBufferData, + context + ); + if (!accessorValid) { + result = false; + } + } + + return result; + } + + private static async validateFeatureIdAccessor( + path: string, + accessor: any, + featureCount: number, + gltf: any, + binaryBufferData: BinaryBufferData, + context: ValidationContext + ): Promise { + // Make sure that the given value is an object + if (!BasicValidator.validateObject(path, "accessor", accessor, context)) { + return false; + } + let result = true; + + // The accessor type must be "SCALAR" + if (accessor.type !== "SCALAR") { + const message = + `The feature ID attribute accessor must have the type 'SCALAR', ` + + `but has the type ${accessor.type}`; + const issue = GltfExtensionValidationIssues.INVALID_GLTF_STRUCTURE( + path, + message + ); + context.addIssue(issue); + result = false; + } + + // The accessor must not be normalized + if (accessor.normalized === true) { + const message = `The feature ID attribute accessor may not be normalized`; + const issue = GltfExtensionValidationIssues.INVALID_GLTF_STRUCTURE( + path, + message + ); + context.addIssue(issue); + result = false; + } + + // Only if the structures have been valid until now, + // validate the actual data of the accessor + if (result) { + const dataValid = + await ExtMeshFeaturesValidator.validateFeatureIdAttributeData( + path, + accessor, + featureCount, + gltf, + binaryBufferData, + context + ); + if (!dataValid) { + result = false; + } + } + return result; + } + + /** + * Validate the data of the given feature ID atribute. + * + * @param path - The path for validation issues + * @param accessor - The feature ID attribute accessor + * @param featureCount - The `featureCount` value from the feature ID definition + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static async validateFeatureIdAttributeData( + path: string, + accessor: any, + featureCount: number, + gltf: any, + binaryBufferData: BinaryBufferData, + context: ValidationContext + ): Promise { + const accessorValues = Accessors.readScalarAccessorValues( + accessor, + gltf, + binaryBufferData + ); + if (!accessorValues) { + // This should only happen for invalid glTF assets (e.g. ones that + // use wrong accessor component types), but the glTF Validator + // should already have caught that. + const message = `Could not read data for feature ID attribute accessor`; + const issue = ValidationIssues.INTERNAL_ERROR(path, message); + context.addIssue(issue); + return false; + } + + // Make sure that the `featureCount` matches the + // actual number of different values that appear + // in the accessor + const featureIdSet = new Set(accessorValues); + if (featureCount !== featureIdSet.size) { + const message = + `The featureCount was ${featureCount}, but the attribute ` + + `accessor contains ${featureIdSet.size} different values`; + const issue = GltfExtensionValidationIssues.FEATURE_COUNT_MISMATCH( + path, + message + ); + context.addIssue(issue); + return false; + } + return true; + } + + /** + * Returns the index of the accessor that contains the data + * of the specified attribute in the given mesh primitive, + * or `undefined` if this attribute does not exist. + * + * @param meshPrimitive - The mesh primitive + * @param attributeName - The attribute name + * @returns The accessor index + */ + private static findAccessorIndex( + meshPrimitive: any, + attributeName: string + ): number | undefined { + const primitiveAttributes = meshPrimitive.attributes || {}; + const accessorIndex = primitiveAttributes[attributeName]; + return accessorIndex; + } + + /** + * Validate the given feature ID `texture` value that was found in + * a feature ID definition + * + * @param path - The path for validation issues + * @param featureIdTexture - The feature ID texture definition + * @param featureCount - The `featureCount` value from the feature ID definition + * @param meshPrimitive - The mesh primitive that contains the extension + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static async validateFeatureIdTexture( + path: string, + featureIdTexture: any, + featureCount: number, + meshPrimitive: any, + gltf: any, + binaryBufferData: BinaryBufferData, + context: ValidationContext + ): Promise { + // Make sure that the given value is an object + if ( + !BasicValidator.validateObject(path, "texture", featureIdTexture, context) + ) { + return false; + } + + let result = true; + + const textures = gltf.textures || []; + const numTextures = textures.length; + + // Validate the index + // The index MUST be an integer in [0, numTextures) + const index = featureIdTexture.index; + const indexPath = path + "/index"; + if (defined(index)) { + if ( + !BasicValidator.validateIntegerRange( + indexPath, + "index", + index, + 0, + true, + numTextures, + false, + context + ) + ) { + result = false; + } + } + + // Validate the texCoord + const texCoord = featureIdTexture.texCoord; + const texCoordPath = path + "/texCoord"; + if (defined(texCoord)) { + if ( + !ExtMeshFeaturesValidator.validateTexCoord( + texCoordPath, + texCoord, + gltf, + meshPrimitive, + context + ) + ) { + result = false; + } + } + + // Validate the channels. + // This will only check the basic validity, namely that the channels + // (if they are defined) are an array of nonnegative integers. Whether + // the channels match the image structure is validated later, in + // `validateFeatureIdTextureData` + const channels = featureIdTexture.channels; + const channelsPath = path + "/channels"; + if (channels) { + if ( + !BasicValidator.validateArray( + channelsPath, + "channels", + channels, + 1, + undefined, + "number", + context + ) + ) { + result = false; + } else { + if ( + !ExtMeshFeaturesValidator.validateChannels( + channelsPath, + channels, + context + ) + ) { + result = false; + } + } + } + + // Make sure that the sampler of the texture (if present) uses the + // allowed values (namely, 'undefined' or 9728 (NEAREST)) for + // its minFilter and magFilter + const texture = textures[index]; + const samplerIndex = texture.sampler; + if (samplerIndex !== undefined) { + const samplers = gltf.samplers || []; + const sampler = samplers[samplerIndex]; + const NEAREST = 9728; + + if (sampler.minFilter !== undefined && sampler.minFilter !== NEAREST) { + const message = + `The feature ID texture refers to a sampler with 'minFilter' ` + + `mode ${sampler.minFilter}, but the filter mode must either ` + + `be 'undefined', or 9728 (NEAREST)`; + const issue = GltfExtensionValidationIssues.INVALID_FILTER_MODE( + path, + message + ); + context.addIssue(issue); + result = false; + } + if (sampler.magFilter !== undefined && sampler.minFilter !== NEAREST) { + const message = + `The feature ID texture refers to a sampler with 'magFilter' ` + + `mode ${sampler.minFilter}, but the filter mode must either ` + + `be 'undefined', or 9728 (NEAREST)`; + const issue = GltfExtensionValidationIssues.INVALID_FILTER_MODE( + path, + message + ); + context.addIssue(issue); + result = false; + } + } + + // Only if the structures have been valid until now, + // validate the actual data of the texture + if (result) { + const dataValid = + await ExtMeshFeaturesValidator.validateFeatureIdTextureData( + path, + featureIdTexture, + featureCount, + gltf, + binaryBufferData, + context + ); + if (!dataValid) { + result = false; + } + } + return result; + } + + /** + * Validate the data of the given feature ID texture. + * + * This will try to read the image data, check whether it matches + * the `channels` definition of the feature ID texture, and whether + * the number of feature IDs (created from the respective channels + * of the image pixels) actually matches the given `featureCount`. + * + * @param path - The path for validation issues + * @param featureIdTexture - The feature ID texture + * @param featureCount - The `featureCount` value from the feature ID definition + * @param gltf - The glTF object + * @param binaryBufferData - The binary buffer data of the glTF + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static async validateFeatureIdTextureData( + path: string, + featureIdTexture: any, + featureCount: number, + gltf: any, + binaryBufferData: BinaryBufferData, + context: ValidationContext + ) { + const textureIndex = featureIdTexture.index; + const textures = gltf.textures || []; + const texture = textures[textureIndex]; + const images = gltf.images || []; + const imageIndex = texture.source; + const image = images[imageIndex]; + + // Read the image data buffer, either from a data URI or from + // the binary buffer data (using the buffer view index) + let imageDataBuffer; + const uri = image.uri; + if (defined(uri)) { + const resourceResolver = context.getResourceResolver(); + imageDataBuffer = await resourceResolver.resolveData(uri); + } else { + const bufferViewIndex = image.bufferView; + if (defined(bufferViewIndex)) { + imageDataBuffer = binaryBufferData.bufferViewsData[bufferViewIndex]; + } + } + if (!imageDataBuffer) { + const message = `Could not resolve image data for feature ID texture`; + const issue = IoValidationIssues.IO_ERROR(path, message); + context.addIssue(issue); + return false; + } + + // Try to read image data (pixels) from the image data buffer + let imageData: ImageData | undefined = undefined; + try { + imageData = await ImageDataReader.readUnchecked(imageDataBuffer); + } catch (error) { + const message = `Could not read feature ID texture from image data: ${error}`; + const issue = IoValidationIssues.IO_ERROR(path, message); + context.addIssue(issue); + return false; + } + if (!imageData) { + const message = `Could not read feature ID texture from image data`; + const issue = IoValidationIssues.IO_ERROR(path, message); + context.addIssue(issue); + return false; + } + + // Make sure that the `channels` contains only elements that + // are smaller than the number of channels in the image + const channelsInImage = imageData.channels; + const channels = defaultValue(featureIdTexture.channels, [0]); + if (channels.length > channelsInImage) { + const message = + `The feature ID texture defines ${channels.length} channels, ` + + `but the texture only contains ${channelsInImage} channels`; + const issue = GltfExtensionValidationIssues.TEXTURE_CHANNELS_OUT_OF_RANGE( + path, + message + ); + context.addIssue(issue); + return false; + } + for (let i = 0; i < channels.length; i++) { + const c = channels[i]; + if (c >= channelsInImage) { + const message = + `Channel ${i} of the feature ID texture is ${c}, ` + + `but the texture only contains ${channelsInImage} channels`; + const issue = + GltfExtensionValidationIssues.TEXTURE_CHANNELS_OUT_OF_RANGE( + path, + message + ); + context.addIssue(issue); + return false; + } + } + + // Make sure that the `featureCount` matches the + // actual number of different values that appear + // in the texture + const featureIdSet = new Set(); + const sizeX = imageData.sizeX; + const sizeY = imageData.sizeY; + for (let y = 0; y < sizeY; y++) { + for (let x = 0; x < sizeX; x++) { + const value = ImageDataReader.getValue(imageData, x, y, channels); + featureIdSet.add(value); + } + } + if (featureCount !== featureIdSet.size) { + const message = + `The featureCount was ${featureCount}, but the texture ` + + `contains ${featureIdSet.size} different values`; + const issue = GltfExtensionValidationIssues.FEATURE_COUNT_MISMATCH( + path, + message + ); + context.addIssue(issue); + return false; + } + + return true; + } + + /** + * Validate the `channels` array of a feature ID texture. + * + * This will only check whether the elements of the given array + * are nonnegative integers. Whether or not these channels match + * the actual texture data will be validated in + * `validateFeatureIdTextureData`. + * + * @param path - The path for validation issues + * @param channels - The channels + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static validateChannels( + path: string, + channels: number[], + context: ValidationContext + ): boolean { + let result = true; + for (let i = 0; i < channels.length; i++) { + const channelsElement = channels[i]; + const channelsElementPath = path + "/" + i; + if ( + !BasicValidator.validateIntegerRange( + channelsElementPath, + "channel", + channelsElement, + 0, + true, + undefined, + false, + context + ) + ) { + result = false; + } + } + + return result; + } + + /** + * Validate the `texCoord` definition of a feature ID texture. + * + * @param path - The path for validation issues + * @param texCoord - The the texture coordinate set index, used for + * constructing the `TEXCOORD_${texCoord}` attribute name. + * @param gltf - The glTF object + * @param meshPrimitive - The mesh primitive that contains the extension + * @param context - The `ValidationContext` that any issues will be added to + * @returns Whether the object was valid + */ + private static validateTexCoord( + path: string, + texCoord: number, + gltf: any, + meshPrimitive: any, + context: ValidationContext + ): boolean { + // The texCoord MUST be an integer of at least 0 + if ( + !BasicValidator.validateIntegerRange( + path, + "texCoord", + texCoord, + 0, + true, + undefined, + false, + context + ) + ) { + return false; + } + + // For a given texCoord value, the attribute + // with the name `TEXCOORD_${texCoord}` must + // appear as an attribute in the mesh primitive + const texCoordAttributeName = `TEXCOORD_${texCoord}`; + const texCoordAccessorIndex = ExtMeshFeaturesValidator.findAccessorIndex( + meshPrimitive, + texCoordAttributeName + ); + if (texCoordAccessorIndex === undefined) { + const message = + `The feature ID defines the texCoord ${texCoord}, ` + + `but the attribute ${texCoordAttributeName} was not ` + + `found in the mesh primitive attributes`; + const issue = StructureValidationIssues.IDENTIFIER_NOT_FOUND( + path, + message + ); + context.addIssue(issue); + return false; + } + + let result = true; + + // The presence and validity of the accessor for the TEXCOORD_n + // attribute has already been validated by the glTF-Validator. + const accessors = gltf.accessors || []; + const accessor = accessors[texCoordAccessorIndex]; + const type = accessor.type; + const componentType = accessor.componentType; + const normalized = accessor.normalized; + + // The following validation is equivalent to what the glTF-Validator + // is doing for `TEXCOORD_n` attributes: + + // The accessor type MUST be "VEC2" + if (type !== "VEC2") { + const message = + `The 'texCoord' property of the feature ID texture is ${texCoord}, ` + + `and refers to an accessor with type ${type}, but must refer to ` + + `an accessor with type VEC2`; + const issue = GltfExtensionValidationIssues.INVALID_GLTF_STRUCTURE( + path, + message + ); + context.addIssue(issue); + result = false; + } + + // The accessor componentType MUST be FLOAT, UNSIGNED_BYTE, + // or UNSIGNED_SHORT + const FLOAT = 5126; + const UNSIGNED_BYTE = 5121; + const UNSIGNED_SHORT = 5123; + if ( + componentType !== FLOAT && + componentType !== UNSIGNED_BYTE && + componentType !== UNSIGNED_SHORT + ) { + const message = + `The 'texCoord' property of the feature ID texture is ${texCoord}, ` + + `and refers to an accessor with component type ${componentType}, but must ` + + `refer to an accessor with type FLOAT, UNSIGNED_BYTE, or UNSIGNED_SHORT`; + const issue = GltfExtensionValidationIssues.INVALID_GLTF_STRUCTURE( + path, + message + ); + context.addIssue(issue); + result = false; + } + + // When the accessor componentType is UNSIGNED_BYTE or UNSIGNED_SHORT, + // the the accessor MUST be normalized + if (componentType === UNSIGNED_BYTE || componentType === UNSIGNED_SHORT) { + if (normalized !== true) { + const message = + `The 'texCoord' property of the feature ID texture is ${texCoord}, ` + + `and refers to an accessor with component type ${componentType} ` + + `that is not normalized.`; + const issue = GltfExtensionValidationIssues.INVALID_GLTF_STRUCTURE( + path, + message + ); + context.addIssue(issue); + result = false; + } + } return result; } } diff --git a/src/validation/gltfExtensions/GltfData.ts b/src/validation/gltfExtensions/GltfData.ts new file mode 100644 index 00000000..7509357c --- /dev/null +++ b/src/validation/gltfExtensions/GltfData.ts @@ -0,0 +1,17 @@ +/** + * A simple combination of a (parsed) glTF JSON object, + * and the optional binary buffer of a glTF asset. + * + * @internal + */ +export type GltfData = { + /** + * The parsed glTF JSON object + */ + gltf: any; + + /** + * The binary buffer, if the glTF was read from a GLB + */ + binary: Buffer | undefined; +}; diff --git a/src/validation/gltfExtensions/GltfExtensionValidators.ts b/src/validation/gltfExtensions/GltfExtensionValidators.ts index 2f4cc245..bca734dc 100644 --- a/src/validation/gltfExtensions/GltfExtensionValidators.ts +++ b/src/validation/gltfExtensions/GltfExtensionValidators.ts @@ -1,9 +1,27 @@ +import { Buffers } from "3d-tiles-tools"; +import { GltfUtilities } from "3d-tiles-tools"; + +import { ValidationContext } from "../ValidationContext"; + import { GltfExtensionValidationIssues } from "../../issues/GltfExtensionValidationIssues"; import { IoValidationIssues } from "../../issues/IoValidationIssue"; -import { ValidationContext } from "../ValidationContext"; -import { Buffers, GltfUtilities } from "3d-tiles-tools"; import { ExtMeshFeaturesValidator } from "./ExtMeshFeaturesValidator"; +import { GltfData } from "./GltfData"; + +// TODO Replace this by moving extractBinaryFromGlb into 3d-tiles-tools +class TileFormatError extends Error { + constructor(message: string) { + super(message); + // See https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes + // #extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, TileFormatError.prototype); + } + + override toString = (): string => { + return `${this.name}: ${this.message}`; + }; +} export class GltfExtensionValidators { static async validateGltfExtensions( @@ -11,12 +29,12 @@ export class GltfExtensionValidators { input: Buffer, context: ValidationContext ): Promise { - const gltf = await GltfExtensionValidators.readGltfObject( + const gltfData = await GltfExtensionValidators.readGltfData( path, input, context ); - if (!gltf) { + if (!gltfData) { // Issue was already added to context return false; } @@ -24,19 +42,25 @@ export class GltfExtensionValidators { let result = true; // Validate `EXT_mesh_features` - if (!ExtMeshFeaturesValidator.validateGltf(path, gltf, context)) { + const extMeshFeaturesValid = await ExtMeshFeaturesValidator.validateGltf( + path, + gltfData, + context + ); + if (!extMeshFeaturesValid) { result = false; } return result; } - private static async readGltfObject( + private static async readGltfData( path: string, input: Buffer, context: ValidationContext - ): Promise { + ): Promise { // Assume that the input contains glTF JSON, but... let gltfJsonBuffer: Buffer | undefined = input; + let gltfBinaryBuffer: Buffer | undefined = undefined; // ... if the input starts with "glTF", then try to // extract the JSON from the GLB: @@ -44,6 +68,7 @@ export class GltfExtensionValidators { if (magicString === "glTF") { try { gltfJsonBuffer = GltfUtilities.extractJsonFromGlb(input); + gltfBinaryBuffer = GltfExtensionValidators.extractBinaryFromGlb(input); } catch (error) { // A TileFormatError may be thrown here const message = `Could not extract JSON from GLB: ${error}`; @@ -62,6 +87,80 @@ export class GltfExtensionValidators { context.addIssue(issue); return undefined; } - return gltf; + + return { + gltf: gltf, + binary: gltfBinaryBuffer, + }; + } + + // TODO This should be in 3d-tiles-tools GltfUtilities + // TODO This does not handle glTF 1.0 properly!!! + static extractBinaryFromGlb(glbBuffer: Buffer): Buffer { + const magic = Buffers.getMagicString(glbBuffer); + if (magic !== "glTF") { + throw new TileFormatError( + `Expected magic header to be 'gltf', but found ${magic}` + ); + } + if (glbBuffer.length < 12) { + throw new TileFormatError( + `Expected at least 12 bytes, but only got ${glbBuffer.length}` + ); + } + const version = glbBuffer.readUInt32LE(4); + const length = glbBuffer.readUInt32LE(8); + if (length > glbBuffer.length) { + throw new TileFormatError( + `Header indicates ${length} bytes, but input has ${glbBuffer.length} bytes` + ); + } + if (version === 1) { + // TODO Handle glTF 1.0! + throw new TileFormatError(`glTF 1.0 is not handled yet`); + } else if (version === 2) { + if (glbBuffer.length < 20) { + throw new TileFormatError( + `Expected at least 20 bytes, but only got ${glbBuffer.length}` + ); + } + const jsonChunkLength = glbBuffer.readUint32LE(12); + const jsonChunkType = glbBuffer.readUint32LE(16); + const expectedJsonChunkType = 0x4e4f534a; // ASCII string for "JSON" + if (jsonChunkType !== expectedJsonChunkType) { + throw new TileFormatError( + `Expected chunk type to be ${expectedJsonChunkType}, but found ${jsonChunkType}` + ); + } + const jsonChunkStart = 20; + const jsonChunkEnd = jsonChunkStart + jsonChunkLength; + if (glbBuffer.length < jsonChunkEnd) { + throw new TileFormatError( + `Expected at least ${jsonChunkEnd} bytes, but only got ${glbBuffer.length}` + ); + } + + const binChunkHeaderStart = jsonChunkEnd; + const binChunkLength = glbBuffer.readUint32LE(binChunkHeaderStart); + const binChunkType = glbBuffer.readUint32LE(binChunkHeaderStart + 4); + const expectedBinChunkType = 0x004e4942; // ASCII string for "BIN" + if (binChunkType !== expectedBinChunkType) { + throw new TileFormatError( + `Expected chunk type to be ${expectedBinChunkType}, but found ${binChunkType}` + ); + } + const binChunkStart = binChunkHeaderStart + 8; + const binChunkEnd = binChunkStart + binChunkLength; + if (glbBuffer.length < binChunkEnd) { + throw new TileFormatError( + `Expected at least ${binChunkEnd} bytes, but only got ${glbBuffer.length}` + ); + } + + const binChunkData = glbBuffer.subarray(binChunkStart, binChunkEnd); + return binChunkData; + } else { + throw new TileFormatError(`Expected version 1 or 2, but got ${version}`); + } } } diff --git a/src/validation/gltfExtensions/ImageData.ts b/src/validation/gltfExtensions/ImageData.ts new file mode 100644 index 00000000..ce5f880a --- /dev/null +++ b/src/validation/gltfExtensions/ImageData.ts @@ -0,0 +1,30 @@ +/** + * An internal interface representing the image data + * that was read for a (feature ID) texture. + * + * @internal + */ +export interface ImageData { + /** + * The width of the image + */ + sizeX: number; + + /** + * The height of the image + */ + sizeY: number; + + /** + * The number of channels (e.g. 3 for RGB, or 4 for RGBA) + */ + channels: number; + + /** + * The pixels. + * + * The channel `c` of the pixel at `x`, `y` is ndexed + * by `index = ((y * sizeX) + x) * channels) + c` + */ + pixels: number[]; +} diff --git a/src/validation/gltfExtensions/ImageDataReader.ts b/src/validation/gltfExtensions/ImageDataReader.ts new file mode 100644 index 00000000..cc5dc76f --- /dev/null +++ b/src/validation/gltfExtensions/ImageDataReader.ts @@ -0,0 +1,74 @@ +import sharp from "sharp"; + +import { ImageData } from "./ImageData"; + +/** + * A class for reading and accessing `ImageData` instances + * + * @internal + */ +export class ImageDataReader { + /** + * Obtains the feature ID value from the specified pixel in the + * given image data. + * + * This is computed by composing the (supposedly `UINT8`) channel + * values of the pixels + * + * @param imageData - The ImageData object + * @param x - The x-coordinate + * @param y - The y-coordinate + * @param channels - The `channels` definition from the feature ID texture + * @returns The feature ID value + */ + static getValue( + imageData: ImageData, + x: number, + y: number, + channels: number[] + ) { + const sizeX = imageData.sizeX; + const numChannels = imageData.channels; + const pixels = imageData.pixels; + let result = 0; + const offset = (y * sizeX + x) * numChannels; + for (let c = 0; c < channels.length; c++) { + const channel = channels[c]; + const shift = c * 8; + const channelValue = pixels[offset + channel]; + result |= channelValue << shift; + } + return result; + } + + /** + * Try to read image data from the given buffer. + * + * The exact set of image formats that may be contained in the given + * buffer is not specified. But it will support PNG and JPEG. + * + * @param imageDataBuffer - The image data buffer + * @returns The `ImageData` + * @throws An error if the image data can not be read + */ + static async readUnchecked( + imageDataBuffer: Buffer + ): Promise { + const sharpImage = sharp(imageDataBuffer); + const metadata = await sharpImage.metadata(); + const sizeX = metadata.width; + const sizeY = metadata.height; + const channels = metadata.channels; + if (sizeX === undefined || sizeY === undefined || channels === undefined) { + return undefined; + } + const pixelsBuffer = await sharpImage.raw().toBuffer(); + const pixels = [...pixelsBuffer]; + return { + sizeX: sizeX, + sizeY: sizeY, + channels: channels, + pixels: pixels, + }; + } +}