UNPKG

@loaders.gl/i3s

Version:
494 lines (493 loc) 20.6 kB
import { load, parse } from '@loaders.gl/core'; import { Vector3, Matrix4 } from '@math.gl/core'; import { Ellipsoid } from '@math.gl/geospatial'; import { parseFromContext } from '@loaders.gl/loader-utils'; import { ImageLoader } from '@loaders.gl/images'; import { DracoLoader } from '@loaders.gl/draco'; import { BasisLoader, CompressedTextureLoader } from '@loaders.gl/textures'; import { HeaderAttributeProperty } from "../../types.js"; import { getUrlWithToken } from "../utils/url-utils.js"; import { GL_TYPE_MAP, getConstructorForDataFormat, sizeOf, COORDINATE_SYSTEM } from "./constants.js"; const scratchVector = new Vector3([0, 0, 0]); function getLoaderForTextureFormat(textureFormat) { switch (textureFormat) { case 'ktx-etc2': case 'dds': return CompressedTextureLoader; case 'ktx2': return BasisLoader; case 'jpg': case 'png': default: return ImageLoader; } } const I3S_ATTRIBUTE_TYPE = 'i3s-attribute-type'; export async function parseI3STileContent(arrayBuffer, tileOptions, tilesetOptions, options, context) { const content = { attributes: {}, indices: null, featureIds: [], vertexCount: 0, modelMatrix: new Matrix4(), coordinateSystem: 0, byteLength: 0, texture: null }; if (tileOptions.textureUrl) { // @ts-expect-error options is not properly typed const url = getUrlWithToken(tileOptions.textureUrl, options?.i3s?.token); const loader = getLoaderForTextureFormat(tileOptions.textureFormat); const fetchFunc = context?.fetch || fetch; const response = await fetchFunc(url); // options?.fetch const arrayBuffer = await response.arrayBuffer(); // @ts-expect-error options is not properly typed if (options?.i3s.decodeTextures) { // TODO - replace with switch if (loader === ImageLoader) { const options = { ...tileOptions.textureLoaderOptions, image: { type: 'data' } }; try { // Image constructor is not supported in worker thread. // Do parsing image data on the main thread by using context to avoid worker issues. const texture = await parseFromContext(arrayBuffer, [], options, context); content.texture = texture; } catch (e) { // context object is different between worker and node.js conversion script. // To prevent error we parse data in ordinary way if it is not parsed by using context. const texture = await parse(arrayBuffer, loader, options, context); content.texture = texture; } } else if (loader === CompressedTextureLoader || loader === BasisLoader) { let texture = await load(arrayBuffer, loader, tileOptions.textureLoaderOptions); if (loader === BasisLoader) { texture = texture[0]; } content.texture = { compressed: true, mipmaps: false, width: texture[0].width, height: texture[0].height, data: texture }; } } else { content.texture = arrayBuffer; } } content.material = makePbrMaterial(tileOptions.materialDefinition, content.texture); if (content.material) { content.texture = null; } return await parseI3SNodeGeometry(arrayBuffer, content, tileOptions, tilesetOptions, options); } /* eslint-disable max-statements */ async function parseI3SNodeGeometry(arrayBuffer, content, tileOptions, tilesetOptions, options) { const contentByteLength = arrayBuffer.byteLength; let attributes; let vertexCount; let byteOffset = 0; let featureCount = 0; let indices; if (tileOptions.isDracoGeometry) { const decompressedGeometry = await parse(arrayBuffer, DracoLoader, { draco: { attributeNameEntry: I3S_ATTRIBUTE_TYPE } }); // @ts-expect-error vertexCount = decompressedGeometry.header.vertexCount; indices = decompressedGeometry.indices?.value; const { POSITION, NORMAL, COLOR_0, TEXCOORD_0, ['feature-index']: featureIndex, ['uv-region']: uvRegion } = decompressedGeometry.attributes; attributes = { position: POSITION, normal: NORMAL, color: COLOR_0, uv0: TEXCOORD_0, uvRegion, id: featureIndex }; updateAttributesMetadata(attributes, decompressedGeometry); const featureIds = getFeatureIdsFromFeatureIndexMetadata(featureIndex); if (featureIds) { flattenFeatureIdsByFeatureIndices(attributes, featureIds); } } else { const { vertexAttributes, ordering: attributesOrder, featureAttributes, featureAttributeOrder } = tilesetOptions.store.defaultGeometrySchema; // First 8 bytes reserved for header (vertexCount and featureCount) const headers = parseHeaders(arrayBuffer, tilesetOptions); byteOffset = headers.byteOffset; vertexCount = headers.vertexCount; featureCount = headers.featureCount; // Getting vertex attributes such as positions, normals, colors, etc... const { attributes: normalizedVertexAttributes, byteOffset: offset } = normalizeAttributes(arrayBuffer, byteOffset, vertexAttributes, vertexCount, attributesOrder); // Getting feature attributes such as featureIds and faceRange const { attributes: normalizedFeatureAttributes } = normalizeAttributes(arrayBuffer, offset, featureAttributes, featureCount, featureAttributeOrder); flattenFeatureIdsByFaceRanges(normalizedFeatureAttributes); attributes = concatAttributes(normalizedVertexAttributes, normalizedFeatureAttributes); } if (!options?.i3s?.coordinateSystem || // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison options.i3s.coordinateSystem === COORDINATE_SYSTEM.METER_OFFSETS) { const enuMatrix = parsePositions(attributes.position, tileOptions); content.modelMatrix = enuMatrix.invert(); content.coordinateSystem = COORDINATE_SYSTEM.METER_OFFSETS; } else { content.modelMatrix = getModelMatrix(attributes.position); content.coordinateSystem = COORDINATE_SYSTEM.LNGLAT_OFFSETS; } content.attributes = { positions: attributes.position, normals: attributes.normal, colors: normalizeAttribute(attributes.color), // Normalize from UInt8 texCoords: attributes.uv0, uvRegions: normalizeAttribute(attributes.uvRegion || attributes.region) // Normalize from UInt16 }; content.indices = indices || null; if (attributes.id && attributes.id.value) { content.featureIds = attributes.id.value; } // Remove undefined attributes for (const attributeIndex in content.attributes) { if (!content.attributes[attributeIndex]) { delete content.attributes[attributeIndex]; } } content.vertexCount = vertexCount; content.byteLength = contentByteLength; return content; } /** * Update attributes with metadata from decompressed geometry. * @param decompressedGeometry * @param attributes */ function updateAttributesMetadata(attributes, decompressedGeometry) { for (const key in decompressedGeometry.loaderData.attributes) { const dracoAttribute = decompressedGeometry.loaderData.attributes[key]; switch (dracoAttribute.name) { case 'POSITION': attributes.position.metadata = dracoAttribute.metadata; break; case 'feature-index': attributes.id.metadata = dracoAttribute.metadata; break; default: break; } } } /** * Do concatenation of attribute objects. * Done as separate fucntion to avoid ts errors. * @param normalizedVertexAttributes * @param normalizedFeatureAttributes * @returns - result of attributes concatenation. */ function concatAttributes(normalizedVertexAttributes, normalizedFeatureAttributes) { return { ...normalizedVertexAttributes, ...normalizedFeatureAttributes }; } /** * Normalize attribute to range [0..1] . Eg. convert colors buffer from [255,255,255,255] to [1,1,1,1] * @param attribute - geometry attribute * @returns - geometry attribute in right format */ function normalizeAttribute(attribute) { if (!attribute) { return attribute; } attribute.normalized = true; return attribute; } function parseHeaders(arrayBuffer, options) { let byteOffset = 0; // First 8 bytes reserved for header (vertexCount and featurecount) let vertexCount = 0; let featureCount = 0; for (const { property, type } of options.store.defaultGeometrySchema.header) { const TypedArrayTypeHeader = getConstructorForDataFormat(type); switch (property) { case HeaderAttributeProperty.vertexCount.toString(): vertexCount = new TypedArrayTypeHeader(arrayBuffer, 0, 4)[0]; byteOffset += sizeOf(type); break; case HeaderAttributeProperty.featureCount.toString(): featureCount = new TypedArrayTypeHeader(arrayBuffer, 4, 4)[0]; byteOffset += sizeOf(type); break; default: break; } } return { vertexCount, featureCount, byteOffset }; } /* eslint-enable max-statements */ function normalizeAttributes(arrayBuffer, byteOffset, vertexAttributes, attributeCount, attributesOrder) { const attributes = {}; // the order of attributes depend on the order being added to the vertexAttributes object for (const attribute of attributesOrder) { if (vertexAttributes[attribute]) { const { valueType, valuesPerElement } = vertexAttributes[attribute]; // protect from arrayBuffer read overunns by NOT assuming node has regions always even though its declared in defaultGeometrySchema. // In i3s 1.6: client is required to decide that based on ./shared resource of the node (materialDefinitions.[Mat_id].params.vertexRegions == true) // In i3s 1.7 the property has been rolled into the 3d scene layer json/node pages. // Code below does not account when the bytelength is actually bigger than // the calculated value (b\c the tile potentially could have mesh segmentation information). // In those cases tiles without regions could fail or have garbage values. if (byteOffset + attributeCount * valuesPerElement * sizeOf(valueType) <= arrayBuffer.byteLength) { const buffer = arrayBuffer.slice(byteOffset); let value; if (valueType === 'UInt64') { value = parseUint64Values(buffer, attributeCount * valuesPerElement, sizeOf(valueType)); } else { const TypedArrayType = getConstructorForDataFormat(valueType); value = new TypedArrayType(buffer, 0, attributeCount * valuesPerElement); } attributes[attribute] = { value, type: GL_TYPE_MAP[valueType], size: valuesPerElement }; switch (attribute) { case 'color': attributes.color.normalized = true; break; case 'position': case 'region': case 'normal': default: } byteOffset = byteOffset + attributeCount * valuesPerElement * sizeOf(valueType); } else if (attribute !== 'uv0') { break; } } } return { attributes, byteOffset }; } /** * Parse buffer to return array of uint64 values * * @param buffer * @param elementsCount * @returns 64-bit array of values until precision is lost after Number.MAX_SAFE_INTEGER */ function parseUint64Values(buffer, elementsCount, attributeSize) { const values = []; const dataView = new DataView(buffer); let offset = 0; for (let index = 0; index < elementsCount; index++) { // split 64-bit number into two 32-bit parts const left = dataView.getUint32(offset, true); const right = dataView.getUint32(offset + 4, true); // combine the two 32-bit values const value = left + 2 ** 32 * right; values.push(value); offset += attributeSize; } return new Uint32Array(values); } function parsePositions(attribute, options) { const mbs = options.mbs; const value = attribute.value; const metadata = attribute.metadata; const enuMatrix = new Matrix4(); const cartographicOrigin = new Vector3(mbs[0], mbs[1], mbs[2]); const cartesianOrigin = new Vector3(); Ellipsoid.WGS84.cartographicToCartesian(cartographicOrigin, cartesianOrigin); Ellipsoid.WGS84.eastNorthUpToFixedFrame(cartesianOrigin, enuMatrix); attribute.value = offsetsToCartesians(value, metadata, cartographicOrigin); return enuMatrix; } /** * Converts position coordinates to absolute cartesian coordinates * @param vertices - "position" attribute data * @param metadata - When the geometry is DRACO compressed, contain position attribute's metadata * https://github.com/Esri/i3s-spec/blob/master/docs/1.7/compressedAttributes.cmn.md * @param cartographicOrigin - Cartographic origin coordinates * @returns - converted "position" data */ function offsetsToCartesians(vertices, metadata = {}, cartographicOrigin) { const positions = new Float64Array(vertices.length); const scaleX = (metadata['i3s-scale_x'] && metadata['i3s-scale_x'].double) || 1; const scaleY = (metadata['i3s-scale_y'] && metadata['i3s-scale_y'].double) || 1; for (let i = 0; i < positions.length; i += 3) { positions[i] = vertices[i] * scaleX + cartographicOrigin.x; positions[i + 1] = vertices[i + 1] * scaleY + cartographicOrigin.y; positions[i + 2] = vertices[i + 2] + cartographicOrigin.z; } for (let i = 0; i < positions.length; i += 3) { // @ts-ignore Ellipsoid.WGS84.cartographicToCartesian(positions.subarray(i, i + 3), scratchVector); positions[i] = scratchVector.x; positions[i + 1] = scratchVector.y; positions[i + 2] = scratchVector.z; } return positions; } /** * Get model matrix for loaded vertices * @param positions positions attribute * @returns Matrix4 - model matrix for geometry transformation */ function getModelMatrix(positions) { const metadata = positions.metadata; const scaleX = metadata?.['i3s-scale_x']?.double || 1; const scaleY = metadata?.['i3s-scale_y']?.double || 1; const modelMatrix = new Matrix4(); modelMatrix[0] = scaleX; modelMatrix[5] = scaleY; return modelMatrix; } /** * Makes a glTF-compatible PBR material from an I3S material definition * @param materialDefinition - i3s material definition * https://github.com/Esri/i3s-spec/blob/master/docs/1.7/materialDefinitions.cmn.md * @param texture - texture image * @returns {object} */ function makePbrMaterial(materialDefinition, texture) { let pbrMaterial; if (materialDefinition) { pbrMaterial = { ...materialDefinition, pbrMetallicRoughness: materialDefinition.pbrMetallicRoughness ? { ...materialDefinition.pbrMetallicRoughness } : { baseColorFactor: [255, 255, 255, 255] } }; } else { pbrMaterial = { pbrMetallicRoughness: {} }; if (texture) { pbrMaterial.pbrMetallicRoughness.baseColorTexture = { texCoord: 0 }; } else { pbrMaterial.pbrMetallicRoughness.baseColorFactor = [255, 255, 255, 255]; } } // Set default 0.25 per spec https://github.com/Esri/i3s-spec/blob/master/docs/1.7/materialDefinitions.cmn.md pbrMaterial.alphaCutoff = pbrMaterial.alphaCutoff || 0.25; if (pbrMaterial.alphaMode) { // I3S contain alphaMode in lowerCase pbrMaterial.alphaMode = pbrMaterial.alphaMode.toUpperCase(); } // Convert colors from [255,255,255,255] to [1,1,1,1] if (pbrMaterial.emissiveFactor) { pbrMaterial.emissiveFactor = convertColorFormat(pbrMaterial.emissiveFactor); } if (pbrMaterial.pbrMetallicRoughness && pbrMaterial.pbrMetallicRoughness.baseColorFactor) { pbrMaterial.pbrMetallicRoughness.baseColorFactor = convertColorFormat(pbrMaterial.pbrMetallicRoughness.baseColorFactor); } if (texture) { setMaterialTexture(pbrMaterial, texture); } return pbrMaterial; } /** * Convert color from [255,255,255,255] to [1,1,1,1] * @param colorFactor - color array * @returns - new color array */ function convertColorFormat(colorFactor) { const normalizedColor = [...colorFactor]; for (let index = 0; index < colorFactor.length; index++) { normalizedColor[index] = colorFactor[index] / 255; } return normalizedColor; } /** * Set texture in PBR material * @param {object} material - i3s material definition * @param image - texture image * @returns */ function setMaterialTexture(material, image) { const texture = { source: { image } }; // I3SLoader now support loading only one texture. This elseif sequence will assign this texture to one of // properties defined in materialDefinition if (material.pbrMetallicRoughness && material.pbrMetallicRoughness.baseColorTexture) { material.pbrMetallicRoughness.baseColorTexture = { ...material.pbrMetallicRoughness.baseColorTexture, texture }; } else if (material.emissiveTexture) { material.emissiveTexture = { ...material.emissiveTexture, texture }; } else if (material.pbrMetallicRoughness && material.pbrMetallicRoughness.metallicRoughnessTexture) { material.pbrMetallicRoughness.metallicRoughnessTexture = { ...material.pbrMetallicRoughness.metallicRoughnessTexture, texture }; } else if (material.normalTexture) { material.normalTexture = { ...material.normalTexture, texture }; } else if (material.occlusionTexture) { material.occlusionTexture = { ...material.occlusionTexture, texture }; } } /** * Flatten feature ids using face ranges * @param normalizedFeatureAttributes * @returns */ function flattenFeatureIdsByFaceRanges(normalizedFeatureAttributes) { const { id, faceRange } = normalizedFeatureAttributes; if (!id || !faceRange) { return; } const featureIds = id.value; const range = faceRange.value; const featureIdsLength = range[range.length - 1] + 1; const orderedFeatureIndices = new Uint32Array(featureIdsLength * 3); let featureIndex = 0; let startIndex = 0; for (let index = 1; index < range.length; index += 2) { const fillId = Number(featureIds[featureIndex]); const endValue = range[index]; const prevValue = range[index - 1]; const trianglesCount = endValue - prevValue + 1; const endIndex = startIndex + trianglesCount * 3; orderedFeatureIndices.fill(fillId, startIndex, endIndex); featureIndex++; startIndex = endIndex; } normalizedFeatureAttributes.id.value = orderedFeatureIndices; } /** * Flatten feature ids using featureIndices * @param attributes * @param featureIds * @returns */ function flattenFeatureIdsByFeatureIndices(attributes, featureIds) { const featureIndices = attributes.id.value; const result = new Float32Array(featureIndices.length); for (let index = 0; index < featureIndices.length; index++) { result[index] = featureIds[featureIndices[index]]; } attributes.id.value = result; } /** * Flatten feature ids using featureIndices * @param featureIndex * @returns */ function getFeatureIdsFromFeatureIndexMetadata(featureIndex) { return featureIndex?.metadata?.['i3s-feature-ids']?.intArray; }