import * as path from 'path' ; import crypto from 'crypto' ; import * as fse from 'fs-extra' ; import * as gltf from './schema' ; import { isUndefined , isNullOrUndefined } from 'util' ; import { ImagePlaceholder } from '../common/image-placeholders' ; import * as IMF from '../common/intermediate-format' ; const MaxBufferSize = 5 << 20 ; const DefaultMaterial = { pbrMetallicRoughness : { baseColorFactor : [ 0.25 , 0.25 , 0.25 , 1.0 ] , metallicFactor : 0.0 , roughnessFactor : 0.5 } } ; export interface IWriterOptions { maxBufferSize ? : number ; ignoreMeshGeometry ? : boolean ; ignoreLineGeometry ? : boolean ; ignorePointGeometry ? : boolean ; deduplicate ? : boolean ; skipUnusedUvs ? : boolean ; center ? : boolean ; log ? : ( msg : string ) => void ; filter ? : ( dbid : number ) => boolean ; } function hasTextures ( material ) { return ! ! ( material ?. maps ?. diffuse ) ; } interface IWriterStats { materialsDeduplicated : number ; meshesDeduplicated : number ; accessorsDeduplicated : number ; bufferViewsDeduplicated : number ; } /**
 * Utility class for serializing parsed 3D content to local file system as glTF (2.0).
 */ export class Writer { protected options : Required < IWriterOptions > ; protected baseDir : string ; protected manifest : gltf . GlTf ; protected bufferStream : fse . WriteStream | null ; protected bufferSize : number ; protected bufferViewCache = new Map < string , gltf . BufferView > ( ) ; protected meshHashes : string [ ] = [ ] ; protected bufferViewHashes : string [ ] = [ ] ; protected accessorHashes : string [ ] = [ ] ; protected pendingTasks : Promise < void > [ ] = [ ] ; protected stats : IWriterStats = { materialsDeduplicated : 0 , meshesDeduplicated : 0 , accessorsDeduplicated : 0 , bufferViewsDeduplicated : 0 } ; param { IWriterOptions } options constructor ( options = { } ) { this . options = { maxBufferSize : isNullOrUndefined ( options . maxBufferSize ) ? MaxBufferSize : options . maxBufferSize , ignoreMeshGeometry : ! ! options . ignoreMeshGeometry , ignoreLineGeometry : ! ! options . ignoreLineGeometry , ignorePointGeometry : ! ! options . ignorePointGeometry , deduplicate : ! ! options . deduplicate , skipUnusedUvs : ! ! options . skipUnusedUvs , center : ! ! options . center , log : ( options && options . log ) || function ( msg ) { } , filter : options && options . filter || ( ( dbid ) => true ) } ; this . manifest = { } as gltf . GlTf ; this . bufferStream = null ; this . bufferSize = 0 ; this . baseDir = '' ; } async param { IMF . IScene } imf param { string } outputDir async write ( imf , outputDir ) { this . reset ( outputDir ) ; const scene = this . createScene ( imf ) ; const scenes = this . manifest . scenes as gltf . Scene [ ] ; scenes . push ( scene ) ; if ( this . bufferStream ) { const stream = this . bufferStream as fse . WriteStream ; this . pendingTasks . push ( new Promise ( ( resolve , reject ) => { stream . on ( 'finish' , resolve ) ; } ) ) ; this . bufferStream . close ( ) ; this . bufferStream = null ; this . bufferSize = 0 ; } await Promise . all ( this . pendingTasks ) ; if ( this . manifest . textures && this . manifest . textures . length === 0 ) delete this . manifest . textures ; if ( this . manifest . images && this . manifest . images . length === 0 ) delete this . manifest . images ; const gltfPath = path . join ( this . baseDir , 'output.gltf' ) ; fse . writeFileSync ( gltfPath , JSON . stringify ( this . manifest , null , 4 ) ) ; this . options . log ( `Closing gltf output: done` ) ; this . options . log ( `Stats: ${ JSON . stringify ( this . stats ) }` ) ; await this . postprocess ( imf , gltfPath ) ; } protected reset ( outputDir ) { this . baseDir = outputDir ; this . manifest = { asset : { version : '2.0' , generator : 'forge-convert-utils' , copyright : '2019 (c) Autodesk' } , extensionsUsed : [ "KHR_texture_transform" ] , buffers : [ ] , bufferViews : [ ] , accessors : [ ] , meshes : [ ] , materials : [ ] , nodes : [ ] , scenes : [ ] , textures : [ ] , images : [ ] , scene : 0 } ; this . bufferStream = null ; this . bufferSize = 0 ; this . bufferViewCache . clear ( ) ; this . meshHashes = [ ] ; this . bufferViewHashes = [ ] ; this . accessorHashes = [ ] ; this . pendingTasks = [ ] ; this . stats = { materialsDeduplicated : 0 , meshesDeduplicated : 0 , accessorsDeduplicated : 0 , bufferViewsDeduplicated : 0 } ; } protected async postprocess ( imf , gltfPath ) { } protected createScene ( imf ) { fse . ensureDirSync ( this . baseDir ) ; let scene = { nodes : [ ] } ; const manifestNodes = this . manifest . nodes as gltf . Node [ ] ; const manifestMaterials = this . manifest . materials as gltf . MaterialPbrMetallicRoughness [ ] ; const rootNode = { children : [ ] } ; const xformNode = { children : [ ] } ; ( scene . nodes as number [ ] ) . push ( manifestNodes . push ( rootNode ) - 1 ) ; ( rootNode . children as number [ ] ) . push ( manifestNodes . push ( xformNode ) - 1 ) ; const metadata = imf . getMetadata ( ) ; if ( metadata [ 'world up vector' ] && metadata [ 'world front vector' ] && metadata [ 'distance unit' ] ) { const up = metadata [ 'world up vector' ] . XYZ ; const front = metadata [ 'world front vector' ] . XYZ ; const distanceUnit = metadata [ 'distance unit' ] . value ; if ( up && front && distanceUnit ) { const left = [ up [ 1 ] * front [ 2 ] - up [ 2 ] * front [ 1 ] , up [ 2 ] * front [ 0 ] - up [ 0 ] * front [ 2 ] , up [ 0 ] * front [ 1 ] - up [ 1 ] * front [ 0 ] ] ; if ( left [ 0 ] * left [ 0 ] + left [ 1 ] * left [ 1 ] + left [ 2 ] * left [ 2 ] > 0.0 ) { let scale = 1.0 ; switch ( distanceUnit ) { case 'centimeter' : case 'cm' : scale = 0.01 ; break ; case 'millimeter' : case 'mm' : scale = 0.001 ; break ; case 'foot' : case 'ft' : scale = 0.3048 ; break ; case 'inch' : case 'in' : scale = 0.0254 ; break ; default : scale = 1.0 ; } rootNode . matrix = [ left [ 0 ] * scale , up [ 0 ] * scale , front [ 0 ] * scale , 0 , left [ 1 ] * scale , up [ 1 ] * scale , front [ 1 ] * scale , 0 , left [ 2 ] * scale , up [ 2 ] * scale , front [ 2 ] * scale , 0 , 0 , 0 , 0 , 1 ] ; } else { console . warn ( 'Could not compute world matrix, leaving it as identity...' ) ; } } } if ( metadata [ 'world bounding box' ] && this . options . center ) { const boundsMin = metadata [ 'world bounding box' ] . minXYZ ; const boundsMax = metadata [ 'world bounding box' ] . maxXYZ ; if ( boundsMin && boundsMax ) { let translation = [ - 0.5 * ( boundsMin [ 0 ] + boundsMax [ 0 ] ) , - 0.5 * ( boundsMin [ 1 ] + boundsMax [ 1 ] ) , - 0.5 * ( boundsMin [ 2 ] + boundsMax [ 2 ] ) ] ; xformNode . matrix = [ 1 , 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 1 , 0 , translation [ 0 ] , translation [ 1 ] , translation [ 2 ] , 1 ] ; } } const nodeIndices = ( xformNode . children as number [ ] ) ; this . options . log ( `Writing scene nodes...` ) ; const { filter } = this . options ; for ( let i = 0 , len = imf . getNodeCount ( ) ; i < len ; i ++ ) { const fragment = imf . getNode ( i ) ; if ( fragment . kind !== IMF . NodeKind . Object ) { continue ; } if ( ! filter ( fragment . dbid ) ) { continue ; } const material = imf . getMaterial ( fragment . material ) ; const outputUvs = hasTextures ( material ) || ! this . options . skipUnusedUvs ; const node = this . createNode ( fragment , imf , outputUvs ) ; if ( ! isUndefined ( node . mesh ) ) { nodeIndices . push ( manifestNodes . push ( node ) - 1 ) ; } } this . options . log ( `Writing materials...` ) ; if ( this . options . deduplicate ) { const hashes = [ ] ; const newMaterialIndices = new Uint16Array ( imf . getMaterialCount ( ) ) ; for ( let i = 0 , len = imf . getMaterialCount ( ) ; i < len ; i ++ ) { const material = imf . getMaterial ( i ) ; const hash = this . computeMaterialHash ( material ) ; const match = hashes . indexOf ( hash ) ; if ( match === - 1 ) { newMaterialIndices [ i ] = manifestMaterials . length ; manifestMaterials . push ( this . createMaterial ( material , imf ) ) ; hashes . push ( hash ) ; } else { this . options . log ( `Skipping a duplicate material (hash: ${ hash })` ) ; newMaterialIndices [ i ] = match ; this . stats . materialsDeduplicated ++ ; } } for ( const mesh of ( this . manifest . meshes as gltf . Mesh [ ] ) ) { for ( const primitive of mesh . primitives ) { if ( ! isUndefined ( primitive . material ) ) { primitive . material = newMaterialIndices [ primitive . material ] ; } } } } else { for ( let i = 0 , len = imf . getMaterialCount ( ) ; i < len ; i ++ ) { const material = imf . getMaterial ( i ) ; const mat = this . createMaterial ( material , imf ) ; manifestMaterials . push ( mat ) ; } } this . options . log ( `Writing scene: done` ) ; return scene ; } protected createNode ( fragment , imf , outputUvs ) { let node = { name : fragment . dbid . toString ( ) } ; if ( fragment . transform ) { switch ( fragment . transform . kind ) { case IMF . TransformKind . Matrix : node . matrix = fragment . transform . elements ; break ; case IMF . TransformKind . Decomposed : if ( fragment . transform . scale ) { const s = fragment . transform . scale ; node . scale = [ s . x , s . y , s . z ] ; } if ( fragment . transform . rotation ) { const r = fragment . transform . rotation ; node . rotation = [ r . x , r . y , r . z , r . w ] ; } if ( fragment . transform . translation ) { const t = fragment . transform . translation ; node . translation = [ t . x , t . y , t . z ] ; } break ; } } const geometry = imf . getGeometry ( fragment . geometry ) ; let mesh = undefined ; switch ( geometry . kind ) { case IMF . GeometryKind . Mesh : mesh = this . createMeshGeometry ( geometry , imf , outputUvs ) ; break ; case IMF . GeometryKind . Lines : mesh = this . createLineGeometry ( geometry , imf ) ; break ; case IMF . GeometryKind . Points : mesh = this . createPointGeometry ( geometry , imf ) ; break ; case IMF . GeometryKind . Empty : console . warn ( 'Could not find mesh for fragment' , fragment ) ; break ; } if ( mesh && mesh . primitives . length > 0 ) { for ( const primitive of mesh . primitives ) { primitive . material = fragment . material ; } node . mesh = this . addMesh ( mesh ) ; } return node ; } protected addMesh ( mesh ) { const meshes = this . manifest . meshes as gltf . Mesh [ ] ; const hash = this . computeMeshHash ( mesh ) ; const match = this . options . deduplicate ? this . meshHashes . indexOf ( hash ) : - 1 ; if ( match !== - 1 ) { this . options . log ( `Skipping a duplicate mesh (${ hash })` ) ; this . stats . meshesDeduplicated ++ ; return match ; } else { if ( this . options . deduplicate ) { this . meshHashes . push ( hash ) ; } return meshes . push ( mesh ) - 1 ; } } protected createMeshGeometry ( geometry , imf , outputUvs ) { let mesh = { primitives : [ ] } ; if ( this . options . ignoreMeshGeometry ) { return mesh ; } const indices = geometry . getIndices ( ) ; const indexBufferView = this . createBufferView ( Buffer . from ( indices . buffer ) ) ; const indexBufferViewID = this . addBufferView ( indexBufferView ) ; const indexAccessor = this . createAccessor ( indexBufferViewID , 5123 , indexBufferView . byteLength / 2 , 'SCALAR' ) ; const indexAccessorID = this . addAccessor ( indexAccessor ) ; const vertices = geometry . getVertices ( ) ; const positionBounds = this . computeBoundsVec3 ( vertices ) ; const positionBufferView = this . createBufferView ( Buffer . from ( vertices . buffer ) ) ; const positionBufferViewID = this . addBufferView ( positionBufferView ) ; const positionAccessor = this . createAccessor ( positionBufferViewID , 5126 , positionBufferView . byteLength / 4 / 3 , 'VEC3' , positionBounds . min , positionBounds . max ) ; const positionAccessorID = this . addAccessor ( positionAccessor ) ; let normalAccessorID = undefined ; const normals = geometry . getNormals ( ) ; if ( normals ) { const normalBufferView = this . createBufferView ( Buffer . from ( normals . buffer ) ) ; const normalBufferViewID = this . addBufferView ( normalBufferView ) ; const normalAccessor = this . createAccessor ( normalBufferViewID , 5126 , normalBufferView . byteLength / 4 / 3 , 'VEC3' ) ; normalAccessorID = this . addAccessor ( normalAccessor ) ; } let colorAccessorID = undefined ; const colors = geometry . getColors ( ) ; if ( colors ) { const colorBufferView = this . createBufferView ( Buffer . from ( colors . buffer ) ) ; const colorBufferViewID = this . addBufferView ( colorBufferView ) ; const colorAccessor = this . createAccessor ( colorBufferViewID , 5126 , colorBufferView . byteLength / 4 / 4 , 'VEC4' ) ; colorAccessorID = this . addAccessor ( colorAccessor ) ; } let uvAccessorID = undefined ; if ( geometry . getUvChannelCount ( ) > 0 && outputUvs ) { const uvs = geometry . getUvs ( 0 ) ; const uvBufferView = this . createBufferView ( Buffer . from ( uvs . buffer ) ) ; const uvBufferViewID = this . addBufferView ( uvBufferView ) ; const uvAccessor = this . createAccessor ( uvBufferViewID , 5126 , uvBufferView . byteLength / 4 / 2 , 'VEC2' ) ; uvAccessorID = this . addAccessor ( uvAccessor ) ; } mesh . primitives . push ( { mode : 4 , attributes : { POSITION : positionAccessorID , } , indices : indexAccessorID } ) ; if ( ! isUndefined ( normalAccessorID ) ) { mesh . primitives [ 0 ] . attributes . NORMAL = normalAccessorID ; } if ( ! isUndefined ( colorAccessorID ) ) { mesh . primitives [ 0 ] . attributes . COLOR_0 = colorAccessorID ; } if ( ! isUndefined ( uvAccessorID ) ) { mesh . primitives [ 0 ] . attributes . TEXCOORD_0 = uvAccessorID ; } return mesh ; } protected createLineGeometry ( geometry , imf ) { let mesh = { primitives : [ ] } ; if ( this . options . ignoreLineGeometry ) { return mesh ; } const indices = geometry . getIndices ( ) ; const indexBufferView = this . createBufferView ( Buffer . from ( indices . buffer ) ) ; const indexBufferViewID = this . addBufferView ( indexBufferView ) ; const indexAccessor = this . createAccessor ( indexBufferViewID , 5123 , indexBufferView . byteLength / 2 , 'SCALAR' ) ; const indexAccessorID = this . addAccessor ( indexAccessor ) ; const vertices = geometry . getVertices ( ) ; const positionBounds = this . computeBoundsVec3 ( vertices ) ; const positionBufferView = this . createBufferView ( Buffer . from ( vertices . buffer ) ) ; const positionBufferViewID = this . addBufferView ( positionBufferView ) ; const positionAccessor = this . createAccessor ( positionBufferViewID , 5126 , positionBufferView . byteLength / 4 / 3 , 'VEC3' , positionBounds . min , positionBounds . max ) ; const positionAccessorID = this . addAccessor ( positionAccessor ) ; let colorAccessorID = undefined ; const colors = geometry . getColors ( ) ; if ( colors ) { const colorBufferView = this . createBufferView ( Buffer . from ( colors . buffer ) ) ; const colorBufferViewID = this . addBufferView ( colorBufferView ) ; const colorAccessor = this . createAccessor ( colorBufferViewID , 5126 , colorBufferView . byteLength / 4 / 3 , 'VEC3' ) ; colorAccessorID = this . addAccessor ( colorAccessor ) ; } mesh . primitives . push ( { mode : 1 , attributes : { POSITION : positionAccessorID } , indices : indexAccessorID } ) ; if ( ! isUndefined ( colorAccessorID ) ) { mesh . primitives [ 0 ] . attributes [ 'COLOR_0' ] = colorAccessorID ; } return mesh ; } protected createPointGeometry ( geometry , imf ) { let mesh = { primitives : [ ] } ; if ( this . options . ignorePointGeometry ) { return mesh ; } const vertices = geometry . getVertices ( ) ; const positionBounds = this . computeBoundsVec3 ( vertices ) ; const positionBufferView = this . createBufferView ( Buffer . from ( vertices . buffer ) ) ; const positionBufferViewID = this . addBufferView ( positionBufferView ) ; const positionAccessor = this . createAccessor ( positionBufferViewID , 5126 , positionBufferView . byteLength / 4 / 3 , 'VEC3' , positionBounds . min , positionBounds . max ) ; const positionAccessorID = this . addAccessor ( positionAccessor ) ; let colorAccessorID = undefined ; const colors = geometry . getColors ( ) ; if ( colors ) { const colorBufferView = this . createBufferView ( Buffer . from ( colors . buffer ) ) ; const colorBufferViewID = this . addBufferView ( colorBufferView ) ; const colorAccessor = this . createAccessor ( colorBufferViewID , 5126 , colorBufferView . byteLength / 4 / 3 , 'VEC3' ) ; colorAccessorID = this . addAccessor ( colorAccessor ) ; } mesh . primitives . push ( { mode : 0 , attributes : { POSITION : positionAccessorID } } ) ; if ( ! isUndefined ( colorAccessorID ) ) { mesh . primitives [ 0 ] . attributes [ 'COLOR_0' ] = colorAccessorID ; } return mesh ; } protected addBufferView ( bufferView ) { const bufferViews = this . manifest . bufferViews as gltf . BufferView [ ] ; const hash = this . computeBufferViewHash ( bufferView ) ; const match = this . options . deduplicate ? this . bufferViewHashes . indexOf ( hash ) : - 1 ; if ( match !== - 1 ) { this . options . log ( `Skipping a duplicate buffer view (${ hash })` ) ; this . stats . bufferViewsDeduplicated ++ ; return match ; } else { if ( this . options . deduplicate ) { this . bufferViewHashes . push ( hash ) ; } return bufferViews . push ( bufferView ) - 1 ; } } protected createBufferView ( data ) { const hash = this . computeBufferHash ( data ) ; const cache = this . bufferViewCache . get ( hash ) ; if ( this . options . deduplicate && cache ) { this . options . log ( `Skipping a duplicate buffer (${ hash })` ) ; return cache ; } const manifestBuffers = this . manifest . buffers as gltf . Buffer [ ] ; if ( this . bufferStream === null || this . bufferSize > this . options . maxBufferSize ) { if ( this . bufferStream ) { const stream = this . bufferStream as fse . WriteStream ; this . pendingTasks . push ( new Promise ( ( resolve , reject ) => { stream . on ( 'finish' , resolve ) ; } ) ) ; this . bufferStream . close ( ) ; this . bufferStream = null ; this . bufferSize = 0 ; } const bufferUri = `${ manifestBuffers . length }.bin` ; manifestBuffers . push ( { uri : bufferUri , byteLength : 0 } ) ; const bufferPath = path . join ( this . baseDir , bufferUri ) ; this . bufferStream = fse . createWriteStream ( bufferPath ) ; } const bufferID = manifestBuffers . length - 1 ; const buffer = manifestBuffers [ bufferID ] ; this . bufferStream . write ( data ) ; this . bufferSize += data . byteLength ; const bufferView = { buffer : bufferID , byteOffset : buffer . byteLength , byteLength : data . byteLength } ; buffer . byteLength += bufferView . byteLength ; if ( buffer . byteLength % 4 !== 0 ) { const pad = 4 - buffer . byteLength % 4 ; this . bufferStream . write ( new Uint8Array ( pad ) ) ; this . bufferSize += pad ; buffer . byteLength += pad ; } if ( this . options . deduplicate ) { this . bufferViewCache . set ( hash , bufferView ) ; } return bufferView ; } protected addAccessor ( accessor ) { const accessors = this . manifest . accessors as gltf . Accessor [ ] ; const hash = this . computeAccessorHash ( accessor ) ; const match = this . options . deduplicate ? this . accessorHashes . indexOf ( hash ) : - 1 ; if ( match !== - 1 ) { this . options . log ( `Skipping a duplicate accessor (${ hash })` ) ; this . stats . accessorsDeduplicated ++ ; return match ; } else { if ( this . options . deduplicate ) { this . accessorHashes . push ( hash ) ; } return accessors . push ( accessor ) - 1 ; } } protected createAccessor ( bufferViewID , componentType , count , type , min ? , max ? ) { const accessor = { bufferView : bufferViewID , componentType : componentType , count : count , type : type } ; if ( ! isUndefined ( min ) ) { accessor . min = min . map ( Math . fround ) ; } if ( ! isUndefined ( max ) ) { accessor . max = max . map ( Math . fround ) ; } return accessor ; } protected createMaterial ( mat , imf ) { if ( ! mat ) { return DefaultMaterial ; } const diffuse = mat . diffuse ; let material = { pbrMetallicRoughness : { baseColorFactor : [ diffuse . x , diffuse . y , diffuse . z , 1.0 ] , metallicFactor : mat . metallic , roughnessFactor : mat . roughness } } ; if ( ! isUndefined ( mat . opacity ) && mat . opacity < 1.0 && material . pbrMetallicRoughness . baseColorFactor ) { material . alphaMode = 'BLEND' ; material . pbrMetallicRoughness . baseColorFactor [ 3 ] = mat . opacity ; } if ( mat . maps ) { const manifestTextures = this . manifest . textures as gltf . Texture [ ] ; if ( mat . maps . diffuse ) { const textureID = manifestTextures . length ; manifestTextures . push ( this . createTexture ( mat . maps . diffuse , imf ) ) ; material . pbrMetallicRoughness . baseColorTexture = { index : textureID , texCoord : 0 , extensions : { "KHR_texture_transform" : { scale : [ mat . scale ?. x , mat . scale ?. y ] } } } ; } } return material ; } protected createTexture ( uri , imf ) { const manifestImages = this . manifest . images as gltf . Image [ ] ; let imageID = manifestImages . findIndex ( image => image . uri === uri ) ; if ( imageID === - 1 ) { imageID = manifestImages . length ; const normalizedUri = uri . toLowerCase ( ) . split ( /[\/\\]/ ) . join ( path . sep ) ; manifestImages . push ( { uri : normalizedUri } ) ; const filePath = path . join ( this . baseDir , normalizedUri ) ; fse . ensureDirSync ( path . dirname ( filePath ) ) ; let imageData = imf . getImage ( normalizedUri ) ; if ( ! imageData ) { switch ( normalizedUri . substr ( normalizedUri . lastIndexOf ( '.' ) ) ) { case '.jpg' : case '.jpeg' : imageData = ImagePlaceholder . JPG ; break ; case '.png' : imageData = ImagePlaceholder . PNG ; break ; case '.bmp' : imageData = ImagePlaceholder . BMP ; break ; case '.gif' : imageData = ImagePlaceholder . GIF ; break ; default : throw new Error ( `Unsupported image format for ${ normalizedUri }` ) ; } } fse . writeFileSync ( filePath , imageData ) ; } return { source : imageID } ; } protected computeMeshHash ( mesh ) { return mesh . primitives . map ( p => { return `${ p . mode || '' }/${ p . material || '' }/${ p . indices }/${ p . attributes [ 'POSITION' ] || '' }/${ p . attributes [ 'NORMAL' ] || '' }/${ p . attributes [ 'TEXCOORD_0' ] || '' }/${ p . attributes [ 'COLOR_0' ] || '' }` ; } ) . join ( '/' ) ; } protected computeBufferViewHash ( bufferView ) { return `${ bufferView . buffer }/${ bufferView . byteLength }/${ bufferView . byteOffset || '' }/${ bufferView . byteStride || '' }` ; } protected computeAccessorHash ( accessor ) { return `${ accessor . type }/${ accessor . componentType }/${ accessor . count }/${ accessor . bufferView || 'X' }` ; } protected computeBufferHash ( buffer ) { const hash = crypto . createHash ( 'md5' ) ; hash . update ( buffer ) ; return hash . digest ( 'hex' ) ; } protected computeMaterialHash ( material ) { if ( ! material ) { return 'null' ; } const hash = crypto . createHash ( 'md5' ) ; hash . update ( JSON . stringify ( material ) ) ; return hash . digest ( 'hex' ) ; } protected computeBoundsVec3 ( array ) { const min = [ array [ 0 ] , array [ 1 ] , array [ 2 ] ] ; const max = [ array [ 0 ] , array [ 1 ] , array [ 2 ] ] ; for ( let i = 0 ; i < array . length ; i += 3 ) { min [ 0 ] = Math . min ( min [ 0 ] , array [ i ] ) ; max [ 0 ] = Math . max ( max [ 0 ] , array [ i ] ) ; min [ 1 ] = Math . min ( min [ 1 ] , array [ i + 1 ] ) ; max [ 1 ] = Math . max ( max [ 1 ] , array [ i + 1 ] ) ; min [ 2 ] = Math . min ( min [ 2 ] , array [ i + 2 ] ) ; max [ 2 ] = Math . max ( max [ 2 ] , array [ i + 2 ] ) ; } return { min , max } ; } }