diff --git a/.gitignore b/.gitignore index 53cace90..4ffd6811 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ npm-debug.log # Generate data .eslintcache coverage -doc *.tgz .nyc_output +doc/* +!doc/cesium.png diff --git a/.idea/encodings.xml b/.idea/encodings.xml index 97626ba4..65bf126c 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -1,6 +1,8 @@ + + \ No newline at end of file diff --git a/.npmignore b/.npmignore index 3480bcec..33f72365 100644 --- a/.npmignore +++ b/.npmignore @@ -1,6 +1,6 @@ /.idea /coverage -/doc +/doc/* /specs /test .editorconfig @@ -12,3 +12,4 @@ .travis.yml gulpfile.js *.tgz +!doc/cesium.png diff --git a/CHANGES.md b/CHANGES.md index a2f93930..0dc04e97 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,14 @@ Change Log ========== +### 2.0.0 2017-07-XX + +* Breaking changes + * Obj models now convert to glTF 2.0. Possible material profiles are `metallicRoughness`, `specGlossiness` (using the `KHR_materials_pbrSpecularGlossiness` extension), and `materialsCommon` (using the `KHR_materials_common` extension). + * Removed `gltf-pipeline` dependency. The following options have been removed: `compress`, `optimize`, `generateNormals`, `optimizeForCesium`, `ao`, and `bypassPipeline`. + * Removed `inputUpAxis` and `outputUpAxis`. This stage will be incorporated into `gltf-pipeline` instead. + * `obj2gltf` no longer takes a `gltfPath` argument and saves a glTF file. Instead it returns a promise that resolves to the glTF JSON or glb buffer. + ### 1.2.0 2017-07-11 * Change texture sampling to use `NEAREST_MIPMAP_LINEAR` by default [#83](https://github.com/AnalyticalGraphicsInc/obj2gltf/pull/83). diff --git a/README.md b/README.md index 7b5e7863..fafefdd1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OBJ2GLTF -Convert OBJ assets to [glTF](https://www.khronos.org/gltf) 1.0. +Convert OBJ assets to [glTF](https://www.khronos.org/gltf) 2.0. ## Getting Started @@ -8,22 +8,64 @@ Install [Node.js](https://nodejs.org/en/) if you don't already have it, and then ``` npm install --save obj2gltf ``` -Using obj2gltf as a library: + +### Using obj2gltf as a command-line tool: + +`node bin/obj2gltf.js -i model.obj` + +`node bin/obj2gltf.js -i model.obj -o model.gltf` + +`node bin/obj2gltf.js -i model.obj -o model.glb` + +### Using obj2gltf as a library: + +#### Converting an obj model to gltf: + +```javascript +var obj2gltf = require('obj2gltf'); +obj2gltf('model.obj') + .then(function(gltf) { + console.log(gltf.asset); + }); +``` + +#### Converting an obj model to glb + ```javascript var obj2gltf = require('obj2gltf'); var options = { - separateTextures : true // Don't embed textures in the converted glTF + binary : true } -obj2gltf('model.obj', 'model.gltf', options) - .then(function() { - console.log('Converted model'); +obj2gltf('model.obj', options) + .then(function(glb) { + console.log(glb.length); }); ``` -Using obj2gltf as a command-line tool: -`node bin/obj2gltf.js -i model.obj` +## Material types -`node bin/obj2gltf.js -i model.obj -o model.gltf` +Traditionally the .mtl file format describes the Blinn-Phong shading model. Meanwhile glTF 2.0 introduces physically-based +materials. + +There are three shading models supported by `obj2gltf`: + +* Metallic roughness PBR +* Specular glossiness PBR (via `KHR_materials_pbrSpecularGlossiness` extension) +* Materials common (via `KHR_materials_common` extension) + +If the material type is known in advance, it should be specified with either the `metallicRoughness`, `specularGlossiness`, or `materialsCommon` flag. + +In general, if a model is authored with traditional diffuse, specular, and shininess textures the `materialsCommon` flag should be passed in. +The glTF will be saved with the `KHR_materials_common` extension and the Blinn-Phong shading model will be used. + +However if the model is created with PBR textures, either the `metallicRoughness` or `specularGlossiness` flag should be passed in. +See the command line flags below for more information about how to specify PBR values inside the .mtl file. + +If none of these flags are provided, the .mtl is assumed to contain traditional Blinn-Phong materials which will be converted to metallic-roughness PBR. +There may be some quality loss as traditional materials do not map perfectly to PBR materials. + +Commonly in PBR workflows the the .mtl file may not exist or its values may be outdated or incorrect. +As a convenience the PBR textures may be supplied directly to the command line. See the options below. ## Usage @@ -33,21 +75,22 @@ Using obj2gltf as a command-line tool: |----|-----------|--------| |`-h`, `--help`|Display help.|No| |`-i`, `--input`|Path to the obj file.| :white_check_mark: Yes| -|`-o`, `--output`|Path of the converted glTF file.|No| -|`-b`, `--binary`|Save as binary glTF.|No, default `false`| -|`-s`, `--separate`|Writes out separate geometry data files, shader files, and textures instead of embedding them in the glTF file.|No, default `false`| +|`-o`, `--output`|Path of the converted glTF or glb file.|No| +|`-b`, `--binary`|Save as binary glTF (.glb).|No, default `false`| +|`-s`, `--separate`|Writes out separate buffers and textures instead of embedding them in the glTF file.|No, default `false`| |`-t`, `--separateTextures`|Write out separate textures only.|No, default `false`| -|`-c`, `--compress`|Quantize positions, compress texture coordinates, and oct-encode normals.|No, default `false`| -|`-z`, `--optimize`|Use the optimization stages in the glTF pipeline.|No, default `false`| -|`-n`, `--generateNormals`|Generate normals if they are missing.|No, default `false`| -|`--optimizeForCesium`|Optimize the glTF for Cesium by using the sun as a default light source.|No, default `false`| -|`--ao`|Apply ambient occlusion to the converted model.|No, default `false`| -|`--kmc`|Output glTF with the KHR_materials_common extension.|No, default `false`| -|`--bypassPipeline`|Bypass the gltf-pipeline for debugging purposes. This option overrides many of the options above and will save the glTF with the KHR_materials_common extension.|No, default `false`| |`--checkTransparency`|Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. By default textures are considered to be opaque.|No, default `false`| -|`--secure`|Prevent the converter from reading image or mtl files outside of the input obj directory.|No, default `false`| -|`--inputUpAxis`|Up axis of the obj. Choices are 'X', 'Y', and 'Z'.|No, default `Y`| -|`--outputUpAxis`|Up axis of the converted glTF. Choices are 'X', 'Y', and 'Z'.|No, default `Y`| +|`--secure`|Prevent the converter from reading texture or mtl files outside of the input obj directory.|No, default `false`| +|`--packOcclusion`|Pack the occlusion texture in the red channel of metallic-roughness texture.|No, default `false`| +|`--metallicRoughness`|The values in the mtl file are already metallic-roughness PBR values and no conversion step should be applied. Metallic is stored in the Ks and map_Ks slots and roughness is stored in the Ns and map_Ns slots.|No, default `false`| +|`--specularGlossiness`|The values in the mtl file are already specular-glossiness PBR values and no conversion step should be applied. Specular is stored in the Ks and map_Ks slots and glossiness is stored in the Ns and map_Ns slots. The glTF will be saved with the `KHR_materials_pbrSpecularGlossiness` extension.|No, default `false`| +|`--materialsCommon`|The glTF will be saved with the KHR_materials_common extension.|No, default `false`| +|`--metallicRoughnessOcclusionTexture`|Path to the metallic-roughness-occlusion texture that should override textures in the .mtl file, where occlusion is stored in the red channel, roughness is stored in the green channel, and metallic is stored in the blue channel. The model will be saved with a pbrMetallicRoughness material. This is often convenient in workflows where the .mtl does not exist or is not set up to use PBR materials. Intended for models with a single material.|No| +|`--specularGlossinessTexture`|Path to the specular-glossiness texture that should override textures in the .mtl file, where specular color is stored in the red, green, and blue channels and specular glossiness is stored in the alpha channel. The model will be saved with a material using the KHR_materials_pbrSpecularGlossiness extension.|No| +|`--occlusionTexture`|Path to the occlusion texture that should override textures in the .mtl file.|No| +|`--normalTexture`|Path to the normal texture that should override textures in the .mtl file.|No| +|`--baseColorTexture`|Path to the baseColor/diffuse texture that should override textures in the .mtl file.|No| +|`--emissiveTexture`|Path to the emissive texture that should override textures in the .mtl file.|No| ## Build Instructions @@ -83,11 +126,6 @@ npm run jsdoc The documentation will be placed in the `doc` folder. -## Debugging - -* To debug the tests in Webstorm, open the Gulp tab, right click the `test` task, and click `Debug 'test'`. -* To run a single test, change the test function from `it` to `fit`. - ## Contributions Pull requests are appreciated. Please use the same [Contributor License Agreement (CLA)](https://github.com/AnalyticalGraphicsInc/cesium/blob/master/CONTRIBUTING.md) used for [Cesium](http://cesiumjs.org/). diff --git a/bin/obj2gltf.js b/bin/obj2gltf.js index 478664c8..58abdd94 100644 --- a/bin/obj2gltf.js +++ b/bin/obj2gltf.js @@ -1,6 +1,7 @@ #!/usr/bin/env node 'use strict'; var Cesium = require('cesium'); +var fsExtra = require('fs-extra'); var path = require('path'); var yargs = require('yargs'); var obj2gltf = require('../lib/obj2gltf'); @@ -18,130 +19,164 @@ var argv = yargs .alias('h', 'help') .options({ input : { - alias: 'i', - describe: 'Path to the obj file.', - type: 'string', - normalize: true, - demandOption: true + alias : 'i', + describe : 'Path to the obj file.', + type : 'string', + normalize : true, + demandOption : true }, output : { - alias: 'o', - describe: 'Path of the converted glTF file.', - type: 'string', - normalize: true + alias : 'o', + describe : 'Path of the converted glTF or glb file.', + type : 'string', + normalize : true }, binary : { - alias: 'b', - describe: 'Save as binary glTF.', - type: 'boolean', - default: defaults.binary + alias : 'b', + describe : 'Save as binary glTF (.glb)', + type : 'boolean', + default : defaults.binary }, separate : { - alias: 's', - describe: 'Write separate geometry data files, shader files, and textures instead of embedding them in the glTF.', - type: 'boolean', - default: defaults.separate + alias : 's', + describe : 'Write separate buffers and textures instead of embedding them in the glTF.', + type : 'boolean', + default : defaults.separate }, separateTextures : { - alias: 't', - describe: 'Write out separate textures only.', - type: 'boolean', - default: defaults.separateTextures - }, - compress : { - alias: 'c', - describe: 'Quantize positions, compress texture coordinates, and oct-encode normals.', - type: 'boolean', - default: defaults.compress - }, - optimize : { - alias: 'z', - describe: 'Optimize the glTF for size and runtime performance.', - type: 'boolean', - default: defaults.optimize - }, - optimizeForCesium : { - describe: 'Optimize the glTF for Cesium by using the sun as a default light source.', - type: 'boolean', - default: defaults.optimizeForCesium - }, - generateNormals : { - alias: 'n', - describe: 'Generate normals if they are missing.', - type: 'boolean', - default: defaults.generateNormals - }, - ao : { - describe: 'Apply ambient occlusion to the converted model.', - type: 'boolean', - default: defaults.ao - }, - kmc : { - describe: 'Output glTF with the KHR_materials_common extension.', - type: 'boolean', - default: defaults.kmc - }, - bypassPipeline : { - describe: 'Bypass the gltf-pipeline for debugging purposes. This option overrides many of the options above and will save the glTF with the KHR_materials_common extension.', - type: 'boolean', - default: defaults.bypassPipeline + alias : 't', + describe : 'Write out separate textures only.', + type : 'boolean', + default : defaults.separateTextures }, checkTransparency : { - describe: 'Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. By default textures are considered to be opaque.', - type: 'boolean', - default: defaults.checkTransparency + describe : 'Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. By default textures are considered to be opaque.', + type : 'boolean', + default : defaults.checkTransparency }, secure : { - describe: 'Prevent the converter from reading image or mtl files outside of the input obj directory.', - type: 'boolean', - default: defaults.secure - }, - inputUpAxis : { - describe: 'Up axis of the obj.', - choices: ['X', 'Y', 'Z'], - type: 'string', - default: 'Y' - }, - outputUpAxis : { - describe: 'Up axis of the converted glTF.', - choices: ['X', 'Y', 'Z'], - type: 'string', - default: 'Y' + describe : 'Prevent the converter from reading textures or mtl files outside of the input obj directory.', + type : 'boolean', + default : defaults.secure + }, + packOcclusion : { + describe : 'Pack the occlusion texture in the red channel of metallic-roughness texture.', + type : 'boolean', + default : defaults.packOcclusion + }, + metallicRoughness : { + describe : 'The values in the .mtl file are already metallic-roughness PBR values and no conversion step should be applied. Metallic is stored in the Ks and map_Ks slots and roughness is stored in the Ns and map_Ns slots.', + type : 'boolean', + default : defaults.metallicRoughness + }, + specularGlossiness : { + describe : 'The values in the .mtl file are already specular-glossiness PBR values and no conversion step should be applied. Specular is stored in the Ks and map_Ks slots and glossiness is stored in the Ns and map_Ns slots. The glTF will be saved with the KHR_materials_pbrSpecularGlossiness extension.', + type : 'boolean', + default : defaults.specularGlossiness + }, + materialsCommon : { + describe : 'The glTF will be saved with the KHR_materials_common extension.', + type : 'boolean', + default : defaults.materialsCommon + }, + metallicRoughnessOcclusionTexture : { + describe : 'Path to the metallic-roughness-occlusion texture that should override textures in the .mtl file, where occlusion is stored in the red channel, roughness is stored in the green channel, and metallic is stored in the blue channel. The model will be saved with a pbrMetallicRoughness material. This is often convenient in workflows where the .mtl does not exist or is not set up to use PBR materials. Intended for models with a single material', + type : 'string', + normalize : true + }, + specularGlossinessTexture : { + describe : 'Path to the specular-glossiness texture that should override textures in the .mtl file, where specular color is stored in the red, green, and blue channels and specular glossiness is stored in the alpha channel. The model will be saved with a material using the KHR_materials_pbrSpecularGlossiness extension.', + type : 'string', + normalize : true + }, + occlusionTexture : { + describe : 'Path to the occlusion texture that should override textures in the .mtl file.', + type : 'string', + normalize : true + }, + normalTexture : { + describe : 'Path to the normal texture that should override textures in the .mtl file.', + type : 'string', + normalize : true + }, + baseColorTexture : { + describe : 'Path to the baseColor/diffuse texture that should override textures in the .mtl file.', + type : 'string', + normalize : true + }, + emissiveTexture : { + describe : 'Path to the emissive texture that should override textures in the .mtl file.', + type : 'string', + normalize : true } }).parse(args); -var objPath = argv.i; -var gltfPath = argv.o; +if (argv.metallicRoughness + argv.specularGlossiness + argv.materialsCommon > 1) { + console.error('Only one material type may be set from [--metallicRoughness, --specularGlossiness, --materialsCommon].'); + process.exit(1); +} + +if (defined(argv.metallicRoughnessOcclusionTexture) && defined(argv.specularGlossinessTexture)) { + console.error('--metallicRoughnessOcclusionTexture and --specularGlossinessTexture cannot both be set.'); + process.exit(1); +} + +var objPath = argv.input; +var gltfPath = argv.output; +var name = path.basename(objPath, path.extname(objPath)); if (!defined(gltfPath)) { - var extension = argv.b ? '.glb' : '.gltf'; - var modelName = path.basename(objPath, path.extname(objPath)); - gltfPath = path.join(path.dirname(objPath), modelName + extension); + gltfPath = path.join(path.dirname(objPath), name + '.gltf'); +} + +var outputDirectory = path.dirname(gltfPath); +var extension = path.extname(gltfPath).toLowerCase(); +if (argv.binary || extension === '.glb') { + argv.binary = true; + extension = '.glb'; } +gltfPath = path.join(outputDirectory, name + extension); + +var overridingTextures = { + metallicRoughnessOcclusionTexture : argv.metallicRoughnessOcclusionTexture, + specularGlossinessTexture : argv.specularGlossinessTexture, + occlusionTexture : argv.occlusionTexture, + normalTexture : argv.normalTexture, + baseColorTexture : argv.baseColorTexture, + emissiveTexture : argv.emissiveTexture +}; var options = { binary : argv.binary, separate : argv.separate, separateTextures : argv.separateTextures, - compress : argv.compress, - optimize : argv.optimize, - optimizeForCesium : argv.optimizeForCesium, - generateNormals : argv.generateNormals, - ao : argv.ao, - kmc : argv.kmc, - bypassPipeline : argv.bypassPipeline, checkTransparency : argv.checkTransparency, secure : argv.secure, - inputUpAxis : argv.inputUpAxis, - outputUpAxis : argv.outputUpAxis + packOcclusion : argv.packOcclusion, + metallicRoughness : argv.metallicRoughness, + specularGlossiness : argv.specularGlossiness, + materialsCommon : argv.materialsCommon, + overridingTextures : overridingTextures, + outputDirectory : outputDirectory }; console.time('Total'); -obj2gltf(objPath, gltfPath, options) +obj2gltf(objPath, options) + .then(function(gltf) { + if (argv.binary) { + // gltf is a glb buffer + return fsExtra.outputFile(gltfPath, gltf); + } + var jsonOptions = { + spaces : 2 + }; + return fsExtra.outputJson(gltfPath, gltf, jsonOptions); + }) .then(function() { console.timeEnd('Total'); }) .catch(function(error) { console.log(error.message); + process.exit(1); }); diff --git a/lib/ArrayStorage.js b/lib/ArrayStorage.js index c97fd432..3b9cfda9 100644 --- a/lib/ArrayStorage.js +++ b/lib/ArrayStorage.js @@ -14,7 +14,6 @@ var fixedExpansionLength = 33554432; // 2^25 (~134 MB for a Float32Array) * stored with double precision. The resizing mechanism is similar to std::vector. * * @param {ComponentDatatype} componentDatatype The data type. - * @constructor * * @private */ diff --git a/lib/Material.js b/lib/Material.js deleted file mode 100644 index 60d284dc..00000000 --- a/lib/Material.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -module.exports = Material; - -function Material() { - this.ambientColor = [0.0, 0.0, 0.0, 1.0]; // Ka - this.emissionColor = [0.0, 0.0, 0.0, 1.0]; // Ke - this.diffuseColor = [0.5, 0.5, 0.5, 1.0]; // Kd - this.specularColor = [0.0, 0.0, 0.0, 1.0]; // Ks - this.specularShininess = 0.0; // Ns - this.alpha = 1.0; // d / Tr - this.ambientTexture = undefined; // map_Ka - this.emissionTexture = undefined; // map_Ke - this.diffuseTexture = undefined; // map_Kd - this.specularTexture = undefined; // map_Ks - this.specularShininessMap = undefined; // map_Ns - this.normalMap = undefined; // map_Bump - this.alphaMap = undefined; // map_d -} diff --git a/lib/Texture.js b/lib/Texture.js new file mode 100644 index 00000000..aa440533 --- /dev/null +++ b/lib/Texture.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = Texture; + +/** + * An object containing information about a texture. + * + * @private + */ +function Texture() { + this.transparent = false; + this.source = undefined; + this.name = undefined; + this.extension = undefined; + this.path = undefined; + this.pixels = undefined; + this.width = undefined; + this.height = undefined; +} diff --git a/lib/createGltf.js b/lib/createGltf.js index f7bc7473..5b6c0444 100644 --- a/lib/createGltf.js +++ b/lib/createGltf.js @@ -1,10 +1,10 @@ 'use strict'; var Cesium = require('cesium'); -var path = require('path'); -var Material = require('./Material'); +var getBufferPadded = require('./getBufferPadded'); +var getDefaultMaterial = require('./loadMtl').getDefaultMaterial; +var Texture = require('./Texture'); var defined = Cesium.defined; -var defaultValue = Cesium.defaultValue; var WebGLConstants = Cesium.WebGLConstants; module.exports = createGltf; @@ -12,336 +12,383 @@ module.exports = createGltf; /** * Create a glTF from obj data. * - * @param {Object} objData Output of obj.js, containing an array of nodes containing geometry information, materials, and images. - * @returns {Object} A glTF asset with the KHR_materials_common extension. + * @param {Object} objData An object containing an array of nodes containing geometry information and an array of materials. + * @param {Object} options The options object passed along from lib/obj2gltf.js + * @returns {Object} A glTF asset. * * @private */ function createGltf(objData, options) { var nodes = objData.nodes; var materials = objData.materials; - var images = objData.images; - var sceneId = 'scene'; - var samplerId = 'sampler'; - var bufferId = 'buffer'; - var vertexBufferViewId = 'bufferView_vertex'; - var indexBufferViewId = 'bufferView_index'; + var name = objData.name; var gltf = { - accessors : {}, + accessors : [], asset : {}, - buffers : {}, - bufferViews : {}, - extensionsUsed : ['KHR_materials_common'], - images : {}, - materials : {}, - meshes : {}, - nodes : {}, - samplers : {}, - scene : sceneId, - scenes : {}, - textures : {} + buffers : [], + bufferViews : [], + extensionsUsed : [], + extensionsRequired : [], + images : [], + materials : [], + meshes : [], + nodes : [], + samplers : [], + scene : 0, + scenes : [], + textures : [] }; gltf.asset = { generator : 'obj2gltf', - profile : { - api : 'WebGL', - version : '1.0' - }, - version: '1.0' + version: '2.0' }; - gltf.scenes[sceneId] = { + gltf.scenes.push({ nodes : [] + }); + + var bufferState = { + positionBuffers : [], + normalBuffers : [], + uvBuffers : [], + indexBuffers : [], + positionAccessors : [], + normalAccessors : [], + uvAccessors : [], + indexAccessors : [] }; - function getImageId(imagePath) { - return path.basename(imagePath, path.extname(imagePath)); - } + var uint32Indices = requiresUint32Indices(nodes); - function getTextureId(imagePath) { - if (!defined(imagePath) || !defined(images[imagePath])) { - return undefined; - } - return 'texture_' + getImageId(imagePath); - } + var nodesLength = nodes.length; + for (var i = 0; i < nodesLength; ++i) { + var node = nodes[i]; + var meshes = node.meshes; + var meshesLength = meshes.length; + var meshIndex; - function createMaterial(material, hasNormals, options) { - var ambient = defaultValue(defaultValue(getTextureId(material.ambientTexture), material.ambientColor)); - var diffuse = defaultValue(defaultValue(getTextureId(material.diffuseTexture), material.diffuseColor)); - var emission = defaultValue(defaultValue(getTextureId(material.emissionTexture), material.emissionColor)); - var specular = defaultValue(defaultValue(getTextureId(material.specularTexture), material.specularColor)); - var alpha = defaultValue(defaultValue(material.alpha), 1.0); - var shininess = defaultValue(material.specularShininess, 0.0); - var hasSpecular = (shininess > 0.0) && (specular[0] > 0.0 || specular[1] > 0.0 || specular[2] > 0.0); - - var transparent; - var transparency = 1.0; - if (typeof diffuse === 'string') { - transparency = alpha; - transparent = images[material.diffuseTexture].transparent || (transparency < 1.0); + if (meshesLength === 1) { + meshIndex = addMesh(gltf, materials, bufferState, uint32Indices, meshes[0], options); + addNode(gltf, node.name, meshIndex, undefined); } else { - diffuse[3] = alpha; - transparent = diffuse[3] < 1.0; + // Add meshes as child nodes + var parentIndex = addNode(gltf, node.name); + for (var j = 0; j < meshesLength; ++j) { + var mesh = meshes[j]; + meshIndex = addMesh(gltf, materials, bufferState, uint32Indices, mesh, options); + addNode(gltf, mesh.name, meshIndex, parentIndex); + } } + } - if (Array.isArray(ambient)) { - // If ambient color is [1, 1, 1] assume it is a multiplier and instead change to [0, 0, 0] - if (ambient[0] === 1.0 && ambient[1] === 1.0 && ambient[2] === 1.0) { - ambient = [0.0, 0.0, 0.0, 1.0]; + if (gltf.images.length > 0) { + gltf.samplers.push({ + magFilter : WebGLConstants.LINEAR, + minFilter : WebGLConstants.NEAREST_MIPMAP_LINEAR, + wrapS : WebGLConstants.REPEAT, + wrapT : WebGLConstants.REPEAT + }); + } + + addBuffers(gltf, bufferState, name); + + if (options.specularGlossiness) { + gltf.extensionsUsed.push('KHR_materials_pbrSpecularGlossiness'); + gltf.extensionsRequired.push('KHR_materials_pbrSpecularGlossiness'); + } else if (options.materialsCommon) { + gltf.extensionsUsed.push('KHR_materials_common'); + gltf.extensionsRequired.push('KHR_materials_common'); + } + + return gltf; +} + +function addBufferView(gltf, buffers, accessors, byteStride, target) { + var length = buffers.length; + if (length === 0) { + return; + } + var bufferViewIndex = gltf.bufferViews.length; + var previousBufferView = gltf.bufferViews[bufferViewIndex - 1]; + var byteOffset = defined(previousBufferView) ? previousBufferView.byteOffset + previousBufferView.byteLength : 0; + var byteLength = 0; + for (var i = 0; i < length; ++i) { + var accessor = gltf.accessors[accessors[i]]; + accessor.bufferView = bufferViewIndex; + accessor.byteOffset = byteLength; + byteLength += buffers[i].length; + } + gltf.bufferViews.push({ + name : 'bufferView_' + bufferViewIndex, + buffer : 0, + byteLength : byteLength, + byteOffset : byteOffset, + byteStride : byteStride, + target : target + }); +} + +function addBuffers(gltf, bufferState, name) { + // Positions and normals share the same byte stride so they can share the same bufferView + var positionsAndNormalsAccessors = bufferState.positionAccessors.concat(bufferState.normalAccessors); + var positionsAndNormalsBuffers = bufferState.positionBuffers.concat(bufferState.normalBuffers); + addBufferView(gltf, positionsAndNormalsBuffers, positionsAndNormalsAccessors, 12, WebGLConstants.ARRAY_BUFFER); + addBufferView(gltf, bufferState.uvBuffers, bufferState.uvAccessors, 8, WebGLConstants.ARRAY_BUFFER); + addBufferView(gltf, bufferState.indexBuffers, bufferState.indexAccessors, undefined, WebGLConstants.ELEMENT_ARRAY_BUFFER); + + var buffers = []; + buffers = buffers.concat(bufferState.positionBuffers, bufferState.normalBuffers, bufferState.uvBuffers, bufferState.indexBuffers); + var buffer = getBufferPadded(Buffer.concat(buffers)); + + gltf.buffers.push({ + name : name, + byteLength : buffer.length, + extras : { + _obj2gltf : { + source : buffer } } + }); +} - var doubleSided = transparent; +function addTexture(gltf, texture) { + var imageName = texture.name; + var textureName = texture.name; + var imageIndex = gltf.images.length; + var textureIndex = gltf.textures.length; - if (!hasNormals && !options.generateNormals) { - // Constant technique only factors in ambient and emission sources - set emission to diffuse - emission = diffuse; - diffuse = [0, 0, 0, 1]; + gltf.images.push({ + name : imageName, + extras : { + _obj2gltf : texture } + }); - var technique = hasNormals ? (hasSpecular ? 'PHONG' : 'LAMBERT') : 'CONSTANT'; - return { - extensions : { - KHR_materials_common : { - technique : technique, - transparent : transparent, - doubleSided : doubleSided, - values : { - ambient : ambient, - diffuse : diffuse, - emission : emission, - specular : specular, - shininess : shininess, - transparency : transparency, - transparent : transparent, - doubleSided : doubleSided - } - } - } - }; + gltf.textures.push({ + name : textureName, + sampler : 0, + source : imageIndex + }); + + return textureIndex; +} + +function getTexture(gltf, texture) { + var textureIndex; + var name = texture.name; + var textures = gltf.textures; + var length = textures.length; + for (var i = 0; i < length; ++i) { + if (textures[i].name === name) { + textureIndex = i; + break; + } } - if (Object.keys(images).length > 0) { - gltf.samplers[samplerId] = { - magFilter : WebGLConstants.LINEAR, - minFilter : WebGLConstants.NEAREST_MIPMAP_LINEAR, - wrapS : WebGLConstants.REPEAT, - wrapT : WebGLConstants.REPEAT - }; + if (!defined(textureIndex)) { + textureIndex = addTexture(gltf, texture); } - for (var imagePath in images) { - if (images.hasOwnProperty(imagePath)) { - var image = images[imagePath]; - var imageId = getImageId(imagePath); - var textureId = getTextureId(imagePath); - - gltf.images[imageId] = { - name : imageId, - extras : { - _obj2gltf : { - source : image.source, - extension : image.extension - } - } - }; - gltf.textures[textureId] = { - format : image.format, - internalFormat : image.format, - sampler : samplerId, - source : imageId, - target : WebGLConstants.TEXTURE_2D, - type : WebGLConstants.UNSIGNED_BYTE - }; + return { + index : textureIndex + }; +} + +function resolveTextures(gltf, material) { + for (var name in material) { + if (material.hasOwnProperty(name)) { + var property = material[name]; + if (property instanceof Texture) { + material[name] = getTexture(gltf, property); + } else if (!Array.isArray(property) && (typeof property === 'object')) { + resolveTextures(gltf, property); + } } } +} + +function addMaterial(gltf, material) { + resolveTextures(gltf, material); + var materialIndex = gltf.materials.length; + gltf.materials.push(material); + return materialIndex; +} - var vertexBuffers = []; - var vertexBufferByteOffset = 0; - var indexBuffers = []; - var indexBufferByteOffset = 0; - var accessorCount = 0; - - function addVertexAttribute(array, components) { - var count = array.length / components; - var buffer = array.toFloatBuffer(); - var minMax = array.getMinMax(components); - - var type = (components === 3 ? 'VEC3' : 'VEC2'); - var accessor = { - bufferView : vertexBufferViewId, - byteOffset : vertexBufferByteOffset, - byteStride : 0, - componentType : WebGLConstants.FLOAT, - count : count, - min : minMax.min, - max : minMax.max, - type : type - }; - - vertexBufferByteOffset += buffer.length; - vertexBuffers.push(buffer); - var accessorId = 'accessor_' + accessorCount++; - gltf.accessors[accessorId] = accessor; - return accessorId; +function getMaterial(gltf, materials, materialName, options) { + if (!defined(materialName)) { + // Create a default material if the primitive does not specify one + materialName = 'default'; } - function addIndexArray(array, uint32Indices) { - var buffer = uint32Indices ? array.toUint32Buffer() : array.toUint16Buffer(); - var componentType = uint32Indices ? WebGLConstants.UNSIGNED_INT : WebGLConstants.UNSIGNED_SHORT; - var length = array.length; - var minMax = array.getMinMax(1); - var accessor = { - bufferView : indexBufferViewId, - byteOffset : indexBufferByteOffset, - byteStride : 0, - componentType : componentType, - count : length, - min : minMax.min, - max : minMax.max, - type : 'SCALAR' - }; - - indexBufferByteOffset += buffer.length; - indexBuffers.push(buffer); - - var accessorId = 'accessor_' + accessorCount++; - gltf.accessors[accessorId] = accessor; - return accessorId; + var i; + var material; + var materialsLength = materials.length; + for (i = 0; i < materialsLength; ++i) { + if (materials[i].name === materialName) { + material = materials[i]; + break; + } } - function requiresUint32Indices(nodes) { - var nodesLength = nodes.length; - for (var i = 0; i < nodesLength; ++i) { - var meshes = nodes[i].meshes; - var meshesLength = meshes.length; - for (var j = 0; j < meshesLength; ++j) { - // Reserve the 65535 index for primitive restart - var vertexCount = meshes[j].positions.length / 3; - if (vertexCount > 65534) { - return true; - } - } + if (!defined(material)) { + material = getDefaultMaterial(options); + material.name = materialName; + } + + var materialIndex; + materialsLength = gltf.materials.length; + for (i = 0; i < materialsLength; ++i) { + if (gltf.materials[i].name === materialName) { + materialIndex = i; + break; } - return false; } - var uint32Indices = requiresUint32Indices(nodes); - var gltfSceneNodes = gltf.scenes[sceneId].nodes; + if (!defined(materialIndex)) { + materialIndex = addMaterial(gltf, material); + } + + return materialIndex; +} + +function addVertexAttribute(gltf, array, components, name) { + var count = array.length / components; + var minMax = array.getMinMax(components); + var type = (components === 3 ? 'VEC3' : 'VEC2'); + + var accessor = { + name : name, + componentType : WebGLConstants.FLOAT, + count : count, + min : minMax.min, + max : minMax.max, + type : type + }; + + var accessorIndex = gltf.accessors.length; + gltf.accessors.push(accessor); + return accessorIndex; +} + +function addIndexArray(gltf, array, uint32Indices, name) { + var componentType = uint32Indices ? WebGLConstants.UNSIGNED_INT : WebGLConstants.UNSIGNED_SHORT; + var count = array.length; + var minMax = array.getMinMax(1); + + var accessor = { + name : name, + componentType : componentType, + count : count, + min : minMax.min, + max : minMax.max, + type : 'SCALAR' + }; + + var accessorIndex = gltf.accessors.length; + gltf.accessors.push(accessor); + return accessorIndex; +} + +function requiresUint32Indices(nodes) { var nodesLength = nodes.length; for (var i = 0; i < nodesLength; ++i) { - // Add node - var node = nodes[i]; - var nodeId = node.name; - gltfSceneNodes.push(nodeId); - var gltfNodeMeshes = []; - gltf.nodes[nodeId] = { - name : nodeId, - meshes : gltfNodeMeshes - }; - - // Add meshes to node - var meshes = node.meshes; + var meshes = nodes[i].meshes; var meshesLength = meshes.length; for (var j = 0; j < meshesLength; ++j) { - var mesh = meshes[j]; - var meshId = mesh.name; - gltfNodeMeshes.push(meshId); - - var hasPositions = mesh.positions.length > 0; - var hasNormals = mesh.normals.length > 0; - var hasUVs = mesh.uvs.length > 0; - - var attributes = {}; - if (hasPositions) { - attributes.POSITION = addVertexAttribute(mesh.positions, 3); - } - if (hasNormals) { - attributes.NORMAL = addVertexAttribute(mesh.normals, 3); - } - if (hasUVs) { - attributes.TEXCOORD_0 = addVertexAttribute(mesh.uvs, 2); - } - - // Unload resources - mesh.positions = undefined; - mesh.normals = undefined; - mesh.uvs = undefined; - - var gltfMeshPrimitives = []; - gltf.meshes[meshId] = { - name : meshId, - primitives : gltfMeshPrimitives - }; - - // Add primitives to mesh - var primitives = mesh.primitives; - var primitivesLength = primitives.length; - for (var k = 0; k < primitivesLength; ++k) { - var primitive = primitives[k]; - var indexAccessorId = addIndexArray(primitive.indices, uint32Indices); - primitive.indices = undefined; // Unload resources - var materialId = primitive.material; - - if (!defined(materialId)) { - // Create a default material if the primitive does not specify one - materialId = 'default'; - } - - var material = materials[materialId]; - material = defined(material) ? material : new Material(); - var gltfMaterial = gltf.materials[materialId]; - if (defined(gltfMaterial)) { - // Check if this material has already been added but with incompatible shading - var normalShading = (gltfMaterial.extensions.KHR_materials_common.technique !== 'CONSTANT'); - if (hasNormals !== normalShading) { - materialId += (hasNormals ? '_shaded' : '_constant'); - gltfMaterial = gltf.materials[materialId]; - } - } - - if (!defined(gltfMaterial)) { - gltf.materials[materialId] = createMaterial(material, hasNormals, options); - } - - gltfMeshPrimitives.push({ - attributes : attributes, - indices : indexAccessorId, - material : materialId, - mode : WebGLConstants.TRIANGLES - }); + // Reserve the 65535 index for primitive restart + var vertexCount = meshes[j].positions.length / 3; + if (vertexCount > 65534) { + return true; } } } + return false; +} - var buffers = []; - buffers = buffers.concat(vertexBuffers, indexBuffers); - var buffer = Buffer.concat(buffers); +function addMesh(gltf, materials, bufferState, uint32Indices, mesh, options) { + var hasPositions = mesh.positions.length > 0; + var hasNormals = mesh.normals.length > 0; + var hasUVs = mesh.uvs.length > 0; + + // Vertex attributes are shared by all primitives in the mesh + var accessorIndex; + var attributes = {}; + if (hasPositions) { + accessorIndex = addVertexAttribute(gltf, mesh.positions, 3, mesh.name + '_positions'); + attributes.POSITION = accessorIndex; + bufferState.positionBuffers.push(mesh.positions.toFloatBuffer()); + bufferState.positionAccessors.push(accessorIndex); + } + if (hasNormals) { + accessorIndex = addVertexAttribute(gltf, mesh.normals, 3, mesh.name + '_normals'); + attributes.NORMAL = accessorIndex; + bufferState.normalBuffers.push(mesh.normals.toFloatBuffer()); + bufferState.normalAccessors.push(accessorIndex); + } + if (hasUVs) { + accessorIndex = addVertexAttribute(gltf, mesh.uvs, 2, mesh.name + '_texcoords'); + attributes.TEXCOORD_0 = accessorIndex; + bufferState.uvBuffers.push(mesh.uvs.toFloatBuffer()); + bufferState.uvAccessors.push(accessorIndex); + } - gltf.buffers[bufferId] = { - byteLength : buffer.byteLength, - extras : { - _obj2gltf : { - source : buffer - } - } - }; + // Unload resources + mesh.positions = undefined; + mesh.normals = undefined; + mesh.uvs = undefined; + + var gltfPrimitives = []; + var primitives = mesh.primitives; + var primitivesLength = primitives.length; + for (var i = 0; i < primitivesLength; ++i) { + var primitive = primitives[i]; + var indexAccessorIndex = addIndexArray(gltf, primitive.indices, uint32Indices, mesh.name + '_' + i + '_indices'); + var indexBuffer = uint32Indices ? primitive.indices.toUint32Buffer() : primitive.indices.toUint16Buffer(); + bufferState.indexBuffers.push(indexBuffer); + bufferState.indexAccessors.push(indexAccessorIndex); + + primitive.indices = undefined; // Unload resources + + var materialIndex = getMaterial(gltf, materials, primitive.material, options); + + gltfPrimitives.push({ + attributes : attributes, + indices : indexAccessorIndex, + material : materialIndex, + mode : WebGLConstants.TRIANGLES + }); + } - gltf.bufferViews[vertexBufferViewId] = { - buffer : bufferId, - byteLength : vertexBufferByteOffset, - byteOffset : 0, - target : WebGLConstants.ARRAY_BUFFER + var gltfMesh = { + name : mesh.name, + primitives : gltfPrimitives }; - gltf.bufferViews[indexBufferViewId] = { - buffer : bufferId, - byteLength : indexBufferByteOffset, - byteOffset : vertexBufferByteOffset, - target : WebGLConstants.ELEMENT_ARRAY_BUFFER + var meshIndex = gltf.meshes.length; + gltf.meshes.push(gltfMesh); + return meshIndex; +} + +function addNode(gltf, name, meshIndex, parentIndex) { + var node = { + name : name, + mesh : meshIndex }; - return gltf; + var nodeIndex = gltf.nodes.length; + gltf.nodes.push(node); + + if (defined(parentIndex)) { + var parentNode = gltf.nodes[parentIndex]; + if (!defined(parentNode.children)) { + parentNode.children = []; + } + parentNode.children.push(nodeIndex); + } else { + gltf.scenes[gltf.scene].nodes.push(nodeIndex); + } + + return nodeIndex; } diff --git a/lib/getBufferPadded.js b/lib/getBufferPadded.js new file mode 100644 index 00000000..9b5b6455 --- /dev/null +++ b/lib/getBufferPadded.js @@ -0,0 +1,22 @@ +'use strict'; +module.exports = getBufferPadded; + +/** + * Pad the buffer to the next 4-byte boundary to ensure proper alignment for the section that follows. + * + * @param {Buffer} buffer The buffer. + * @returns {Buffer} The padded buffer. + * + * @private + */ +function getBufferPadded(buffer) { + var boundary = 4; + var byteLength = buffer.length; + var remainder = byteLength % boundary; + if (remainder === 0) { + return buffer; + } + var padding = (remainder === 0) ? 0 : boundary - remainder; + var emptyBuffer = Buffer.alloc(padding); + return Buffer.concat([buffer, emptyBuffer]); +} diff --git a/lib/getJsonBufferPadded.js b/lib/getJsonBufferPadded.js new file mode 100644 index 00000000..f51b4c3d --- /dev/null +++ b/lib/getJsonBufferPadded.js @@ -0,0 +1,29 @@ +'use strict'; +module.exports = getJsonBufferPadded; + +/** + * Convert the JSON object to a padded buffer. + * + * Pad the JSON with extra whitespace to fit the next 4-byte boundary. This ensures proper alignment + * for the section that follows. + * + * @param {Object} [json] The JSON object. + * @returns {Buffer} The padded JSON buffer. + * + * @private + */ +function getJsonBufferPadded(json) { + var string = JSON.stringify(json); + + var boundary = 4; + var byteLength = Buffer.byteLength(string); + var remainder = byteLength % boundary; + var padding = (remainder === 0) ? 0 : boundary - remainder; + var whitespace = ''; + for (var i = 0; i < padding; ++i) { + whitespace += ' '; + } + string += whitespace; + + return Buffer.from(string); +} diff --git a/lib/gltfToGlb.js b/lib/gltfToGlb.js new file mode 100644 index 00000000..85888b21 --- /dev/null +++ b/lib/gltfToGlb.js @@ -0,0 +1,61 @@ +'use strict'; +var Cesium = require('cesium'); +var getJsonBufferPadded = require('./getJsonBufferPadded'); + +var defined = Cesium.defined; + +module.exports = gltfToGlb; + +/** + * Convert a glTF to binary glTF. + * + * The glTF is expected to have a single buffer and all embedded resources stored in bufferViews. + * + * @param {Object} gltf The glTF asset. + * @param {Buffer} binaryBuffer The binary buffer. + * @returns {Buffer} The glb buffer. + * + * @private + */ +function gltfToGlb(gltf, binaryBuffer) { + var buffer = gltf.buffers[0]; + if (defined(buffer.uri)) { + binaryBuffer = Buffer.alloc(0); + } + + // Create padded binary scene string + var jsonBuffer = getJsonBufferPadded(gltf); + + // Allocate buffer (Global header) + (JSON chunk header) + (JSON chunk) + (Binary chunk header) + (Binary chunk) + var glbLength = 12 + 8 + jsonBuffer.length + 8 + binaryBuffer.length; + var glb = Buffer.alloc(glbLength); + + // Write binary glTF header (magic, version, length) + var byteOffset = 0; + glb.writeUInt32LE(0x46546C67, byteOffset); + byteOffset += 4; + glb.writeUInt32LE(2, byteOffset); + byteOffset += 4; + glb.writeUInt32LE(glbLength, byteOffset); + byteOffset += 4; + + // Write JSON Chunk header (length, type) + glb.writeUInt32LE(jsonBuffer.length, byteOffset); + byteOffset += 4; + glb.writeUInt32LE(0x4E4F534A, byteOffset); // JSON + byteOffset += 4; + + // Write JSON Chunk + jsonBuffer.copy(glb, byteOffset); + byteOffset += jsonBuffer.length; + + // Write Binary Chunk header (length, type) + glb.writeUInt32LE(binaryBuffer.length, byteOffset); + byteOffset += 4; + glb.writeUInt32LE(0x004E4942, byteOffset); // BIN + byteOffset += 4; + + // Write Binary Chunk + binaryBuffer.copy(glb, byteOffset); + return glb; +} diff --git a/lib/loadImage.js b/lib/loadImage.js deleted file mode 100644 index c238a273..00000000 --- a/lib/loadImage.js +++ /dev/null @@ -1,107 +0,0 @@ -'use strict'; -var Cesium = require('cesium'); -var fsExtra = require('fs-extra'); -var path = require('path'); -var PNG = require('pngjs').PNG; -var Promise = require('bluebird'); - -var defined = Cesium.defined; -var WebGLConstants = Cesium.WebGLConstants; - -module.exports = loadImage; - -/** - * Load an image file and get information about it. - * - * @param {String} imagePath Path to the image file. - * @param {Object} options An object with the following properties: - * @param {Boolean} options.checkTransparency Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. - * @returns {Promise} A promise resolving to the image information, or undefined if the file doesn't exist. - * - * @private - */ -function loadImage(imagePath, options) { - return fsExtra.readFile(imagePath) - .then(function(data) { - var extension = path.extname(imagePath).toLowerCase(); - - var info = { - transparent : false, - format : getFormat(3), - source : data, - extension : extension - }; - - if (extension === '.png') { - return getPngInfo(data, info, options); - } - - return info; - }); -} - -function getPngInfo(data, info, options) { - // Color type is encoded in the 25th bit of the png - var colorType = data[25]; - var channels = getChannels(colorType); - info.format = getFormat(channels); - - if (channels === 4) { - if (options.checkTransparency) { - return isTransparent(data) - .then(function(transparent) { - info.transparent = transparent; - return info; - }); - } - } - return info; -} - -function isTransparent(data) { - return new Promise(function(resolve, reject) { - new PNG().parse(data, function(error, data) { - if (defined(error)) { - reject(error); - return; - } - var pixels = data.data; - var pixelsLength = data.width * data.height; - for (var i = 0; i < pixelsLength; ++i) { - if (pixels[i * 4 + 3] < 255) { - resolve(true); - return; - } - } - resolve(false); - }); - }); -} - -function getChannels(colorType) { - switch (colorType) { - case 0: // greyscale - return 1; - case 2: // RGB - return 3; - case 4: // greyscale + alpha - return 2; - case 6: // RGB + alpha - return 4; - default: - return 3; - } -} - -function getFormat(channels) { - switch (channels) { - case 1: - return WebGLConstants.ALPHA; - case 2: - return WebGLConstants.LUMINANCE_ALPHA; - case 3: - return WebGLConstants.RGB; - case 4: - return WebGLConstants.RGBA; - } -} diff --git a/lib/loadMtl.js b/lib/loadMtl.js index 5b5c72bb..bc10fbaa 100644 --- a/lib/loadMtl.js +++ b/lib/loadMtl.js @@ -1,31 +1,86 @@ 'use strict'; +var Cesium = require('cesium'); var path = require('path'); -var Material = require('./Material'); +var Promise = require('bluebird'); +var loadTexture = require('./loadTexture'); +var outsideDirectory = require('./outsideDirectory'); var readLines = require('./readLines'); +var Texture = require('./Texture'); + +var CesiumMath = Cesium.Math; +var combine = Cesium.combine; +var defaultValue = Cesium.defaultValue; +var defined = Cesium.defined; module.exports = loadMtl; /** - * Parse an mtl file. + * Parse a .mtl file and load textures referenced within. Returns an array of glTF materials with Texture + * objects stored in the texture slots. + *

+ * Packed PBR textures (like metallicRoughnessOcclusion and specularGlossiness) require all input textures to be decoded before hand. + * If a texture is of an unsupported format like .gif or .tga it can't be packed and a metallicRoughness texture will not be created. + * Similarly if a texture cannot be found it will be ignored and a default value will be used instead. + *

* - * @param {String} mtlPath Path to the mtl file. - * @returns {Promise} A promise resolving to the materials. + * @param {String} mtlPath Path to the .mtl file. + * @param {Object} options The options object passed along from lib/obj2gltf.js + * @returns {Promise} A promise resolving to an array of glTF materials with Texture objects stored in the texture slots. * * @private */ -function loadMtl(mtlPath) { +function loadMtl(mtlPath, options) { var material; var values; var value; + var texturePath; + var mtlDirectory = path.dirname(mtlPath); - var materials = {}; + var materials = []; + var texturePromiseMap = {}; // Maps texture paths to load promises so that no texture is loaded twice + var texturePromises = []; + + var overridingTextures = options.overridingTextures; + var overridingSpecularTexture = defaultValue(overridingTextures.metallicRoughnessOcclusionTexture, overridingTextures.specularGlossinessTexture); + var overridingSpecularShininessTexture = defaultValue(overridingTextures.metallicRoughnessOcclusionTexture, overridingTextures.specularGlossinessTexture); + var overridingAmbientTexture = defaultValue(overridingTextures.metallicRoughnessOcclusionTexture, overridingTextures.occlusionTexture); + var overridingNormalTexture = overridingTextures.normalTexture; + var overridingDiffuseTexture = overridingTextures.baseColorTexture; + var overridingEmissiveTexture = overridingTextures.emissiveTexture; + + // Textures that are packed into PBR textures need to be decoded first + var decodeOptions = options.materialsCommon ? undefined : { + decode : true + }; + + var diffuseTextureOptions = { + checkTransparency : options.checkTransparency + }; + + var ambientTextureOptions = options.packOcclusion ? decodeOptions : undefined; + var specularTextureOptions = decodeOptions; + var specularShinessTextureOptions = decodeOptions; + var emissiveTextureOptions; + var normalTextureOptions; + + function createMaterial(name) { + material = new Material(); + material.name = name; + material.specularShininess = options.metallicRoughness ? 1.0 : 0.0; + loadMaterialTexture(material, 'specularTexture', overridingSpecularTexture, undefined, mtlDirectory, texturePromiseMap, texturePromises, options); + loadMaterialTexture(material, 'specularShininessTexture', overridingSpecularShininessTexture, undefined, mtlDirectory, texturePromiseMap, texturePromises, options); + loadMaterialTexture(material, 'ambientTexture', overridingAmbientTexture, undefined, mtlDirectory, texturePromiseMap, texturePromises, options); + loadMaterialTexture(material, 'normalTexture', overridingNormalTexture, undefined, mtlDirectory, texturePromiseMap, texturePromises, options); + loadMaterialTexture(material, 'diffuseTexture', overridingDiffuseTexture, diffuseTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + loadMaterialTexture(material, 'emissiveTexture', overridingEmissiveTexture, undefined, mtlDirectory, texturePromiseMap, texturePromises, options); + materials.push(material); + } function parseLine(line) { line = line.trim(); if (/^newmtl /i.test(line)) { var name = line.substring(7).trim(); - material = new Material(); - materials[name] = material; + createMaterial(name); } else if (/^Ka /i.test(line)) { values = line.substring(3).trim().split(' '); material.ambientColor = [ @@ -36,7 +91,7 @@ function loadMtl(mtlPath) { ]; } else if (/^Ke /i.test(line)) { values = line.substring(3).trim().split(' '); - material.emissionColor = [ + material.emissiveColor = [ parseFloat(values[0]), parseFloat(values[1]), parseFloat(values[2]), @@ -68,24 +123,536 @@ function loadMtl(mtlPath) { value = line.substring(3).trim(); material.alpha = 1.0 - parseFloat(value); } else if (/^map_Ka /i.test(line)) { - material.ambientTexture = path.resolve(mtlDirectory, line.substring(7).trim()); + if (!defined(overridingAmbientTexture)) { + texturePath = path.resolve(mtlDirectory, line.substring(7).trim()); + loadMaterialTexture(material, 'ambientTexture', texturePath, ambientTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + } } else if (/^map_Ke /i.test(line)) { - material.emissionTexture = path.resolve(mtlDirectory, line.substring(7).trim()); + if (!defined(overridingEmissiveTexture)) { + texturePath = path.resolve(mtlDirectory, line.substring(7).trim()); + loadMaterialTexture(material, 'emissiveTexture', texturePath, emissiveTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + } } else if (/^map_Kd /i.test(line)) { - material.diffuseTexture = path.resolve(mtlDirectory, line.substring(7).trim()); + if (!defined(overridingDiffuseTexture)) { + texturePath = path.resolve(mtlDirectory, line.substring(7).trim()); + loadMaterialTexture(material, 'diffuseTexture', texturePath, diffuseTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + } } else if (/^map_Ks /i.test(line)) { - material.specularTexture = path.resolve(mtlDirectory, line.substring(7).trim()); + if (!defined(overridingSpecularTexture)) { + texturePath = path.resolve(mtlDirectory, line.substring(7).trim()); + loadMaterialTexture(material, 'specularTexture', texturePath, specularTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + } } else if (/^map_Ns /i.test(line)) { - material.specularShininessMap = path.resolve(mtlDirectory, line.substring(7).trim()); + if (!defined(overridingSpecularShininessTexture)) { + texturePath = path.resolve(mtlDirectory, line.substring(7).trim()); + loadMaterialTexture(material, 'specularShininessTexture', texturePath, specularShinessTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + } } else if (/^map_Bump /i.test(line)) { - material.normalMap = path.resolve(mtlDirectory, line.substring(9).trim()); - } else if (/^map_d /i.test(line)) { - material.alphaMap = path.resolve(mtlDirectory, line.substring(6).trim()); + if (!defined(overridingNormalTexture)) { + texturePath = path.resolve(mtlDirectory, line.substring(9).trim()); + loadMaterialTexture(material, 'normalTexture', texturePath, normalTextureOptions, mtlDirectory, texturePromiseMap, texturePromises, options); + } } } return readLines(mtlPath, parseLine) .then(function() { - return materials; + return Promise.all(texturePromises); + }) + .then(function() { + return convertMaterials(materials, options); }); } + +function Material() { + this.name = undefined; + this.ambientColor = [0.0, 0.0, 0.0, 1.0]; // Ka + this.emissiveColor = [0.0, 0.0, 0.0, 1.0]; // Ke + this.diffuseColor = [0.5, 0.5, 0.5, 1.0]; // Kd + this.specularColor = [0.0, 0.0, 0.0, 1.0]; // Ks + this.specularShininess = 0.0; // Ns + this.alpha = 1.0; // d / Tr + this.ambientTexture = undefined; // map_Ka + this.emissiveTexture = undefined; // map_Ke + this.diffuseTexture = undefined; // map_Kd + this.specularTexture = undefined; // map_Ks + this.specularShininessTexture = undefined; // map_Ns + this.normalTexture = undefined; // map_Bump +} + +loadMtl.getDefaultMaterial = function(options) { + return convertMaterial(new Material(), options); +}; + +// Exposed for testing +loadMtl._createMaterial = function(materialOptions, options) { + return convertMaterial(combine(materialOptions, new Material()), options); +}; + +function loadMaterialTexture(material, name, texturePath, textureOptions, mtlDirectory, texturePromiseMap, texturePromises, options) { + if (!defined(texturePath)) { + return; + } + var texturePromise = texturePromiseMap[texturePath]; + if (!defined(texturePromise)) { + if (options.secure && outsideDirectory(texturePath, mtlDirectory)) { + options.logger('Could not read texture file at ' + texturePath + ' because it is outside of the mtl directory and the secure flag is true. This texture will be ignored.'); + texturePromise = Promise.resolve(); + } else { + texturePromise = loadTexture(texturePath, textureOptions) + .catch(function() { + options.logger('Could not read texture file at ' + texturePath + '. This texture will be ignored.'); + }); + } + texturePromiseMap[texturePath] = texturePromise; + } + + texturePromises.push(texturePromise + .then(function(texture) { + material[name] = texture; + })); +} + +function convertMaterial(material, options) { + if (options.specularGlossiness) { + return createSpecularGlossinessMaterial(material, options); + } else if (options.metallicRoughness) { + return createMetallicRoughnessMaterial(material, options); + } else if (options.materialsCommon) { + return createMaterialsCommonMaterial(material); + } + + // No material type specified, convert the material to metallic roughness + convertTraditionalToMetallicRoughness(material); + return createMetallicRoughnessMaterial(material, options); +} + +function convertMaterials(materials, options) { + return materials.map(function(material) { + return convertMaterial(material, options); + }); +} + +function resizeChannel(sourcePixels, sourceWidth, sourceHeight, targetPixels, targetWidth, targetHeight) { + // Nearest neighbor sampling + var widthRatio = sourceWidth / targetWidth; + var heightRatio = sourceHeight / targetHeight; + + for (var y = 0; y < targetHeight; ++y) { + for (var x = 0; x < targetWidth; ++x) { + var targetIndex = y * targetWidth + x; + var sourceY = Math.round(y * heightRatio); + var sourceX = Math.round(x * widthRatio); + var sourceIndex = sourceY * sourceWidth + sourceX; + var sourceValue = sourcePixels.readUInt8(sourceIndex); + targetPixels.writeUInt8(sourceValue, targetIndex); + } + } + return targetPixels; +} + +var scratchResizeChannel; + +function getTextureChannel(texture, index, targetWidth, targetHeight, targetChannel) { + var pixels = texture.pixels; // RGBA + var sourceWidth = texture.width; + var sourceHeight = texture.height; + var sourcePixelsLength = sourceWidth * sourceHeight; + var targetPixelsLength = targetWidth * targetHeight; + + // Allocate the scratchResizeChannel on demand if the texture needs to be resized + var sourceChannel = targetChannel; + if (sourcePixelsLength > targetPixelsLength) { + if (!defined(scratchResizeChannel) || (sourcePixelsLength > scratchResizeChannel.length)) { + scratchResizeChannel = Buffer.alloc(sourcePixelsLength); + } + sourceChannel = scratchResizeChannel; + } + + for (var i = 0; i < sourcePixelsLength; ++i) { + var value = pixels.readUInt8(i * 4 + index); + sourceChannel.writeUInt8(value, i); + } + + if (sourcePixelsLength > targetPixelsLength) { + resizeChannel(sourceChannel, sourceWidth, sourceHeight, targetChannel, targetWidth, targetHeight); + } + + return targetChannel; +} + +function writeChannel(pixels, channel, index) { + var pixelsLength = pixels.length / 4; + for (var i = 0; i < pixelsLength; ++i) { + var value = channel.readUInt8(i); + pixels.writeUInt8(value, i * 4 + index); + } +} + +function getMinimumDimensions(textures, options) { + var i; + var texture; + var width = Number.POSITIVE_INFINITY; + var height = Number.POSITIVE_INFINITY; + + var length = textures.length; + for (i = 0; i < length; ++i) { + texture = textures[i]; + width = Math.min(texture.width, width); + height = Math.min(texture.height, height); + } + + for (i = 0; i < length; ++i) { + texture = textures[i]; + if (texture.width !== width || texture.height !== height) { + options.logger('Texture ' + texture.path + ' will be scaled from ' + texture.width + 'x' + texture.height + ' to ' + width + 'x' + height + '.'); + } + } + + return [width, height]; +} + +function createMetallicRoughnessTexture(metallicTexture, roughnessTexture, occlusionTexture, options) { + if (defined(options.overridingTextures.metallicRoughnessOcclusionTexture)) { + return metallicTexture; + } + + var packMetallic = defined(metallicTexture); + var packRoughness = defined(roughnessTexture); + var packOcclusion = defined(occlusionTexture) && options.packOcclusion; + + if (!packMetallic && !packRoughness) { + return undefined; + } + + if (packMetallic && !defined(metallicTexture.pixels)) { + options.logger('Could not get decoded texture data for ' + metallicTexture.path + '. The material will be created without a metallicRoughness texture.'); + return undefined; + } + + if (packRoughness && !defined(roughnessTexture.pixels)) { + options.logger('Could not get decoded texture data for ' + roughnessTexture.path + '. The material will be created without a metallicRoughness texture.'); + return undefined; + } + + if (packOcclusion && !defined(occlusionTexture.pixels)) { + options.logger('Could not get decoded texture data for ' + occlusionTexture.path + '. The occlusion texture will not be packed in the metallicRoughness texture.'); + return undefined; + } + + var packedTextures = [metallicTexture, roughnessTexture, occlusionTexture].filter(function(texture) { + return defined(texture) && defined(texture.pixels); + }); + + var dimensions = getMinimumDimensions(packedTextures, options); + var width = dimensions[0]; + var height = dimensions[1]; + var pixelsLength = width * height; + var pixels = Buffer.alloc(pixelsLength * 4, 0xFF); // Initialize with 4 channels, unused channels will be white + var scratchChannel = Buffer.alloc(pixelsLength); + + if (packMetallic) { + // Write into the B channel + var metallicChannel = getTextureChannel(metallicTexture, 0, width, height, scratchChannel); + writeChannel(pixels, metallicChannel, 2); + } + + if (packRoughness) { + // Write into the G channel + var roughnessChannel = getTextureChannel(roughnessTexture, 0, width, height, scratchChannel); + writeChannel(pixels, roughnessChannel, 1); + } + + if (packOcclusion) { + // Write into the R channel + var occlusionChannel = getTextureChannel(occlusionTexture, 0, width, height, scratchChannel); + writeChannel(pixels, occlusionChannel, 0); + } + + var length = packedTextures.length; + var names = new Array(length); + for (var i = 0; i < length; ++i) { + names[i] = packedTextures[i].name; + } + var name = names.join('_'); + + var texture = new Texture(); + texture.name = name; + texture.extension = '.png'; + texture.pixels = pixels; + texture.width = width; + texture.height = height; + + return texture; +} + +function createSpecularGlossinessTexture(specularTexture, glossinessTexture, options) { + if (defined(options.overridingTextures.specularGlossinessTexture)) { + return specularTexture; + } + + var packSpecular = defined(specularTexture); + var packGlossiness = defined(glossinessTexture); + + if (!packSpecular && !packGlossiness) { + return undefined; + } + + if (packSpecular && !defined(specularTexture.pixels)) { + options.logger('Could not get decoded texture data for ' + specularTexture.path + '. The material will be created without a specularGlossiness texture.'); + return undefined; + } + + if (packGlossiness && !defined(glossinessTexture.pixels)) { + options.logger('Could not get decoded texture data for ' + glossinessTexture.path + '. The material will be created without a specularGlossiness texture.'); + return undefined; + } + + var packedTextures = [specularTexture, glossinessTexture].filter(function(texture) { + return defined(texture) && defined(texture.pixels); + }); + + var dimensions = getMinimumDimensions(packedTextures, options); + var width = dimensions[0]; + var height = dimensions[1]; + var pixelsLength = width * height; + var pixels = Buffer.alloc(pixelsLength * 4, 0xFF); // Initialize with 4 channels, unused channels will be white + var scratchChannel = Buffer.alloc(pixelsLength); + + if (packSpecular) { + // Write into the R, G, B channels + var redChannel = getTextureChannel(specularTexture, 0, width, height, scratchChannel); + writeChannel(pixels, redChannel, 0); + var greenChannel = getTextureChannel(specularTexture, 1, width, height, scratchChannel); + writeChannel(pixels, greenChannel, 1); + var blueChannel = getTextureChannel(specularTexture, 2, width, height, scratchChannel); + writeChannel(pixels, blueChannel, 2); + } + + if (packGlossiness) { + // Write into the A channel + var glossinessChannel = getTextureChannel(glossinessTexture, 0, width, height, scratchChannel); + writeChannel(pixels, glossinessChannel, 3); + } + + var length = packedTextures.length; + var names = new Array(length); + for (var i = 0; i < length; ++i) { + names[i] = packedTextures[i].name; + } + var name = names.join('_'); + + var texture = new Texture(); + texture.name = name; + texture.extension = '.png'; + texture.pixels = pixels; + texture.width = width; + texture.height = height; + + return texture; +} + +function createSpecularGlossinessMaterial(material, options) { + var emissiveTexture = material.emissiveTexture; + var normalTexture = material.normalTexture; + var occlusionTexture = material.ambientTexture; + var diffuseTexture = material.diffuseTexture; + var specularTexture = material.specularTexture; + var glossinessTexture = material.specularShininessTexture; + var specularGlossinessTexture = createSpecularGlossinessTexture(specularTexture, glossinessTexture, options); + + var emissiveFactor = material.emissiveColor.slice(0, 3); + var diffuseFactor = material.diffuseColor; + var specularFactor = material.specularColor.slice(0, 3); + var glossinessFactor = material.specularShininess; + + if (defined(emissiveTexture)) { + emissiveFactor = [1.0, 1.0, 1.0]; + } + + if (defined(diffuseTexture)) { + diffuseFactor = [1.0, 1.0, 1.0, 1.0]; + } + + if (defined(specularTexture)) { + specularFactor = [1.0, 1.0, 1.0]; + } + + if (defined(glossinessTexture)) { + glossinessFactor = 1.0; + } + + var alpha = material.alpha; + diffuseFactor[3] = alpha; + + var transparent = alpha < 1.0; + if (defined(diffuseTexture)) { + transparent = transparent || diffuseTexture.transparent; + } + + var doubleSided = transparent; + var alphaMode = transparent ? 'BLEND' : 'OPAQUE'; + + return { + name : material.name, + extensions : { + KHR_materials_pbrSpecularGlossiness: { + diffuseTexture : diffuseTexture, + specularGlossinessTexture : specularGlossinessTexture, + diffuseFactor : diffuseFactor, + specularFactor : specularFactor, + glossinessFactor : glossinessFactor + } + }, + emissiveTexture : emissiveTexture, + normalTexture : normalTexture, + occlusionTexture : occlusionTexture, + emissiveFactor : emissiveFactor, + alphaMode : alphaMode, + doubleSided : doubleSided + }; +} + +function createMetallicRoughnessMaterial(material, options) { + var emissiveTexture = material.emissiveTexture; + var normalTexture = material.normalTexture; + var occlusionTexture = material.ambientTexture; + var baseColorTexture = material.diffuseTexture; + var metallicTexture = material.specularTexture; + var roughnessTexture = material.specularShininessTexture; + var metallicRoughnessTexture = createMetallicRoughnessTexture(metallicTexture, roughnessTexture, occlusionTexture, options); + + if (options.packOcclusion) { + occlusionTexture = metallicRoughnessTexture; + } + + var emissiveFactor = material.emissiveColor.slice(0, 3); + var baseColorFactor = material.diffuseColor; + var metallicFactor = material.specularColor[0]; + var roughnessFactor = material.specularShininess; + + if (defined(emissiveTexture)) { + emissiveFactor = [1.0, 1.0, 1.0]; + } + + if (defined(baseColorTexture)) { + baseColorFactor = [1.0, 1.0, 1.0, 1.0]; + } + + if (defined(metallicTexture)) { + metallicFactor = 1.0; + } + + if (defined(roughnessTexture)) { + roughnessFactor = 1.0; + } + + var alpha = material.alpha; + baseColorFactor[3] = alpha; + + var transparent = alpha < 1.0; + if (defined(baseColorTexture)) { + transparent = transparent || baseColorTexture.transparent; + } + + var doubleSided = transparent; + var alphaMode = transparent ? 'BLEND' : 'OPAQUE'; + + return { + name : material.name, + pbrMetallicRoughness : { + baseColorTexture : baseColorTexture, + metallicRoughnessTexture : metallicRoughnessTexture, + baseColorFactor : baseColorFactor, + metallicFactor : metallicFactor, + roughnessFactor : roughnessFactor + }, + emissiveTexture : emissiveTexture, + normalTexture : normalTexture, + occlusionTexture : occlusionTexture, + emissiveFactor : emissiveFactor, + alphaMode : alphaMode, + doubleSided : doubleSided + }; +} + +function luminance(color) { + return color[0] * 0.2125 + color[1] * 0.7154 + color[2] * 0.0721; +} + +function convertTraditionalToMetallicRoughness(material) { + // Translate the blinn-phong model to the pbr metallic-roughness model + // Roughness factor is a combination of specular intensity and shininess + // Metallic factor is 0.0 + // Textures are not converted for now + var specularIntensity = luminance(material.specularColor); + + // Transform from 0-1000 range to 0-1 range. Then invert. + var roughnessFactor = material.specularShininess; + roughnessFactor = roughnessFactor / 1000.0; + roughnessFactor = 1.0 - roughnessFactor; + roughnessFactor = CesiumMath.clamp(roughnessFactor, 0.0, 1.0); + + // Low specular intensity values should produce a rough material even if shininess is high. + if (specularIntensity < 0.1) { + roughnessFactor *= (1.0 - specularIntensity); + } + + var metallicFactor = 0.0; + + material.specularColor = [metallicFactor, metallicFactor, metallicFactor, 1.0]; + material.specularShininess = roughnessFactor; +} + +function createMaterialsCommonMaterial(material) { + var ambient = defaultValue(material.ambientTexture, material.ambientColor); + var diffuse = defaultValue(material.diffuseTexture, material.diffuseColor); + var emission = defaultValue(material.emissiveTexture, material.emissiveColor); + var specular = defaultValue(material.specularTexture, material.specularColor); + + var alpha = material.alpha; + var shininess = material.specularShininess; + var hasSpecular = (shininess > 0.0) && (defined(material.specularTexture) || (specular[0] > 0.0 || specular[1] > 0.0 || specular[2] > 0.0)); + + var transparent; + var transparency = 1.0; + if (defined(material.diffuseTexture)) { + transparency = alpha; + transparent = material.diffuseTexture.transparent || (transparency < 1.0); + } else { + diffuse[3] = alpha; + transparent = alpha < 1.0; + } + + if (!defined(material.ambientTexture)) { + // If ambient color is [1, 1, 1] assume it is a multiplier and instead change to [0, 0, 0] + if (ambient[0] === 1.0 && ambient[1] === 1.0 && ambient[2] === 1.0) { + ambient = [0.0, 0.0, 0.0, 1.0]; + } + } + + var doubleSided = transparent; + + var technique = hasSpecular ? 'PHONG' : 'LAMBERT'; + + return { + name : material.name, + extensions : { + KHR_materials_common : { + technique : technique, + transparent : transparent, + doubleSided : doubleSided, + values : { + ambient : ambient, + diffuse : diffuse, + emission : emission, + specular : specular, + shininess : shininess, + transparency : transparency, + transparent : transparent, + doubleSided : doubleSided + } + } + } + }; +} diff --git a/lib/loadObj.js b/lib/loadObj.js index eb5131b1..2e809bf5 100644 --- a/lib/loadObj.js +++ b/lib/loadObj.js @@ -4,11 +4,10 @@ var path = require('path'); var Promise = require('bluebird'); var ArrayStorage = require('./ArrayStorage'); -var loadImage = require('./loadImage'); var loadMtl = require('./loadMtl'); +var outsideDirectory = require('./outsideDirectory'); var readLines = require('./readLines'); -var Axis = Cesium.Axis; var Cartesian2 = Cesium.Cartesian2; var Cartesian3 = Cesium.Cartesian3; var ComponentDatatype = Cesium.ComponentDatatype; @@ -16,7 +15,6 @@ var defaultValue = Cesium.defaultValue; var defined = Cesium.defined; var IntersectionTests = Cesium.IntersectionTests; var Matrix3 = Cesium.Matrix3; -var Matrix4 = Cesium.Matrix4; var OrientedBoundingBox = Cesium.OrientedBoundingBox; var Plane = Cesium.Plane; var PolygonPipeline = Cesium.PolygonPipeline; @@ -54,26 +52,16 @@ var normalPattern = /vn( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+| var uvPattern = /vt( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+|\-|e|E]+)/; // vt float float var facePattern = /(-?\d+)\/?(-?\d*)\/?(-?\d*)/g; // for any face format "f v", "f v/v", "f v//v", "f v/v/v" -var scratchCartesian = new Cartesian3(); - /** * Parse an obj file. * * @param {String} objPath Path to the obj file. - * @param {Object} options An object with the following properties: - * @param {Boolean} options.checkTransparency Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. - * @param {Boolean} options.secure Prevent the converter from reading image or mtl files outside of the input obj directory. - * @param {String} options.inputUpAxis Up axis of the obj. - * @param {String} options.outputUpAxis Up axis of the converted glTF. - * @param {Boolean} options.logger A callback function for handling logged messages. Defaults to console.log. - * @returns {Promise} A promise resolving to the obj data. - * @exception {RuntimeError} The file does not have any geometry information in it. + * @param {Object} options The options object passed along from lib/obj2gltf.js + * @returns {Promise} A promise resolving to the obj data, which includes an array of nodes containing geometry information and an array of materials. * * @private */ function loadObj(objPath, options) { - var axisTransform = getAxisTransform(options.inputUpAxis, options.outputUpAxis); - // Global store of vertex attributes listed in the obj file var positions = new ArrayStorage(ComponentDatatype.FLOAT); var normals = new ArrayStorage(ComponentDatatype.FLOAT); @@ -452,27 +440,13 @@ function loadObj(objPath, options) { var paths = line.substring(7).trim().split(' '); mtlPaths = mtlPaths.concat(paths); } else if ((result = vertexPattern.exec(line)) !== null) { - var position = scratchCartesian; - position.x = parseFloat(result[1]); - position.y = parseFloat(result[2]); - position.z = parseFloat(result[3]); - if (defined(axisTransform)) { - Matrix4.multiplyByPoint(axisTransform, position, position); - } - positions.push(position.x); - positions.push(position.y); - positions.push(position.z); + positions.push(parseFloat(result[1])); + positions.push(parseFloat(result[2])); + positions.push(parseFloat(result[3])); } else if ((result = normalPattern.exec(line) ) !== null) { - var normal = scratchCartesian; - normal.x = parseFloat(result[1]); - normal.y = parseFloat(result[2]); - normal.z = parseFloat(result[3]); - if (defined(axisTransform)) { - Matrix4.multiplyByPointAsVector(axisTransform, normal, normal); - } - normals.push(normal.x); - normals.push(normal.y); - normals.push(normal.z); + normals.push(parseFloat(result[1])); + normals.push(parseFloat(result[2])); + normals.push(parseFloat(result[3])); } else if ((result = uvPattern.exec(line)) !== null) { uvs.push(parseFloat(result[1])); uvs.push(1.0 - parseFloat(result[2])); // Flip y so 0.0 is the bottom of the image @@ -513,7 +487,7 @@ function loadObj(objPath, options) { normals = undefined; uvs = undefined; - // Load materials and images + // Load materials and textures return finishLoading(nodes, mtlPaths, objPath, options); }); } @@ -521,88 +495,39 @@ function loadObj(objPath, options) { function finishLoading(nodes, mtlPaths, objPath, options) { nodes = cleanNodes(nodes); if (nodes.length === 0) { - return Promise.reject(new RuntimeError(objPath + ' does not have any geometry data')); + throw new RuntimeError(objPath + ' does not have any geometry data'); } - return loadMaterials(mtlPaths, objPath, options) + var name = path.basename(objPath, path.extname(objPath)); + return loadMtls(mtlPaths, objPath, options) .then(function(materials) { - var imagePaths = getImagePaths(materials); - return loadImages(imagePaths, objPath, options) - .then(function(images) { - return { - nodes : nodes, - materials : materials, - images : images - }; - }); + return { + nodes : nodes, + materials : materials, + name : name + }; }); } -function outsideDirectory(filePath, objPath) { - return (path.relative(path.dirname(objPath), filePath).indexOf('..') === 0); -} - -function loadMaterials(mtlPaths, objPath, options) { - var secure = options.secure; - var logger = options.logger; +function loadMtls(mtlPaths, objPath, options) { var objDirectory = path.dirname(objPath); - var materials = {}; + var materials = []; return Promise.map(mtlPaths, function(mtlPath) { mtlPath = path.resolve(objDirectory, mtlPath); - if (secure && outsideDirectory(mtlPath, objPath)) { - logger('Could not read mtl file at ' + mtlPath + ' because it is outside of the obj directory and the secure flag is true. Using default material instead.'); + if (options.secure && outsideDirectory(mtlPath, objDirectory)) { + options.logger('Could not read mtl file at ' + mtlPath + ' because it is outside of the obj directory and the secure flag is true. Using default material instead.'); return; } - return loadMtl(mtlPath) + return loadMtl(mtlPath, options) .then(function(materialsInMtl) { - materials = Object.assign(materials, materialsInMtl); - }) - .catch(function() { - logger('Could not read mtl file at ' + mtlPath + '. Using default material instead.'); - }); - }, {concurrency : 10}) - .thenReturn(materials); -} - -function loadImages(imagePaths, objPath, options) { - var secure = options.secure; - var logger = options.logger; - var images = {}; - return Promise.map(imagePaths, function(imagePath) { - if (secure && outsideDirectory(imagePath, objPath)) { - logger('Could not read image file at ' + imagePath + ' because it is outside of the obj directory and the secure flag is true. Material will ignore this image.'); - return; - } - return loadImage(imagePath, options) - .then(function(image) { - images[imagePath] = image; + materials = materials.concat(materialsInMtl); }) .catch(function() { - logger('Could not read image file at ' + imagePath + '. Material will ignore this image.'); + options.logger('Could not read mtl file at ' + mtlPath + '. Using default material instead.'); }); }, {concurrency : 10}) - .thenReturn(images); -} - -function getImagePaths(materials) { - var imagePaths = {}; - for (var name in materials) { - if (materials.hasOwnProperty(name)) { - var material = materials[name]; - if (defined(material.ambientTexture)) { - imagePaths[material.ambientTexture] = true; - } - if (defined(material.diffuseTexture)) { - imagePaths[material.diffuseTexture] = true; - } - if (defined(material.emissionTexture)) { - imagePaths[material.emissionTexture] = true; - } - if (defined(material.specularTexture)) { - imagePaths[material.specularTexture] = true; - } - } - } - return Object.keys(imagePaths); + .then(function() { + return materials; + }); } function removeEmptyMeshes(meshes) { @@ -684,19 +609,3 @@ function cleanNodes(nodes) { setDefaults(nodes); return nodes; } - -function getAxisTransform(inputUpAxis, outputUpAxis) { - if (inputUpAxis === 'X' && outputUpAxis === 'Y') { - return Axis.X_UP_TO_Y_UP; - } else if (inputUpAxis === 'X' && outputUpAxis === 'Z') { - return Axis.X_UP_TO_Z_UP; - } else if (inputUpAxis === 'Y' && outputUpAxis === 'X') { - return Axis.Y_UP_TO_X_UP; - } else if (inputUpAxis === 'Y' && outputUpAxis === 'Z') { - return Axis.Y_UP_TO_Z_UP; - } else if (inputUpAxis === 'Z' && outputUpAxis === 'X') { - return Axis.Z_UP_TO_X_UP; - } else if (inputUpAxis === 'Z' && outputUpAxis === 'Y') { - return Axis.Z_UP_TO_Y_UP; - } -} diff --git a/lib/loadTexture.js b/lib/loadTexture.js new file mode 100644 index 00000000..13b74648 --- /dev/null +++ b/lib/loadTexture.js @@ -0,0 +1,127 @@ +'use strict'; +var Cesium = require('cesium'); +var fsExtra = require('fs-extra'); +var jpeg = require('jpeg-js'); +var path = require('path'); +var PNG = require('pngjs').PNG; +var Promise = require('bluebird'); +var Texture = require('./Texture'); + +var defaultValue = Cesium.defaultValue; +var defined = Cesium.defined; + +module.exports = loadTexture; + +/** + * Load a texture file. + * + * @param {String} texturePath Path to the texture file. + * @param {Object} [options] An object with the following properties: + * @param {Boolean} [options.checkTransparency=false] Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. + * @param {Boolean} [options.decode=false] Whether to decode the texture. + * @returns {Promise} A promise resolving to a Texture object. + * + * @private + */ +function loadTexture(texturePath, options) { + options = defaultValue(options, {}); + options.checkTransparency = defaultValue(options.checkTransparency, false); + options.decode = defaultValue(options.decode, false); + + return fsExtra.readFile(texturePath) + .then(function(source) { + var name = path.basename(texturePath, path.extname(texturePath)); + var extension = path.extname(texturePath).toLowerCase(); + var texture = new Texture(); + texture.source = source; + texture.name = name; + texture.extension = extension; + texture.path = texturePath; + + var decodePromise; + if (extension === '.png') { + decodePromise = decodePng(texture, options); + } else if (extension === '.jpg' || extension === '.jpeg') { + decodePromise = decodeJpeg(texture, options); + } + + if (defined(decodePromise)) { + return decodePromise.thenReturn(texture); + } + + return texture; + }); +} + +function hasTransparency(pixels) { + var pixelsLength = pixels.length / 4; + for (var i = 0; i < pixelsLength; ++i) { + if (pixels[i * 4 + 3] < 255) { + return true; + } + } + return false; +} + +function getChannels(colorType) { + switch (colorType) { + case 0: // greyscale + return 1; + case 2: // RGB + return 3; + case 4: // greyscale + alpha + return 2; + case 6: // RGB + alpha + return 4; + default: + return 3; + } +} + +function parsePng(data) { + return new Promise(function(resolve, reject) { + new PNG().parse(data, function(error, decodedResults) { + if (defined(error)) { + reject(error); + return; + } + resolve(decodedResults); + }); + }); +} + +function decodePng(texture, options) { + // Color type is encoded in the 25th bit of the png + var source = texture.source; + var colorType = source[25]; + var channels = getChannels(colorType); + + var checkTransparency = (channels === 4 && options.checkTransparency); + var decode = options.decode || checkTransparency; + + if (decode) { + return parsePng(source) + .then(function(decodedResults) { + if (options.checkTransparency) { + texture.transparent = hasTransparency(decodedResults.data); + } + if (options.decode) { + texture.pixels = decodedResults.data; + texture.width = decodedResults.width; + texture.height = decodedResults.height; + texture.source = undefined; // Unload resources + } + }); + } +} + +function decodeJpeg(texture, options) { + if (options.decode) { + var source = texture.source; + var decodedResults = jpeg.decode(source); + texture.pixels = decodedResults.data; + texture.width = decodedResults.width; + texture.height = decodedResults.height; + texture.source = undefined; // Unload resources + } +} diff --git a/lib/obj2gltf.js b/lib/obj2gltf.js index 86cac904..3dcd0928 100644 --- a/lib/obj2gltf.js +++ b/lib/obj2gltf.js @@ -1,140 +1,109 @@ 'use strict'; var Cesium = require('cesium'); var fsExtra = require('fs-extra'); -var GltfPipeline = require('gltf-pipeline').Pipeline; -var os = require('os'); var path = require('path'); -var Promise = require('bluebird'); -var uuid = require('uuid'); var createGltf = require('./createGltf'); var loadObj = require('./loadObj'); -var writeUris = require('./writeUris'); +var writeGltf = require('./writeGltf'); var defaultValue = Cesium.defaultValue; var defined = Cesium.defined; var DeveloperError = Cesium.DeveloperError; -var RuntimeError = Cesium.RuntimeError; module.exports = obj2gltf; /** - * Converts an obj file to a glTF file. + * Converts an obj file to a glTF or glb. * * @param {String} objPath Path to the obj file. - * @param {String} gltfPath Path of the converted glTF file. * @param {Object} [options] An object with the following properties: - * @param {Boolean} [options.binary=false] Save as binary glTF. - * @param {Boolean} [options.separate=false] Writes out separate geometry data files, shader files, and textures instead of embedding them in the glTF. + * @param {Boolean} [options.binary=false] Convert to binary glTF. + * @param {Boolean} [options.separate=false] Write out separate buffer files and textures instead of embedding them in the glTF. * @param {Boolean} [options.separateTextures=false] Write out separate textures only. - * @param {Boolean} [options.compress=false] Quantize positions, compress texture coordinates, and oct-encode normals. - * @param {Boolean} [options.optimize=false] Optimize the glTF for size and runtime performance. - * @param {Boolean} [options.optimizeForCesium=false] Optimize the glTF for Cesium by using the sun as a default light source. - * @param {Boolean} [options.generateNormals=false] Generate normals if they are missing. - * @param {Boolean} [options.ao=false] Apply ambient occlusion to the converted model. - * @param {Boolean} [options.kmc=false] Output glTF with the KHR_materials_common extension. - * @param {Boolean} [options.textureCompressionOptions] Options sent to the compressTextures stage of gltf-pipeline. - * @param {Boolean} [options.bypassPipeline=false] Bypass the gltf-pipeline for debugging purposes. This option overrides many of the options above and will save the glTF with the KHR_materials_common extension. * @param {Boolean} [options.checkTransparency=false] Do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. - * @param {Boolean} [options.secure=false] Prevent the converter from reading image or mtl files outside of the input obj directory. - * @param {String} [options.inputUpAxis='Y'] Up axis of the obj. Choices are 'X', 'Y', and 'Z'. - * @param {String} [options.outputUpAxis='Y'] Up axis of the converted glTF. Choices are 'X', 'Y', and 'Z'. + * @param {Boolean} [options.secure=false] Prevent the converter from reading textures or mtl files outside of the input obj directory. + * @param {Boolean} [options.packOcclusion=false] Pack the occlusion texture in the red channel of the metallic-roughness texture. + * @param {Boolean} [options.metallicRoughness=false] The values in the mtl file are already metallic-roughness PBR values and no conversion step should be applied. Metallic is stored in the Ks and map_Ks slots and roughness is stored in the Ns and map_Ns slots. + * @param {Boolean} [options.specularGlossiness=false] The values in the mtl file are already specular-glossiness PBR values and no conversion step should be applied. Specular is stored in the Ks and map_Ks slots and glossiness is stored in the Ns and map_Ns slots. The glTF will be saved with the KHR_materials_pbrSpecularGlossiness extension. + * @param {Boolean} [options.materialsCommon=false] The glTF will be saved with the KHR_materials_common extension. + * @param {Object} [options.overridingTextures] An object containing texture paths that override textures defined in the .mtl file. This is often convenient in workflows where the .mtl does not exist or is not set up to use PBR materials. Intended for models with a single material. + * @param {String} [options.overridingTextures.metallicRoughnessOcclusionTexture] Path to the metallic-roughness-occlusion texture, where occlusion is stored in the red channel, roughness is stored in the green channel, and metallic is stored in the blue channel. The model will be saved with a pbrMetallicRoughness material. + * @param {String} [options.overridingTextures.specularGlossinessTexture] Path to the specular-glossiness texture, where specular color is stored in the red, green, and blue channels and specular glossiness is stored in the alpha channel. The model will be saved with a material using the KHR_materials_pbrSpecularGlossiness extension. + * @param {String} [options.overridingTextures.occlusionTexture] Path to the occlusion texture. Ignored if metallicRoughnessOcclusionTexture is also set. + * @param {String} [options.overridingTextures.normalTexture] Path to the normal texture. + * @param {String} [options.overridingTextures.baseColorTexture] Path to the baseColor/diffuse texture. + * @param {String} [options.overridingTextures.emissiveTexture] Path to the emissive texture. * @param {Logger} [options.logger] A callback function for handling logged messages. Defaults to console.log. + * @param {Writer} [options.writer] A callback function that writes files that are saved as separate resources. + * @param {String} [options.outputDirectory] Output directory for writing separate resources when options.writer is not defined. + * @return {Promise} A promise that resolves to the glTF JSON or glb buffer. */ -function obj2gltf(objPath, gltfPath, options) { +function obj2gltf(objPath, options) { var defaults = obj2gltf.defaults; - options = defaultValue(options, {}); - var binary = defaultValue(options.binary, defaults.binary); - var separate = defaultValue(options.separate, defaults.separate); - var separateTextures = defaultValue(options.separateTextures, defaults.separateTextures) || separate; - var compress = defaultValue(options.compress, defaults.compress); - var optimize = defaultValue(options.optimize, defaults.optimize); - var optimizeForCesium = defaultValue(options.optimizeForCesium, defaults.optimizeForCesium); - var generateNormals = defaultValue(options.generateNormals, defaults.generateNormals); - var ao = defaultValue(options.ao, defaults.ao); - var kmc = defaultValue(options.kmc, defaults.kmc); - var textureCompressionOptions = options.textureCompressionOptions; - var bypassPipeline = defaultValue(options.bypassPipeline, defaults.bypassPipeline); - var checkTransparency = defaultValue(options.checkTransparency, defaults.checkTransparency); - var secure = defaultValue(options.secure, defaults.secure); - var inputUpAxis = defaultValue(options.inputUpAxis, defaults.inputUpAxis); - var outputUpAxis = defaultValue(options.outputUpAxis, defaults.outputUpAxis); - var logger = defaultValue(options.logger, defaults.logger); - - options.generateNormals = generateNormals; - options.separate = separate; - options.separateTextures = separateTextures; - options.checkTransparency = checkTransparency; - options.secure = secure; - options.inputUpAxis = inputUpAxis; - options.outputUpAxis = outputUpAxis; - options.logger = logger; + options.binary = defaultValue(options.binary, defaults.binary); + options.separate = defaultValue(options.separate, defaults.separate); + options.separateTextures = defaultValue(options.separateTextures, defaults.separateTextures) || options.separate; + options.checkTransparency = defaultValue(options.checkTransparency, defaults.checkTransparency); + options.secure = defaultValue(options.secure, defaults.secure); + options.packOcclusion = defaultValue(options.packOcclusion, defaults.packOcclusion); + options.metallicRoughness = defaultValue(options.metallicRoughness, defaults.metallicRoughness); + options.specularGlossiness = defaultValue(options.specularGlossiness, defaults.specularGlossiness); + options.materialsCommon = defaultValue(options.materialsCommon, defaults.materialsCommon); + options.overridingTextures = defaultValue(options.overridingTextures, defaultValue.EMPTY_OBJECT); + options.logger = defaultValue(options.logger, getDefaultLogger()); + options.writer = defaultValue(options.writer, getDefaultWriter(options.outputDirectory)); if (!defined(objPath)) { throw new DeveloperError('objPath is required'); } - if (!defined(gltfPath)) { - throw new DeveloperError('gltfPath is required'); + if (options.separateTextures && !defined(options.writer)) { + throw new DeveloperError('Either options.writer or options.outputDirectory must be defined when writing separate resources.'); } - var extension = path.extname(gltfPath).toLowerCase(); - var modelName = path.basename(gltfPath, path.extname(gltfPath)); - if (extension === '.glb') { - binary = true; + if (options.metallicRoughness + options.specularGlossiness + options.materialsCommon > 1) { + throw new DeveloperError('Only one material type may be set from [metallicRoughness, specularGlossiness, materialsCommon].'); } - if (binary && bypassPipeline) { - return Promise.reject(new RuntimeError('--bypassPipeline does not convert to binary glTF')); + if (defined(options.overridingTextures.metallicRoughnessOcclusionTexture) && defined(options.overridingTextures.specularGlossinessTexture)) { + throw new DeveloperError('metallicRoughnessOcclusionTexture and specularGlossinessTexture cannot both be defined.'); } - gltfPath = path.join(path.dirname(gltfPath), modelName + extension); - var resourcesDirectory = options.bypassPipeline ? path.dirname(gltfPath) : obj2gltf._getTempDirectory(); - - var aoOptions = ao ? {} : undefined; - var kmcOptions = kmc ? {} : undefined; + if (defined(options.overridingTextures.metallicRoughnessOcclusionTexture)) { + options.metallicRoughness = true; + options.specularGlossiness = false; + options.materialsCommon = false; + options.packOcclusion = true; + } - var pipelineOptions = { - createDirectory : false, - basePath : resourcesDirectory, - binary : binary, - embed : !separate, - embedImage : !separateTextures, - quantize : compress, - compressTextureCoordinates : compress, - encodeNormals : compress, - preserve : !optimize, - optimizeForCesium : optimizeForCesium, - smoothNormals : generateNormals, - aoOptions : aoOptions, - kmcOptions : kmcOptions, - textureCompressionOptions : textureCompressionOptions - }; + if (defined(options.overridingTextures.specularGlossinessTexture)) { + options.metallicRoughness = false; + options.specularGlossiness = true; + options.materialsCommon = false; + } return loadObj(objPath, options) .then(function(objData) { return createGltf(objData, options); }) .then(function(gltf) { - return writeUris(gltf, gltfPath, resourcesDirectory, options); - }) - .then(function(gltf) { - if (bypassPipeline) { - return fsExtra.outputJson(gltfPath, gltf); - } - return GltfPipeline.processJSONToDisk(gltf, gltfPath, pipelineOptions); - }) - .finally(function() { - return cleanup(resourcesDirectory, options); + return writeGltf(gltf, options); }); } -function cleanup(resourcesDirectory, options) { - if (!options.bypassPipeline && options.separate) { - fsExtra.remove(resourcesDirectory, function () { - // Don't fail simply because we couldn't - // clean up the temporary files. - }); +function getDefaultLogger() { + return function(message) { + console.log(message); + }; +} + +function getDefaultWriter(outputDirectory) { + if (defined(outputDirectory)) { + return function(file, data) { + var outputFile = path.join(outputDirectory, file); + return fsExtra.outputFile(outputFile, data); + }; } } @@ -143,105 +112,60 @@ function cleanup(resourcesDirectory, options) { */ obj2gltf.defaults = { /** - * Gets or sets whether the model will be saved as binary glTF. + * Gets or sets whether the converter will return a glb. * @type Boolean * @default false */ - binary: false, + binary : false, /** - * Gets or sets whether to write out separate geometry/animation data files, + * Gets or sets whether to write out separate buffer and texture, * shader files, and textures instead of embedding them in the glTF. * @type Boolean * @default false */ - separate: false, + separate : false, /** * Gets or sets whether to write out separate textures only. * @type Boolean * @default false */ - separateTextures: false, - /** - * Gets or sets whether to compress attribute data. This includes quantizing positions, compressing texture coordinates, and oct-encoding normals. - * @type Boolean - * @default false - */ - compress: false, - /** - * Gets or sets whether the model is optimized for size and runtime performance. - * @type Boolean - * @default false - */ - optimize: false, - /** - * Gets or sets whether the model is optimized for Cesium by using the sun as a default light source. - * @type Boolean - * @default false - */ - optimizeForCesium: false, + separateTextures : false, /** - * Gets or sets whether normals will be generated for the model if they are missing. + * Gets or sets whether the converter will do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. * @type Boolean * @default false */ - generateNormals: false, + checkTransparency : false, /** - * Gets or sets whether the model will have ambient occlusion applied. + * Gets or sets whether the source model can reference paths outside of its directory. * @type Boolean * @default false */ - ao: false, + secure : false, /** - * Gets or sets whether the model will be saved with the KHR_materials_common extension. + * Gets or sets whether to pack the occlusion texture in the red channel of the metallic-roughness texture. * @type Boolean * @default false */ - kmc: false, + packOcclusion : false, /** - * Gets or sets whether the converter will bypass the gltf-pipeline for debugging purposes. + * Gets or sets whether rhe values in the .mtl file are already metallic-roughness PBR values and no conversion step should be applied. Metallic is stored in the Ks and map_Ks slots and roughness is stored in the Ns and map_Ns slots. * @type Boolean * @default false */ - bypassPipeline: false, + metallicRoughness : false, /** - * Gets or sets whether the converter will do a more exhaustive check for texture transparency by looking at the alpha channel of each pixel. + * Gets or sets whether the values in the .mtl file are already specular-glossiness PBR values and no conversion step should be applied. Specular is stored in the Ks and map_Ks slots and glossiness is stored in the Ns and map_Ns slots. The glTF will be saved with the KHR_materials_pbrSpecularGlossiness extension. * @type Boolean * @default false */ - checkTransparency: false, + specularGlossiness : false, /** - * Gets or sets whether the source model can reference paths outside of its directory. + * Gets or sets whether the glTF will be saved with the KHR_materials_common extension. * @type Boolean * @default false */ - secure: false, - /** - * Gets or sets the up axis of the obj. - * @type String - * @default 'Y' - */ - inputUpAxis: 'Y', - /** - * Gets or sets the up axis of the converted glTF. - * @type String - * @default 'Y' - */ - outputUpAxis: 'Y', - /** - * @private - */ - logger: function(message) { - console.log(message); - } -}; - -/** - * Exposed for testing - * - * @private - */ -obj2gltf._getTempDirectory = function () { - return path.join(os.tmpdir(), uuid()); + materialsCommon : false }; /** @@ -251,3 +175,11 @@ obj2gltf._getTempDirectory = function () { * @param {String} message The message to log. */ +/** + * A callback function that writes files that are saved as separate resources. + * @callback Writer + * + * @param {String} file The relative path of the file. + * @param {Buffer} data The file data to write. + * @returns {Promise} A promise that resolves when the file is written. + */ diff --git a/lib/outsideDirectory.js b/lib/outsideDirectory.js new file mode 100644 index 00000000..df2c8a1a --- /dev/null +++ b/lib/outsideDirectory.js @@ -0,0 +1,15 @@ +'use strict'; +var path = require('path'); + +module.exports = outsideDirectory; + +/** + * Checks if a file is outside of a directory. + * + * @param {String} file Path to the file. + * @param {String} directory Path to the directory. + * @returns {Boolean} Whether the file is outside of the directory. + */ +function outsideDirectory(file, directory) { + return (path.relative(directory, file).indexOf('..') === 0); +} diff --git a/lib/readLines.js b/lib/readLines.js index 073b85ce..185cead0 100644 --- a/lib/readLines.js +++ b/lib/readLines.js @@ -15,13 +15,13 @@ module.exports = readLines; * @private */ function readLines(path, callback) { - return new Promise(function (resolve, reject) { + return new Promise(function(resolve, reject) { var stream = fsExtra.createReadStream(path); stream.on('error', reject); stream.on('end', resolve); var lineReader = readline.createInterface({ - input: stream + input : stream }); lineReader.on('line', callback); }); diff --git a/lib/writeGltf.js b/lib/writeGltf.js new file mode 100644 index 00000000..36348895 --- /dev/null +++ b/lib/writeGltf.js @@ -0,0 +1,185 @@ +'use strict'; +var Cesium = require('cesium'); +var mime = require('mime'); +var PNG = require('pngjs').PNG; +var Promise = require('bluebird'); +var getBufferPadded = require('./getBufferPadded'); +var gltfToGlb = require('./gltfToGlb'); + +var defined = Cesium.defined; +var RuntimeError = Cesium.RuntimeError; + +module.exports = writeGltf; + +/** + * Write glTF resources as embedded data uris or external files. + * + * @param {Object} gltf The glTF asset. + * @param {Object} options The options object passed along from lib/obj2gltf.js + * @returns {Promise} A promise that resolves to the glTF JSON or glb buffer. + * + * @private + */ +function writeGltf(gltf, options) { + return encodeTextures(gltf) + .then(function() { + var binary = options.binary; + var separate = options.separate; + var separateTextures = options.separateTextures; + + var promises = []; + if (separateTextures) { + promises.push(writeSeparateTextures(gltf, options)); + } else { + writeEmbeddedTextures(gltf); + } + + if (separate) { + promises.push(writeSeparateBuffer(gltf, options)); + } else if (!binary) { + writeEmbeddedBuffer(gltf); + } + + var binaryBuffer = gltf.buffers[0].extras._obj2gltf.source; + + return Promise.all(promises) + .then(function() { + deleteExtras(gltf); + removeEmpty(gltf); + if (binary) { + return gltfToGlb(gltf, binaryBuffer); + } + return gltf; + }); + }); +} + +function encodePng(texture) { + // Constants defined by pngjs + var rgbColorType = 2; + var rgbaColorType = 6; + + var png = new PNG({ + width : texture.width, + height : texture.height, + colorType : texture.transparent ? rgbaColorType : rgbColorType, + inputColorType : rgbaColorType, + inputHasAlpha : true + }); + + png.data = texture.pixels; + + return new Promise(function(resolve, reject) { + var chunks = []; + var stream = png.pack(); + stream.on('data', function(chunk) { + chunks.push(chunk); + }); + stream.on('end', function() { + resolve(Buffer.concat(chunks)); + }); + stream.on('error', reject); + }); +} + +function encodeTexture(texture) { + if (!defined(texture.source) && defined(texture.pixels) && texture.extension === '.png') { + return encodePng(texture) + .then(function(encoded) { + texture.source = encoded; + }); + } +} + +function encodeTextures(gltf) { + // Dynamically generated PBR textures need to be encoded to png prior to being saved + var encodePromises = []; + var images = gltf.images; + var length = images.length; + for (var i = 0; i < length; ++i) { + encodePromises.push(encodeTexture(images[i].extras._obj2gltf)); + } + return Promise.all(encodePromises); +} + +function deleteExtras(gltf) { + var buffer = gltf.buffers[0]; + delete buffer.extras; + + var images = gltf.images; + var imagesLength = images.length; + for (var i = 0; i < imagesLength; ++i) { + delete images[i].extras; + } +} + +function removeEmpty(json) { + Object.keys(json).forEach(function(key) { + if (!defined(json[key]) || (Array.isArray(json[key]) && json[key].length === 0)) { + delete json[key]; // Delete values that are undefined or [] + } else if (typeof json[key] === 'object') { + removeEmpty(json[key]); + } + }); +} + +function writeSeparateBuffer(gltf, options) { + var buffer = gltf.buffers[0]; + var source = buffer.extras._obj2gltf.source; + var bufferUri = buffer.name + '.bin'; + buffer.uri = bufferUri; + return options.writer(bufferUri, source); +} + +function writeSeparateTextures(gltf, options) { + var images = gltf.images; + return Promise.map(images, function(image) { + var texture = image.extras._obj2gltf; + var imageUri = image.name + texture.extension; + image.uri = imageUri; + return options.writer(imageUri, texture.source); + }, {concurrency : 10}); +} + +function writeEmbeddedBuffer(gltf) { + var buffer = gltf.buffers[0]; + var source = buffer.extras._obj2gltf.source; + + // Buffers larger than ~192MB cannot be base64 encoded due to a NodeJS limitation. Source: https://github.com/nodejs/node/issues/4266 + if (source.length > 201326580) { + throw new RuntimeError('Buffer is too large to embed in the glTF. Use the --separate flag instead.'); + } + + buffer.uri = 'data:application/octet-stream;base64,' + source.toString('base64'); +} + +function writeEmbeddedTextures(gltf) { + var buffer = gltf.buffers[0]; + var bufferExtras = buffer.extras._obj2gltf; + var bufferSource = bufferExtras.source; + var images = gltf.images; + var imagesLength = images.length; + var sources = [bufferSource]; + var byteOffset = bufferSource.length; + + for (var i = 0; i < imagesLength; ++i) { + var image = images[i]; + var texture = image.extras._obj2gltf; + var textureSource = texture.source; + var textureByteLength = textureSource.length; + + image.mimeType = mime.lookup(texture.extension); + image.bufferView = gltf.bufferViews.length; + gltf.bufferViews.push({ + buffer : 0, + byteOffset : byteOffset, + byteLength : textureByteLength + }); + byteOffset += textureByteLength; + sources.push(textureSource); + } + + var source = getBufferPadded(Buffer.concat(sources)); + bufferExtras.source = source; + buffer.byteLength = source.length; +} diff --git a/lib/writeUris.js b/lib/writeUris.js deleted file mode 100644 index 5987a728..00000000 --- a/lib/writeUris.js +++ /dev/null @@ -1,119 +0,0 @@ -'use strict'; -var Cesium = require('cesium'); -var fsExtra = require('fs-extra'); -var mime = require('mime'); -var path = require('path'); -var Promise = require('bluebird'); - -var RuntimeError = Cesium.RuntimeError; - -module.exports = writeUris; - -/** - * Write glTF resources as embedded data uris or external files. - * - * @param {Object} gltf The glTF asset. - * @param {String} gltfPath Path where the glTF will be saved. - * @param {String} resourcesDirectory Path where separate resources will be saved. - * @param {Object} options An object with the following properties: - * @param {Boolean} options.separate Writes out separate buffers. - * @param {Boolean} options.separateTextures Write out separate textures only. - * @returns {Promise} A promise that resolves to the glTF asset. - * - * @private - */ -function writeUris(gltf, gltfPath, resourcesDirectory, options) { - var separate = options.separate; - var separateTextures = options.separateTextures; - - var promises = []; - - var buffer = gltf.buffers[Object.keys(gltf.buffers)[0]]; - var bufferByteLength = buffer.extras._obj2gltf.source.length; - - var texturesByteLength = 0; - var images = gltf.images; - for (var id in images) { - if (images.hasOwnProperty(id)) { - texturesByteLength += images[id].extras._obj2gltf.source.length; - } - } - - // Buffers larger than ~192MB cannot be base64 encoded due to a NodeJS limitation. Source: https://github.com/nodejs/node/issues/4266 - var exceedsMaximum = (texturesByteLength + bufferByteLength > 201326580); - - if (exceedsMaximum && !separate) { - return Promise.reject(new RuntimeError('Buffers and textures are too large to encode in the glTF. Use the --separate flag instead.')); - } - - var name = path.basename(gltfPath, path.extname(gltfPath)); - - if (separate) { - promises.push(writeSeparateBuffer(gltf, resourcesDirectory, name)); - } else { - writeEmbeddedBuffer(gltf); - } - - if (separateTextures) { - promises.push(writeSeparateTextures(gltf, resourcesDirectory)); - } else { - writeEmbeddedTextures(gltf); - } - - return Promise.all(promises) - .then(function() { - deleteExtras(gltf); - return gltf; - }); -} - -function deleteExtras(gltf) { - var buffer = gltf.buffers[Object.keys(gltf.buffers)[0]]; - delete buffer.extras; - - var images = gltf.images; - for (var id in images) { - if (images.hasOwnProperty(id)) { - var image = images[id]; - delete image.extras; - } - } -} - -function writeSeparateBuffer(gltf, resourcesDirectory, name) { - var buffer = gltf.buffers[Object.keys(gltf.buffers)[0]]; - var source = buffer.extras._obj2gltf.source; - var bufferUri = name + '.bin'; - buffer.uri = bufferUri; - var bufferPath = path.join(resourcesDirectory, bufferUri); - return fsExtra.outputFile(bufferPath, source); -} - -function writeSeparateTextures(gltf, resourcesDirectory) { - var images = gltf.images; - return Promise.map(Object.keys(images), function(id) { - var image = images[id]; - var extras = image.extras._obj2gltf; - var imageUri = image.name + extras.extension; - image.uri = imageUri; - var imagePath = path.join(resourcesDirectory, imageUri); - return fsExtra.outputFile(imagePath, extras.source); - }, {concurrency : 10}); -} - -function writeEmbeddedBuffer(gltf) { - var buffer = gltf.buffers[Object.keys(gltf.buffers)[0]]; - var source = buffer.extras._obj2gltf.source; - buffer.uri = 'data:application/octet-stream;base64,' + source.toString('base64'); -} - -function writeEmbeddedTextures(gltf) { - var images = gltf.images; - for (var id in images) { - if (images.hasOwnProperty(id)) { - var image = images[id]; - var extras = image.extras._obj2gltf; - image.uri = 'data:' + mime.lookup(extras.extension) + ';base64,' + extras.source.toString('base64'); - } - } -} diff --git a/package.json b/package.json index 6ebf148f..585c0e52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obj2gltf", - "version": "1.2.0", + "version": "2.0.0", "description": "Convert OBJ model format to glTF", "license": "Apache-2.0", "contributors": [ @@ -27,25 +27,25 @@ }, "dependencies": { "bluebird": "^3.5.0", - "cesium": "^1.35.2", - "fs-extra": "^3.0.1", - "gltf-pipeline": "^1.0.0", + "cesium": "^1.36.0", + "fs-extra": "^4.0.1", + "jpeg-js": "^0.3.3", "mime": "^1.3.6", - "pngjs": "^3.2.0", + "pngjs": "^3.3.0", "uuid": "^3.1.0", "yargs": "^8.0.2" }, "devDependencies": { "coveralls": "^2.13.1", - "eslint": "^4.2.0", + "eslint": "^4.4.1", "eslint-config-cesium": "^2.0.1", "gulp": "^3.9.1", - "jasmine": "^2.6.0", - "jasmine-spec-reporter": "^4.1.1", - "jsdoc": "^3.5.1", - "nyc": "^11.0.3", + "jasmine": "^2.7.0", + "jasmine-spec-reporter": "^4.2.0", + "jsdoc": "^3.5.4", + "nyc": "^11.1.0", "open": "^0.0.5", - "requirejs": "^2.3.3" + "requirejs": "^2.3.4" }, "scripts": { "jsdoc": "jsdoc ./lib -R ./README.md -d doc", diff --git a/specs/data/box-complex-material/alpha.png b/specs/data/box-complex-material/alpha.png index 8437c62e..d04eb82f 100644 Binary files a/specs/data/box-complex-material/alpha.png and b/specs/data/box-complex-material/alpha.png differ diff --git a/specs/data/box-objects-groups-materials/box-objects-groups-materials.obj b/specs/data/box-objects-groups-materials/box-objects-groups-materials.obj index 1bb76984..b2fb821f 100644 --- a/specs/data/box-objects-groups-materials/box-objects-groups-materials.obj +++ b/specs/data/box-objects-groups-materials/box-objects-groups-materials.obj @@ -36,7 +36,7 @@ vn 1.0000 0.0000 0.0000 vn 0.0000 0.0000 1.0000 vn 0.0000 -1.0000 0.0000 vn 0.0000 1.0000 0.0000 -g CubeBlue_CubeBlue_Blue +g Blue usemtl Blue f 1/1/1 2/2/1 4/3/1 3/4/1 f 3/5/2 4/6/2 8/7/2 7/8/2 @@ -79,7 +79,7 @@ vn 1.0000 0.0000 0.0000 vn 0.0000 0.0000 1.0000 vn 0.0000 -1.0000 0.0000 vn 0.0000 1.0000 0.0000 -g CubeGreen_CubeGreen_Green +g Green usemtl Green f 9/21/7 10/22/7 12/23/7 11/24/7 f 11/25/8 12/26/8 16/27/8 15/28/8 @@ -122,7 +122,7 @@ vn 1.0000 0.0000 0.0000 vn 0.0000 0.0000 1.0000 vn 0.0000 -1.0000 0.0000 vn 0.0000 1.0000 0.0000 -g CubeRed_CubeRed_Red +g Red usemtl Red f 17/41/13 18/42/13 20/43/13 19/44/13 f 19/45/14 20/46/14 24/47/14 23/48/14 diff --git a/specs/data/box/box.mtl b/specs/data/box/box.mtl index a304b43f..4f8d1294 100644 --- a/specs/data/box/box.mtl +++ b/specs/data/box/box.mtl @@ -3,10 +3,10 @@ newmtl Material Ns 96.078431 -Ka 0.000000 0.000000 0.000000 +Ka 0.100000 0.000000 0.000000 Kd 0.640000 0.640000 0.640000 Ks 0.500000 0.500000 0.500000 -Ke 0.000000 0.000000 0.000000 +Ke 0.000000 0.000000 0.100000 Ni 1.000000 d 1.000000 illum 2 diff --git a/specs/lib/createGltfSpec.js b/specs/lib/createGltfSpec.js index afd0963a..4114c0d6 100644 --- a/specs/lib/createGltfSpec.js +++ b/specs/lib/createGltfSpec.js @@ -3,62 +3,59 @@ var Cesium = require('cesium'); var Promise = require('bluebird'); var obj2gltf = require('../../lib/obj2gltf'); var createGltf = require('../../lib/createGltf'); -var loadImage = require('../../lib/loadImage'); var loadObj = require('../../lib/loadObj'); -var Material = require('../../lib/Material'); var clone = Cesium.clone; var WebGLConstants = Cesium.WebGLConstants; -var boxObjUrl = 'specs/data/box/box.obj'; -var groupObjUrl = 'specs/data/box-objects-groups-materials/box-objects-groups-materials.obj'; -var diffuseTextureUrl = 'specs/data/box-textured/cesium.png'; -var transparentDiffuseTextureUrl = 'specs/data/box-complex-material/diffuse.png'; +var boxObjPath = 'specs/data/box/box.obj'; +var groupObjPath = 'specs/data/box-objects-groups-materials/box-objects-groups-materials.obj'; +var complexObjPath = 'specs/data/box-complex-material/box-complex-material.obj'; +var noMaterialsObjPath = 'specs/data/box-no-materials/box-no-materials.obj'; -var defaultOptions = obj2gltf.defaults; -var checkTransparencyOptions = clone(defaultOptions); -checkTransparencyOptions.checkTransparency = true; +var options; describe('createGltf', function() { var boxObjData; - var duplicateBoxObjData; var groupObjData; - var diffuseTexture; - var transparentDiffuseTexture; + var complexObjData; + var noMaterialsObjData; beforeEach(function(done) { + options = clone(obj2gltf.defaults); + options.overridingTextures = {}; + options.logger = function() {}; + return Promise.all([ - loadObj(boxObjUrl, defaultOptions) + loadObj(boxObjPath, options) .then(function(data) { boxObjData = data; }), - loadObj(boxObjUrl, defaultOptions) - .then(function(data) { - duplicateBoxObjData = data; - }), - loadObj(groupObjUrl, defaultOptions) + loadObj(groupObjPath, options) .then(function(data) { groupObjData = data; }), - loadImage(diffuseTextureUrl, defaultOptions) - .then(function(image) { - diffuseTexture = image; + loadObj(complexObjPath, options) + .then(function(data) { + complexObjData = data; }), - loadImage(transparentDiffuseTextureUrl, checkTransparencyOptions) - .then(function(image) { - transparentDiffuseTexture = image; + loadObj(noMaterialsObjPath, options) + .then(function(data) { + noMaterialsObjData = data; }) ]).then(done); }); it('simple gltf', function() { - var gltf = createGltf(boxObjData, defaultOptions); + var gltf = createGltf(boxObjData, options); - expect(Object.keys(gltf.materials).length).toBe(1); - expect(Object.keys(gltf.nodes).length).toBe(1); - expect(Object.keys(gltf.meshes).length).toBe(1); + expect(gltf.materials.length).toBe(1); + expect(gltf.scene).toBe(0); + expect(gltf.scenes[0].nodes[0]).toBe(0); + expect(gltf.nodes.length).toBe(1); + expect(gltf.meshes.length).toBe(1); - var primitives = gltf.meshes['Cube-Mesh'].primitives; + var primitives = gltf.meshes[0].primitives; var primitive = primitives[0]; var attributes = primitive.attributes; var positionAccessor = gltf.accessors[attributes.POSITION]; @@ -74,214 +71,71 @@ describe('createGltf', function() { }); it('multiple nodes, meshes, and primitives', function() { - var gltf = createGltf(groupObjData, defaultOptions); + var gltf = createGltf(groupObjData, options); - expect(Object.keys(gltf.materials).length).toBe(3); - expect(Object.keys(gltf.nodes).length).toBe(1); - expect(Object.keys(gltf.meshes).length).toBe(3); + expect(gltf.materials.length).toBe(3); + expect(gltf.scene).toBe(0); + expect(gltf.scenes[0].nodes[0]).toBe(0); + expect(gltf.nodes.length).toBe(4); + expect(gltf.nodes[0].mesh).toBeUndefined(); + expect(gltf.nodes[0].children.length).toBe(3); + expect(gltf.meshes.length).toBe(3); // Check for two primitives in each mesh - for (var id in gltf.meshes) { - if (gltf.meshes.hasOwnProperty(id)) { - var mesh = gltf.meshes[id]; - expect(mesh.primitives.length).toBe(2); - } + var length = gltf.meshes.length; + for (var i = 0; i < length; ++i) { + var mesh = gltf.meshes[i]; + expect(mesh.primitives.length).toBe(2); } }); - it('sets default material values', function() { - boxObjData.materials.Material = new Material(); - - var gltf = createGltf(boxObjData, defaultOptions); - var material = gltf.materials.Material; - var kmc = material.extensions.KHR_materials_common; - var values = kmc.values; - - expect(kmc.technique).toBe('LAMBERT'); - expect(values.ambient).toEqual([0.0, 0.0, 0.0, 1]); - expect(values.diffuse).toEqual([0.5, 0.5, 0.5, 1]); - expect(values.emission).toEqual([0.0, 0.0, 0.0, 1]); - expect(values.specular).toEqual([0.0, 0.0, 0.0, 1]); - expect(values.shininess).toEqual(0.0); - }); - - it('sets material for diffuse texture', function() { - var material = new Material(); - material.diffuseTexture = diffuseTextureUrl; - boxObjData.materials.Material = material; - boxObjData.images[diffuseTextureUrl] = diffuseTexture; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - var texture = gltf.textures.texture_cesium; - var image = gltf.images.cesium; - - expect(kmc.technique).toBe('LAMBERT'); - expect(kmc.values.diffuse).toEqual('texture_cesium'); - expect(kmc.values.transparency).toBe(1.0); - expect(kmc.values.transparent).toBe(false); - expect(kmc.values.doubleSided).toBe(false); - - expect(texture).toEqual({ - format : WebGLConstants.RGB, - internalFormat : WebGLConstants.RGB, - sampler : 'sampler', - source : 'cesium', - target : WebGLConstants.TEXTURE_2D, - type : WebGLConstants.UNSIGNED_BYTE - }); - - expect(image).toBeDefined(); - expect(image.name).toBe('cesium'); - expect(image.extras._obj2gltf.source).toBeDefined(); - expect(image.extras._obj2gltf.extension).toBe('.png'); - - expect(gltf.samplers.sampler).toEqual({ - magFilter : WebGLConstants.LINEAR, - minFilter : WebGLConstants.NEAREST_MIPMAP_LINEAR, - wrapS : WebGLConstants.REPEAT, - wrapT : WebGLConstants.REPEAT - }); - }); - - it('sets material for alpha less than 1', function() { - var material = new Material(); - material.alpha = 0.4; - boxObjData.materials.Material = material; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.values.diffuse).toEqual([0.5, 0.5, 0.5, 0.4]); - expect(kmc.values.transparency).toBe(1.0); - expect(kmc.values.transparent).toBe(true); - expect(kmc.values.doubleSided).toBe(true); - }); - - it('sets material for diffuse texture and alpha less than 1', function() { - var material = new Material(); - material.diffuseTexture = diffuseTextureUrl; - material.alpha = 0.4; - boxObjData.materials.Material = material; - - boxObjData.images[diffuseTextureUrl] = diffuseTexture; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.values.diffuse).toEqual('texture_cesium'); - expect(kmc.values.transparency).toBe(0.4); - expect(kmc.values.transparent).toBe(true); - expect(kmc.values.doubleSided).toBe(true); - }); - - it('sets material for transparent diffuse texture', function() { - var material = new Material(); - material.diffuseTexture = transparentDiffuseTextureUrl; - boxObjData.materials.Material = material; - - boxObjData.images[transparentDiffuseTextureUrl] = transparentDiffuseTexture; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.values.diffuse).toBe('texture_diffuse'); - expect(kmc.values.transparency).toBe(1.0); - expect(kmc.values.transparent).toBe(true); - expect(kmc.values.doubleSided).toBe(true); - }); - - it('sets material for specular', function() { - var material = new Material(); - material.specularColor = [0.1, 0.1, 0.2, 1]; - material.specularShininess = 0.1; - boxObjData.materials.Material = material; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.technique).toBe('PHONG'); - expect(kmc.values.specular).toEqual([0.1, 0.1, 0.2, 1]); - expect(kmc.values.shininess).toEqual(0.1); - }); - - it('sets constant material when there are no normals', function() { - boxObjData.nodes[0].meshes[0].normals.length = 0; - - var material = new Material(); - material.diffuseTexture = diffuseTextureUrl; - boxObjData.materials.Material = material; - - boxObjData.images[diffuseTextureUrl] = diffuseTexture; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.technique).toBe('CONSTANT'); - expect(kmc.values.emission).toEqual('texture_cesium'); - }); - - it('sets default material when texture is missing', function() { - var material = new Material(); - material.diffuseTexture = diffuseTextureUrl; - boxObjData.materials.Material = material; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.values.diffuse).toEqual([0.5, 0.5, 0.5, 1.0]); - }); - - it('uses default material (1)', function() { - boxObjData.nodes[0].meshes[0].primitives[0].material = undefined; - - // Creates a material called "default" - var gltf = createGltf(boxObjData, defaultOptions); - expect(gltf.materials.default).toBeDefined(); - var kmc = gltf.materials.default.extensions.KHR_materials_common; - expect(kmc.values.diffuse).toEqual([0.5, 0.5, 0.5, 1.0]); - }); - - it('uses default material (2)', function() { - boxObjData.materials = {}; - - // Uses the original name of the material - var gltf = createGltf(boxObjData, defaultOptions); - var kmc = gltf.materials.Material.extensions.KHR_materials_common; - - expect(kmc.values.diffuse).toEqual([0.5, 0.5, 0.5, 1.0]); - }); - - it('handles material used with and without normals (1)', function() { - // Two meshes - one with normals, and one without - boxObjData.nodes.push(duplicateBoxObjData.nodes[0]); - boxObjData.nodes[1].meshes[0].normals.length = 0; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc1 = gltf.materials.Material.extensions.KHR_materials_common; - var kmc2 = gltf.materials.Material_constant.extensions.KHR_materials_common; - - expect(kmc1.technique).toBe('PHONG'); - expect(kmc2.technique).toBe('CONSTANT'); - }); - - it('handles material used with and without normals (2)', function() { - // Now test in a different order - boxObjData.nodes.push(duplicateBoxObjData.nodes[0]); - boxObjData.nodes[0].meshes[0].normals.length = 0; - - var gltf = createGltf(boxObjData, defaultOptions); - var kmc1 = gltf.materials.Material.extensions.KHR_materials_common; - var kmc2 = gltf.materials.Material_shaded.extensions.KHR_materials_common; - - expect(kmc1.technique).toBe('CONSTANT'); - expect(kmc2.technique).toBe('PHONG'); + it('multiple textures', function() { + var gltf = createGltf(complexObjData, options); + var material = gltf.materials[0]; + var pbr = material.pbrMetallicRoughness; + var textures = [pbr.metallicRoughnessTexture, pbr.baseColorTexture, material.emissiveTexture, material.normalTexture, material.occlusionTexture]; + expect(textures.map(function(texture) { + return texture.index; + }).sort()).toEqual([0, 1, 2, 3, 4]); + expect(gltf.samplers[0]).toBeDefined(); + }); + + it('creates default material', function() { + var gltf = createGltf(noMaterialsObjData, options); + var material = gltf.materials[0]; + var pbr = material.pbrMetallicRoughness; + expect(material.name).toBe('default'); + expect(pbr.baseColorTexture).toBeUndefined(); + expect(pbr.metallicRoughnessTexture).toBeUndefined(); + expect(pbr.baseColorFactor).toEqual([0.5, 0.5, 0.5, 1.0]); + expect(pbr.metallicFactor).toBe(0.0); // No metallic + expect(pbr.roughnessFactor).toBe(1.0); // Fully rough + expect(material.emissiveTexture).toBeUndefined(); + expect(material.normalTexture).toBeUndefined(); + expect(material.ambientTexture).toBeUndefined(); + expect(material.emissiveFactor).toEqual([0.0, 0.0, 0.0]); + expect(material.alphaMode).toBe('OPAQUE'); + expect(material.doubleSided).toBe(false); + }); + + it('adds KHR_materials_pbrSpecularGlossiness extension when specularGlossiness is set', function() { + options.specularGlossiness = true; + var gltf = createGltf(noMaterialsObjData, options); + expect(gltf.extensionsUsed).toEqual(['KHR_materials_pbrSpecularGlossiness']); + expect(gltf.extensionsRequired).toEqual(['KHR_materials_pbrSpecularGlossiness']); + }); + + it('adds KHR_materials_common extension when materialsCommon is set', function() { + options.materialsCommon = true; + var gltf = createGltf(noMaterialsObjData, options); + expect(gltf.extensionsUsed).toEqual(['KHR_materials_common']); + expect(gltf.extensionsRequired).toEqual(['KHR_materials_common']); }); it('runs without normals', function() { boxObjData.nodes[0].meshes[0].normals.length = 0; - var gltf = createGltf(boxObjData, defaultOptions); + var gltf = createGltf(boxObjData, options); var attributes = gltf.meshes[Object.keys(gltf.meshes)[0]].primitives[0].attributes; expect(attributes.POSITION).toBeDefined(); expect(attributes.NORMAL).toBeUndefined(); @@ -291,7 +145,7 @@ describe('createGltf', function() { it('runs without uvs', function() { boxObjData.nodes[0].meshes[0].uvs.length = 0; - var gltf = createGltf(boxObjData, defaultOptions); + var gltf = createGltf(boxObjData, options); var attributes = gltf.meshes[Object.keys(gltf.meshes)[0]].primitives[0].attributes; expect(attributes.POSITION).toBeDefined(); expect(attributes.NORMAL).toBeDefined(); @@ -302,7 +156,7 @@ describe('createGltf', function() { boxObjData.nodes[0].meshes[0].normals.length = 0; boxObjData.nodes[0].meshes[0].uvs.length = 0; - var gltf = createGltf(boxObjData, defaultOptions); + var gltf = createGltf(boxObjData, options); var attributes = gltf.meshes[Object.keys(gltf.meshes)[0]].primitives[0].attributes; expect(attributes.POSITION).toBeDefined(); expect(attributes.NORMAL).toBeUndefined(); @@ -342,7 +196,7 @@ describe('createGltf', function() { var indicesLength = mesh.primitives[0].indices.length; var vertexCount = mesh.positions.length / 3; - var gltf = createGltf(boxObjData, defaultOptions); + var gltf = createGltf(boxObjData, options); var primitive = gltf.meshes[Object.keys(gltf.meshes)[0]].primitives[0]; var indicesAccessor = gltf.accessors[primitive.indices]; expect(indicesAccessor.count).toBe(indicesLength); @@ -352,13 +206,4 @@ describe('createGltf', function() { var positionAccessor = gltf.accessors[primitive.attributes.POSITION]; expect(positionAccessor.count).toBe(vertexCount); }); - - it('ambient of [1, 1, 1] is treated as [0, 0, 0]', function() { - boxObjData.materials.Material.ambientColor = [1.0, 1.0, 1.0, 1.0]; - - var gltf = createGltf(boxObjData, defaultOptions); - var ambient = gltf.materials.Material.extensions.KHR_materials_common.values.ambient; - - expect(ambient).toEqual([0.0, 0.0, 0.0, 1.0]); - }); }); diff --git a/specs/lib/loadImageSpec.js b/specs/lib/loadImageSpec.js deleted file mode 100644 index 3554d83d..00000000 --- a/specs/lib/loadImageSpec.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; -var Cesium = require('cesium'); -var obj2gltf = require('../../lib/obj2gltf'); -var loadImage = require('../../lib/loadImage'); - -var clone = Cesium.clone; -var WebGLConstants = Cesium.WebGLConstants; - -var pngImage = 'specs/data/box-complex-material/shininess.png'; -var jpgImage = 'specs/data/box-complex-material/emission.jpg'; -var jpegImage = 'specs/data/box-complex-material/specular.jpeg'; -var gifImage = 'specs/data/box-complex-material/ambient.gif'; -var grayscaleImage = 'specs/data/box-complex-material/alpha.png'; -var transparentImage = 'specs/data/box-complex-material/diffuse.png'; - -var defaultOptions = obj2gltf.defaults; - -describe('loadImage', function() { - it('loads png image', function(done) { - expect(loadImage(pngImage, defaultOptions) - .then(function(info) { - expect(info.transparent).toBe(false); - expect(info.format).toBe(WebGLConstants.RGB); - expect(info.source).toBeDefined(); - expect(info.extension).toBe('.png'); - }), done).toResolve(); - }); - - it('loads jpg image', function(done) { - expect(loadImage(jpgImage, defaultOptions) - .then(function(info) { - expect(info.transparent).toBe(false); - expect(info.format).toBe(WebGLConstants.RGB); - expect(info.source).toBeDefined(); - expect(info.extension).toBe('.jpg'); - }), done).toResolve(); - }); - - it('loads jpeg image', function(done) { - expect(loadImage(jpegImage, defaultOptions) - .then(function(info) { - expect(info.transparent).toBe(false); - expect(info.format).toBe(WebGLConstants.RGB); - expect(info.source).toBeDefined(); - expect(info.extension).toBe('.jpeg'); - }), done).toResolve(); - }); - - it('loads gif image', function(done) { - expect(loadImage(gifImage, defaultOptions) - .then(function(info) { - expect(info.transparent).toBe(false); - expect(info.format).toBe(WebGLConstants.RGB); - expect(info.source).toBeDefined(); - expect(info.extension).toBe('.gif'); - }), done).toResolve(); - }); - - it('loads grayscale image', function(done) { - expect(loadImage(grayscaleImage, defaultOptions) - .then(function(info) { - expect(info.transparent).toBe(false); - expect(info.format).toBe(WebGLConstants.ALPHA); - expect(info.source).toBeDefined(); - expect(info.extension).toBe('.png'); - }), done).toResolve(); - }); - - it('loads image with alpha channel', function(done) { - expect(loadImage(transparentImage, defaultOptions) - .then(function(info) { - expect(info.transparent).toBe(false); - }), done).toResolve(); - }); - - it('loads image with checkTransparency flag', function(done) { - var options = clone(defaultOptions); - options.checkTransparency = true; - - expect(loadImage(transparentImage, options) - .then(function(info) { - expect(info.transparent).toBe(true); - }), done).toResolve(); - }); -}); diff --git a/specs/lib/loadMtlSpec.js b/specs/lib/loadMtlSpec.js index 976b322d..9d4a5d20 100644 --- a/specs/lib/loadMtlSpec.js +++ b/specs/lib/loadMtlSpec.js @@ -1,43 +1,459 @@ 'use strict'; -var path = require('path'); +var Cesium = require('cesium'); +var Promise = require('bluebird'); +var fsExtra = require('fs-extra'); var loadMtl = require('../../lib/loadMtl'); +var loadTexture = require('../../lib/loadTexture'); +var obj2gltf = require('../../lib/obj2gltf'); +var Texture = require('../../lib/Texture'); -var complexMaterialUrl = 'specs/data/box-complex-material/box-complex-material.mtl'; -var multipleMaterialsUrl = 'specs/data/box-multiple-materials/box-multiple-materials.mtl'; +var clone = Cesium.clone; -function getImagePath(objPath, relativePath) { - return path.resolve(path.dirname(objPath), relativePath); -} +var coloredMaterialPath = 'specs/data/box/box.mtl'; +var texturedMaterialPath = 'specs/data/box-complex-material/box-complex-material.mtl'; +var multipleMaterialsPath = 'specs/data/box-multiple-materials/box-multiple-materials.mtl'; +var externalMaterialPath = 'specs/data/box-external-resources/box-external-resources.mtl'; + +var diffuseTexturePath = 'specs/data/box-textured/cesium.png'; +var transparentDiffuseTexturePath = 'specs/data/box-complex-material/diffuse.png'; +var alphaTexturePath = 'specs/data/box-complex-material/alpha.png'; +var ambientTexturePath = 'specs/data/box-complex-material/ambient.gif'; +var normalTexturePath = 'specs/data/box-complex-material/bump.png'; +var emissiveTexturePath = 'specs/data/box-complex-material/emission.jpg'; +var specularTexturePath = 'specs/data/box-complex-material/specular.jpeg'; +var specularShininessTexturePath = 'specs/data/box-complex-material/shininess.png'; + +var diffuseTexture; +var transparentDiffuseTexture; +var alphaTexture; +var ambientTexture; +var normalTexture; +var emissiveTexture; +var specularTexture; +var specularShininessTexture; + +var checkTransparencyOptions = { + checkTransparency : true +}; +var decodeOptions = { + decode : true +}; + +var options; describe('loadMtl', function() { - it('loads complex material', function(done) { - expect(loadMtl(complexMaterialUrl) + beforeAll(function(done) { + return Promise.all([ + loadTexture(diffuseTexturePath) + .then(function(texture) { + diffuseTexture = texture; + }), + loadTexture(transparentDiffuseTexturePath, checkTransparencyOptions) + .then(function(texture) { + transparentDiffuseTexture = texture; + }), + loadTexture(alphaTexturePath, decodeOptions) + .then(function(texture) { + alphaTexture = texture; + }), + loadTexture(ambientTexturePath) + .then(function(texture) { + ambientTexture = texture; + }), + loadTexture(normalTexturePath) + .then(function(texture) { + normalTexture = texture; + }), + loadTexture(emissiveTexturePath) + .then(function(texture) { + emissiveTexture = texture; + }), + loadTexture(specularTexturePath, decodeOptions) + .then(function(texture) { + specularTexture = texture; + }), + loadTexture(specularShininessTexturePath, decodeOptions) + .then(function(texture) { + specularShininessTexture = texture; + }) + ]).then(done); + }); + + beforeEach(function() { + options = clone(obj2gltf.defaults); + options.overridingTextures = {}; + options.logger = function() {}; + }); + + it('loads mtl', function(done) { + options.metallicRoughness = true; + expect(loadMtl(coloredMaterialPath, options) + .then(function(materials) { + expect(materials.length).toBe(1); + var material = materials[0]; + var pbr = material.pbrMetallicRoughness; + expect(pbr.baseColorTexture).toBeUndefined(); + expect(pbr.metallicRoughnessTexture).toBeUndefined(); + expect(pbr.baseColorFactor).toEqual([0.64, 0.64, 0.64, 1.0]); + expect(pbr.metallicFactor).toBe(0.5); + expect(pbr.roughnessFactor).toBe(96.078431); + expect(material.name).toBe('Material'); + expect(material.emissiveTexture).toBeUndefined(); + expect(material.normalTexture).toBeUndefined(); + expect(material.ambientTexture).toBeUndefined(); + expect(material.emissiveFactor).toEqual([0.0, 0.0, 0.1]); + expect(material.alphaMode).toBe('OPAQUE'); + expect(material.doubleSided).toBe(false); + }), done).toResolve(); + }); + + it('loads mtl with textures', function(done) { + options.metallicRoughness = true; + expect(loadMtl(texturedMaterialPath, options) .then(function(materials) { - var material = materials.Material; - expect(material).toBeDefined(); - expect(material.ambientColor).toEqual([0.2, 0.2, 0.2, 1.0]); - expect(material.emissionColor).toEqual([0.1, 0.1, 0.1, 1.0]); - expect(material.diffuseColor).toEqual([0.64, 0.64, 0.64, 1.0]); - expect(material.specularColor).toEqual([0.5, 0.5, 0.5, 1.0]); - expect(material.specularShininess).toEqual(96.078431); - expect(material.alpha).toEqual(0.9); - expect(material.ambientTexture).toEqual(getImagePath(complexMaterialUrl, 'ambient.gif')); - expect(material.emissionTexture).toEqual(getImagePath(complexMaterialUrl, 'emission.jpg')); - expect(material.diffuseTexture).toEqual(getImagePath(complexMaterialUrl, 'diffuse.png')); - expect(material.specularTexture).toEqual(getImagePath(complexMaterialUrl, 'specular.jpeg')); - expect(material.specularShininessMap).toEqual(getImagePath(complexMaterialUrl, 'shininess.png')); - expect(material.normalMap).toEqual(getImagePath(complexMaterialUrl, 'bump.png')); - expect(material.alphaMap).toEqual(getImagePath(complexMaterialUrl, 'alpha.png')); + expect(materials.length).toBe(1); + var material = materials[0]; + var pbr = material.pbrMetallicRoughness; + expect(pbr.baseColorTexture).toBeDefined(); + expect(pbr.metallicRoughnessTexture).toBeDefined(); + expect(pbr.baseColorFactor).toEqual([1.0, 1.0, 1.0, 0.9]); + expect(pbr.metallicFactor).toBe(1.0); + expect(pbr.roughnessFactor).toBe(1.0); + expect(material.name).toBe('Material'); + expect(material.emissiveTexture).toBeDefined(); + expect(material.normalTexture).toBeDefined(); + expect(material.occlusionTexture).toBeDefined(); + expect(material.emissiveFactor).toEqual([1.0, 1.0, 1.0]); + expect(material.alphaMode).toBe('BLEND'); + expect(material.doubleSided).toBe(true); }), done).toResolve(); }); it('loads mtl with multiple materials', function(done) { - expect(loadMtl(multipleMaterialsUrl) + options.metallicRoughness = true; + expect(loadMtl(multipleMaterialsPath, options) + .then(function(materials) { + expect(materials.length).toBe(3); + expect(materials[0].name).toBe('Blue'); + expect(materials[0].pbrMetallicRoughness.baseColorFactor).toEqual([0.0, 0.0, 0.64, 1.0]); + expect(materials[1].name).toBe('Green'); + expect(materials[1].pbrMetallicRoughness.baseColorFactor).toEqual([0.0, 0.64, 0.0, 1.0]); + expect(materials[2].name).toBe('Red'); + expect(materials[2].pbrMetallicRoughness.baseColorFactor).toEqual([0.64, 0.0, 0.0, 1.0]); + }), done).toResolve(); + }); + + it('sets overriding textures', function(done) { + spyOn(fsExtra, 'readFile').and.callThrough(); + options.overridingTextures = { + metallicRoughnessOcclusionTexture : alphaTexturePath, + baseColorTexture : alphaTexturePath, + emissiveTexture : emissiveTexturePath + }; + expect(loadMtl(texturedMaterialPath, options) + .then(function(materials) { + var material = materials[0]; + var pbr = material.pbrMetallicRoughness; + expect(pbr.baseColorTexture.name).toBe('alpha'); + expect(pbr.metallicRoughnessTexture.name).toBe('alpha'); + expect(material.emissiveTexture.name).toBe('emission'); + expect(material.normalTexture.name).toBe('bump'); + expect(fsExtra.readFile.calls.count()).toBe(3); + }), done).toResolve(); + }); + + it('loads texture outside of the mtl directory', function(done) { + expect(loadMtl(externalMaterialPath, options) .then(function(materials) { - expect(Object.keys(materials).length).toBe(3); - expect(materials.Red.diffuseColor).toEqual([0.64, 0.0, 0.0, 1.0]); - expect(materials.Green.diffuseColor).toEqual([0.0, 0.64, 0.0, 1.0]); - expect(materials.Blue.diffuseColor).toEqual([0.0, 0.0, 0.64, 1.0]); + var material = materials[0]; + var baseColorTexture = material.pbrMetallicRoughness.baseColorTexture; + expect(baseColorTexture.source).toBeDefined(); + expect(baseColorTexture.name).toBe('cesium'); }), done).toResolve(); }); + + it('does not load texture outside of the mtl directory when secure is true', function(done) { + var spy = jasmine.createSpy('logger'); + options.logger = spy; + options.secure = true; + + expect(loadMtl(externalMaterialPath, options) + .then(function(materials) { + var material = materials[0]; + var baseColorTexture = material.pbrMetallicRoughness.baseColorTexture; + expect(baseColorTexture).toBeUndefined(); + expect(spy.calls.argsFor(0)[0].indexOf('Could not read texture file') >= 0).toBe(true); + }), done).toResolve(); + }); + + describe('metallicRoughness', function() { + it('creates default material', function() { + var material = loadMtl._createMaterial(undefined, options); + var pbr = material.pbrMetallicRoughness; + expect(pbr.baseColorTexture).toBeUndefined(); + expect(pbr.metallicRoughnessTexture).toBeUndefined(); + expect(pbr.baseColorFactor).toEqual([0.5, 0.5, 0.5, 1.0]); + expect(pbr.metallicFactor).toBe(0.0); // No metallic + expect(pbr.roughnessFactor).toBe(1.0); // Fully rough + expect(material.emissiveTexture).toBeUndefined(); + expect(material.normalTexture).toBeUndefined(); + expect(material.ambientTexture).toBeUndefined(); + expect(material.emissiveFactor).toEqual([0.0, 0.0, 0.0]); + expect(material.alphaMode).toBe('OPAQUE'); + expect(material.doubleSided).toBe(false); + }); + + it('creates material with textures', function() { + options.metallicRoughness = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : diffuseTexture, + ambientTexture : ambientTexture, + normalTexture : normalTexture, + emissiveTexture : emissiveTexture, + specularTexture : specularTexture, + specularShininessTexture : specularShininessTexture + }, options); + + var pbr = material.pbrMetallicRoughness; + expect(pbr.baseColorTexture).toBeDefined(); + expect(pbr.metallicRoughnessTexture).toBeDefined(); + expect(pbr.baseColorFactor).toEqual([1.0, 1.0, 1.0, 1.0]); + expect(pbr.metallicFactor).toBe(1.0); + expect(pbr.roughnessFactor).toBe(1.0); + expect(material.emissiveTexture).toBeDefined(); + expect(material.normalTexture).toBeDefined(); + expect(material.occlusionTexture).toBeDefined(); + expect(material.emissiveFactor).toEqual([1.0, 1.0, 1.0]); + expect(material.alphaMode).toBe('OPAQUE'); + expect(material.doubleSided).toBe(false); + }); + + it('packs occlusion in metallic roughness texture', function() { + options.metallicRoughness = true; + options.packOcclusion = true; + + var material = loadMtl._createMaterial({ + ambientTexture : alphaTexture, + specularTexture : specularTexture, + specularShininessTexture : specularShininessTexture + }, options); + + var pbr = material.pbrMetallicRoughness; + expect(pbr.metallicRoughnessTexture).toBeDefined(); + expect(pbr.metallicRoughnessTexture).toBe(material.occlusionTexture); + }); + + it('does not create metallic roughness texture if decoded texture data is not available', function() { + options.metallicRoughness = true; + options.packOcclusion = true; + + var material = loadMtl._createMaterial({ + ambientTexture : ambientTexture, // Is a .gif which can't be decoded + specularTexture : specularTexture, + specularShininessTexture : specularShininessTexture + }, options); + + var pbr = material.pbrMetallicRoughness; + expect(pbr.metallicRoughnessTexture).toBeUndefined(); + expect(material.occlusionTexture).toBeUndefined(); + }); + + it('sets material for transparent diffuse texture', function() { + options.metallicRoughness = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : transparentDiffuseTexture + }, options); + expect(material.alphaMode).toBe('BLEND'); + expect(material.doubleSided).toBe(true); + }); + }); + + describe('specularGlossiness', function() { + it('creates default material', function() { + options.specularGlossiness = true; + var material = loadMtl._createMaterial(undefined, options); + var pbr = material.extensions.KHR_materials_pbrSpecularGlossiness; + expect(pbr.diffuseTexture).toBeUndefined(); + expect(pbr.specularGlossinessTexture).toBeUndefined(); + expect(pbr.diffuseFactor).toEqual([0.5, 0.5, 0.5, 1.0]); + expect(pbr.specularFactor).toEqual([0.0, 0.0, 0.0]); // No specular color + expect(pbr.glossinessFactor).toEqual(0.0); // Rough surface + expect(material.emissiveTexture).toBeUndefined(); + expect(material.normalTexture).toBeUndefined(); + expect(material.occlusionTexture).toBeUndefined(); + expect(material.emissiveFactor).toEqual([0.0, 0.0, 0.0]); + expect(material.alphaMode).toBe('OPAQUE'); + expect(material.doubleSided).toBe(false); + }); + + it('creates material with textures', function() { + options.specularGlossiness = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : diffuseTexture, + ambientTexture : ambientTexture, + normalTexture : normalTexture, + emissiveTexture : emissiveTexture, + specularTexture : specularTexture, + specularShininessTexture : specularShininessTexture + }, options); + + var pbr = material.extensions.KHR_materials_pbrSpecularGlossiness; + expect(pbr.diffuseTexture).toBeDefined(); + expect(pbr.specularGlossinessTexture).toBeDefined(); + expect(pbr.diffuseFactor).toEqual([1.0, 1.0, 1.0, 1.0]); + expect(pbr.specularFactor).toEqual([1.0, 1.0, 1.0]); + expect(pbr.glossinessFactor).toEqual(1.0); + expect(material.emissiveTexture).toBeDefined(); + expect(material.normalTexture).toBeDefined(); + expect(material.occlusionTexture).toBeDefined(); + expect(material.emissiveFactor).toEqual([1.0, 1.0, 1.0]); + expect(material.alphaMode).toBe('OPAQUE'); + expect(material.doubleSided).toBe(false); + }); + + it('does not create specular glossiness texture if decoded texture data is not available', function() { + options.specularGlossiness = true; + + var material = loadMtl._createMaterial({ + specularTexture : ambientTexture, // Is a .gif which can't be decoded + specularShininessTexture : specularShininessTexture + }, options); + + var pbr = material.extensions.KHR_materials_pbrSpecularGlossiness; + expect(pbr.specularGlossinessTexture).toBeUndefined(); + }); + + it('sets material for transparent diffuse texture', function() { + options.specularGlossiness = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : transparentDiffuseTexture + }, options); + + expect(material.alphaMode).toBe('BLEND'); + expect(material.doubleSided).toBe(true); + }); + }); + + describe('materialsCommon', function() { + it('creates default material', function() { + options.materialsCommon = true; + var material = loadMtl._createMaterial(undefined, options); + var extension = material.extensions.KHR_materials_common; + var values = extension.values; + expect(extension.technique).toBe('LAMBERT'); + expect(extension.transparent).toBe(false); + expect(extension.doubleSided).toBe(false); + expect(values.ambient).toEqual([0.0, 0.0, 0.0, 1.0]); + expect(values.diffuse).toEqual([0.5, 0.5, 0.5, 1.0]); + expect(values.emission).toEqual([0.0, 0.0, 0.0, 1.0]); + expect(values.specular).toEqual([0.0, 0.0, 0.0, 1.0]); + expect(values.shininess).toEqual(0); + expect(values.transparency).toBe(1.0); + expect(values.transparent).toBe(false); + expect(values.doubleSided).toBe(false); + }); + + it('creates material with textures', function() { + options.materialsCommon = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : diffuseTexture, + ambientTexture : ambientTexture, + normalTexture : normalTexture, + emissiveTexture : emissiveTexture, + specularTexture : specularTexture, + specularShininessTexture : specularShininessTexture + }, options); + + var extension = material.extensions.KHR_materials_common; + var values = extension.values; + expect(extension.technique).toBe('LAMBERT'); + expect(extension.transparent).toBe(false); + expect(extension.doubleSided).toBe(false); + expect(values.ambient instanceof Texture).toBe(true); + expect(values.diffuse instanceof Texture).toBe(true); + expect(values.emission instanceof Texture).toBe(true); + expect(values.specular instanceof Texture).toBe(true); + expect(values.shininess).toEqual(0); + expect(values.transparency).toBe(1.0); + expect(values.transparent).toBe(false); + expect(values.doubleSided).toBe(false); + }); + + it('sets material for alpha less than 1', function() { + options.materialsCommon = true; + + var material = loadMtl._createMaterial({ + alpha : 0.4 + }, options); + + var values = material.extensions.KHR_materials_common.values; + expect(values.diffuse).toEqual([0.5, 0.5, 0.5, 0.4]); + expect(values.transparency).toBe(1.0); + expect(values.transparent).toBe(true); + expect(values.doubleSided).toBe(true); + }); + + it('sets material for diffuse texture and alpha less than 1', function() { + options.materialsCommon = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : diffuseTexture, + alpha : 0.4 + }, options); + + var values = material.extensions.KHR_materials_common.values; + + expect(values.diffuse instanceof Texture).toBe(true); + expect(values.transparency).toBe(0.4); + expect(values.transparent).toBe(true); + expect(values.doubleSided).toBe(true); + }); + + it('sets material for transparent diffuse texture', function() { + options.materialsCommon = true; + + var material = loadMtl._createMaterial({ + diffuseTexture : transparentDiffuseTexture + }, options); + + var values = material.extensions.KHR_materials_common.values; + + expect(values.diffuse instanceof Texture).toBe(true); + expect(values.transparency).toBe(1.0); + expect(values.transparent).toBe(true); + expect(values.doubleSided).toBe(true); + }); + + it('sets material for specular', function() { + options.materialsCommon = true; + + var material = loadMtl._createMaterial({ + specularColor : [0.1, 0.1, 0.2, 1], + specularShininess : 0.1 + }, options); + + var extension = material.extensions.KHR_materials_common; + var values = extension.values; + + expect(extension.technique).toBe('PHONG'); + expect(values.specular).toEqual([0.1, 0.1, 0.2, 1]); + expect(values.shininess).toEqual(0.1); + }); + + it('ambient of [1, 1, 1] is treated as [0, 0, 0]', function() { + options.materialsCommon = true; + + var material = loadMtl._createMaterial({ + ambientColor : [1.0, 1.0, 1.0, 1.0] + }, options); + + var values = material.extensions.KHR_materials_common.values; + expect(values.ambient).toEqual([0.0, 0.0, 0.0, 1.0]); + }); + }); }); diff --git a/specs/lib/loadObjSpec.js b/specs/lib/loadObjSpec.js index a43930da..a2c3c10c 100644 --- a/specs/lib/loadObjSpec.js +++ b/specs/lib/loadObjSpec.js @@ -1,38 +1,34 @@ 'use strict'; var Cesium = require('cesium'); -var path = require('path'); var Promise = require('bluebird'); var loadObj = require('../../lib/loadObj'); var obj2gltf = require('../../lib/obj2gltf'); -var Cartesian3 = Cesium.Cartesian3; var clone = Cesium.clone; var RuntimeError = Cesium.RuntimeError; -var objUrl = 'specs/data/box/box.obj'; -var objRotatedUrl = 'specs/data/box-rotated/box-rotated.obj'; -var objNormalsUrl = 'specs/data/box-normals/box-normals.obj'; -var objUvsUrl = 'specs/data/box-uvs/box-uvs.obj'; -var objPositionsOnlyUrl = 'specs/data/box-positions-only/box-positions-only.obj'; -var objNegativeIndicesUrl = 'specs/data/box-negative-indices/box-negative-indices.obj'; -var objTrianglesUrl = 'specs/data/box-triangles/box-triangles.obj'; -var objObjectsUrl = 'specs/data/box-objects/box-objects.obj'; -var objGroupsUrl = 'specs/data/box-groups/box-groups.obj'; -var objObjectsGroupsUrl = 'specs/data/box-objects-groups/box-objects-groups.obj'; -var objConcaveUrl = 'specs/data/concave/concave.obj'; -var objUsemtlUrl = 'specs/data/box-usemtl/box-usemtl.obj'; -var objNoMaterialsUrl = 'specs/data/box-no-materials/box-no-materials.obj'; -var objMultipleMaterialsUrl = 'specs/data/box-multiple-materials/box-multiple-materials.obj'; -var objUncleanedUrl = 'specs/data/box-uncleaned/box-uncleaned.obj'; -var objMtllibUrl = 'specs/data/box-mtllib/box-mtllib.obj'; -var objMissingMtllibUrl = 'specs/data/box-missing-mtllib/box-missing-mtllib.obj'; -var objExternalResourcesUrl = 'specs/data/box-external-resources/box-external-resources.obj'; -var objTexturedUrl = 'specs/data/box-textured/box-textured.obj'; -var objMissingTextureUrl = 'specs/data/box-missing-texture/box-missing-texture.obj'; -var objSubdirectoriesUrl = 'specs/data/box-subdirectories/box-textured.obj'; -var objComplexMaterialUrl = 'specs/data/box-complex-material/box-complex-material.obj'; -var objInvalidContentsUrl = 'specs/data/box/box.mtl'; -var objInvalidUrl = 'invalid.obj'; +var objPath = 'specs/data/box/box.obj'; +var objNormalsPath = 'specs/data/box-normals/box-normals.obj'; +var objUvsPath = 'specs/data/box-uvs/box-uvs.obj'; +var objPositionsOnlyPath = 'specs/data/box-positions-only/box-positions-only.obj'; +var objNegativeIndicesPath = 'specs/data/box-negative-indices/box-negative-indices.obj'; +var objTrianglesPath = 'specs/data/box-triangles/box-triangles.obj'; +var objObjectsPath = 'specs/data/box-objects/box-objects.obj'; +var objGroupsPath = 'specs/data/box-groups/box-groups.obj'; +var objObjectsGroupsPath = 'specs/data/box-objects-groups/box-objects-groups.obj'; +var objUsemtlPath = 'specs/data/box-usemtl/box-usemtl.obj'; +var objNoMaterialsPath = 'specs/data/box-no-materials/box-no-materials.obj'; +var objMultipleMaterialsPath = 'specs/data/box-multiple-materials/box-multiple-materials.obj'; +var objUncleanedPath = 'specs/data/box-uncleaned/box-uncleaned.obj'; +var objMtllibPath = 'specs/data/box-mtllib/box-mtllib.obj'; +var objMissingMtllibPath = 'specs/data/box-missing-mtllib/box-missing-mtllib.obj'; +var objExternalResourcesPath = 'specs/data/box-external-resources/box-external-resources.obj'; +var objTexturedPath = 'specs/data/box-textured/box-textured.obj'; +var objMissingTexturePath = 'specs/data/box-missing-texture/box-missing-texture.obj'; +var objSubdirectoriesPath = 'specs/data/box-subdirectories/box-textured.obj'; +var objInvalidContentsPath = 'specs/data/box/box.mtl'; +var objConcavePath = 'specs/data/concave/concave.obj'; +var objInvalidPath = 'invalid.obj'; function getMeshes(data) { var meshes = []; @@ -58,28 +54,26 @@ function getPrimitives(data) { return primitives; } -function getImagePath(objPath, relativePath) { - return path.resolve(path.dirname(objPath), relativePath); -} - -var defaultOptions = obj2gltf.defaults; +var options; describe('loadObj', function() { beforeEach(function() { - spyOn(console, 'log'); + options = clone(obj2gltf.defaults); + options.overridingTextures = {}; + options.logger = function() {}; }); it('loads obj with positions, normals, and uvs', function(done) { - expect(loadObj(objUrl, defaultOptions) + expect(loadObj(objPath, options) .then(function(data) { - var images = data.images; var materials = data.materials; var nodes = data.nodes; + var name = data.name; var meshes = getMeshes(data); var primitives = getPrimitives(data); - expect(Object.keys(images).length).toBe(0); - expect(materials.Material).toBeDefined(); + expect(name).toBe('box'); + expect(materials.length).toBe(1); expect(nodes.length).toBe(1); expect(meshes.length).toBe(1); expect(primitives.length).toBe(1); @@ -99,7 +93,7 @@ describe('loadObj', function() { }); it('loads obj with normals', function(done) { - expect(loadObj(objNormalsUrl, defaultOptions) + expect(loadObj(objNormalsPath, options) .then(function(data) { var mesh = getMeshes(data)[0]; expect(mesh.positions.length / 3).toBe(24); @@ -109,7 +103,7 @@ describe('loadObj', function() { }); it('loads obj with uvs', function(done) { - expect(loadObj(objUvsUrl, defaultOptions) + expect(loadObj(objUvsPath, options) .then(function(data) { var mesh = getMeshes(data)[0]; expect(mesh.positions.length / 3).toBe(20); @@ -120,8 +114,8 @@ describe('loadObj', function() { it('loads obj with negative indices', function(done) { expect(Promise.all([ - loadObj(objPositionsOnlyUrl, defaultOptions), - loadObj(objNegativeIndicesUrl, defaultOptions) + loadObj(objPositionsOnlyPath, options), + loadObj(objNegativeIndicesPath, options) ]) .then(function(results) { var positionsReference = getMeshes(results[0])[0].positions.toFloatBuffer(); @@ -131,7 +125,7 @@ describe('loadObj', function() { }); it('loads obj with triangle faces', function(done) { - expect(loadObj(objTrianglesUrl, defaultOptions) + expect(loadObj(objTrianglesPath, options) .then(function(data) { var mesh = getMeshes(data)[0]; var primitive = getPrimitives(data)[0]; @@ -141,7 +135,7 @@ describe('loadObj', function() { }); it('loads obj with objects', function(done) { - expect(loadObj(objObjectsUrl, defaultOptions) + expect(loadObj(objObjectsPath, options) .then(function(data) { var nodes = data.nodes; expect(nodes.length).toBe(3); @@ -158,7 +152,7 @@ describe('loadObj', function() { }); it('loads obj with groups', function(done) { - expect(loadObj(objGroupsUrl, defaultOptions) + expect(loadObj(objGroupsPath, options) .then(function(data) { var nodes = data.nodes; expect(nodes.length).toBe(3); @@ -175,7 +169,7 @@ describe('loadObj', function() { }); it('loads obj with objects and groups', function(done) { - expect(loadObj(objObjectsGroupsUrl, defaultOptions) + expect(loadObj(objObjectsGroupsPath, options) .then(function(data) { var nodes = data.nodes; expect(nodes.length).toBe(3); @@ -198,7 +192,7 @@ describe('loadObj', function() { }); it('loads obj with concave face containing 5 vertices', function(done) { - expect(loadObj(objConcaveUrl, defaultOptions) + expect(loadObj(objConcavePath, options) .then(function(data) { var mesh = getMeshes(data)[0]; var primitive = getPrimitives(data)[0]; @@ -208,7 +202,7 @@ describe('loadObj', function() { }); it('loads obj with usemtl only', function(done) { - expect(loadObj(objUsemtlUrl, defaultOptions) + expect(loadObj(objUsemtlPath, options) .then(function(data) { var nodes = data.nodes; expect(nodes.length).toBe(1); @@ -227,7 +221,7 @@ describe('loadObj', function() { }); it('loads obj with no materials', function(done) { - expect(loadObj(objNoMaterialsUrl, defaultOptions) + expect(loadObj(objNoMaterialsPath, options) .then(function(data) { var nodes = data.nodes; expect(nodes.length).toBe(1); @@ -240,7 +234,7 @@ describe('loadObj', function() { it('loads obj with multiple materials', function(done) { // The usemtl markers are interleaved, but should condense to just three primitives - expect(loadObj(objMultipleMaterialsUrl, defaultOptions) + expect(loadObj(objMultipleMaterialsPath, options) .then(function(data) { var nodes = data.nodes; expect(nodes.length).toBe(1); @@ -260,7 +254,7 @@ describe('loadObj', function() { it('loads obj uncleaned', function(done) { // Obj with extraneous o, g, and usemtl lines // Also tests handling of o and g lines with the same names - expect(loadObj(objUncleanedUrl, defaultOptions) + expect(loadObj(objUncleanedPath, options) .then(function(data) { var nodes = data.nodes; var meshes = getMeshes(data); @@ -276,136 +270,98 @@ describe('loadObj', function() { }); it('loads obj with multiple mtllibs', function(done) { - expect(loadObj(objMtllibUrl, defaultOptions) + expect(loadObj(objMtllibPath, options) .then(function(data) { var materials = data.materials; - expect(Object.keys(materials).length).toBe(3); - expect(materials.Red.diffuseColor).toEqual([0.64, 0.0, 0.0, 1.0]); - expect(materials.Green.diffuseColor).toEqual([0.0, 0.64, 0.0, 1.0]); - expect(materials.Blue.diffuseColor).toEqual([0.0, 0.0, 0.64, 1.0]); + expect(materials.length).toBe(3); + + // .mtl files are loaded in an arbitrary order, so sort for testing purposes + materials.sort(function(a, b){ + return a.name.localeCompare(b.name); + }); + + expect(materials[0].name).toBe('Blue'); + expect(materials[0].pbrMetallicRoughness.baseColorFactor).toEqual([0.0, 0.0, 0.64, 1.0]); + expect(materials[1].name).toBe('Green'); + expect(materials[1].pbrMetallicRoughness.baseColorFactor).toEqual([0.0, 0.64, 0.0, 1.0]); + expect(materials[2].name).toBe('Red'); + expect(materials[2].pbrMetallicRoughness.baseColorFactor).toEqual([0.64, 0.0, 0.0, 1.0]); }), done).toResolve(); }); it('loads obj with missing mtllib', function(done) { - expect(loadObj(objMissingMtllibUrl, defaultOptions) + var spy = jasmine.createSpy('logger'); + options.logger = spy; + + expect(loadObj(objMissingMtllibPath, options) .then(function(data) { - expect(data.materials).toEqual({}); - expect(console.log.calls.argsFor(0)[0].indexOf('Could not read mtl file') >= 0).toBe(true); + expect(data.materials.length).toBe(0); + expect(spy.calls.argsFor(0)[0].indexOf('Could not read mtl file') >= 0).toBe(true); }), done).toResolve(); }); - it('loads resources outside of the obj directory', function(done) { - expect(loadObj(objExternalResourcesUrl, defaultOptions) + it('loads .mtl outside of the obj directory', function(done) { + expect(loadObj(objExternalResourcesPath, options) .then(function(data) { - var imagePath = getImagePath(objTexturedUrl, 'cesium.png'); - expect(data.images[imagePath]).toBeDefined(); - expect(data.materials.MaterialTextured.diffuseTexture).toEqual(imagePath); + var materials = data.materials; + expect(materials.length).toBe(2); + + // .mtl files are loaded in an arbitrary order, so find the "MaterialTextured" material + var materialTextured = materials[0].name === 'MaterialTextured' ? materials[0] : materials[1]; + var baseColorTexture = materialTextured.pbrMetallicRoughness.baseColorTexture; + expect(baseColorTexture.source).toBeDefined(); + expect(baseColorTexture.name).toEqual('cesium'); }), done).toResolve(); }); - it('does not load resources outside of the obj directory when secure is true', function(done) { - var options = clone(defaultOptions); + it('does not load .mtl outside of the obj directory when secure is true', function(done) { + var spy = jasmine.createSpy('logger'); + options.logger = spy; options.secure = true; - expect(loadObj(objExternalResourcesUrl, options) + expect(loadObj(objExternalResourcesPath, options) .then(function(data) { - var imagePath = getImagePath(objMissingTextureUrl, 'cesium.png'); - expect(data.images[imagePath]).toBeUndefined(); - expect(data.materials.MaterialTextured).toBeDefined(); - expect(data.materials.Material).toBeUndefined(); // Not in directory, so not included - expect(console.log.calls.argsFor(0)[0].indexOf('Could not read mtl file') >= 0).toBe(true); - expect(console.log.calls.argsFor(1)[0].indexOf('Could not read image file') >= 0).toBe(true); + expect(data.materials.length).toBe(1); // obj references 2 materials, one of which is outside the input directory + expect(spy.calls.argsFor(0)[0].indexOf('Could not read mtl file') >= 0).toBe(true); + expect(spy.calls.argsFor(1)[0].indexOf('Could not read texture file') >= 0).toBe(true); }), done).toResolve(); }); it('loads obj with texture', function(done) { - expect(loadObj(objTexturedUrl, defaultOptions) + expect(loadObj(objTexturedPath, options) .then(function(data) { - var imagePath = getImagePath(objTexturedUrl, 'cesium.png'); - expect(data.images[imagePath]).toBeDefined(); - expect(data.materials.Material.diffuseTexture).toEqual(imagePath); + var baseColorTexture = data.materials[0].pbrMetallicRoughness.baseColorTexture; + expect(baseColorTexture.name).toBe('cesium'); + expect(baseColorTexture.source).toBeDefined(); }), done).toResolve(); }); it('loads obj with missing texture', function(done) { - expect(loadObj(objMissingTextureUrl, defaultOptions) - .then(function(data) { - var imagePath = getImagePath(objMissingTextureUrl, 'cesium.png'); - expect(data.images[imagePath]).toBeUndefined(); - expect(data.materials.Material.diffuseTexture).toEqual(imagePath); - expect(console.log.calls.argsFor(0)[0].indexOf('Could not read image file') >= 0).toBe(true); - }), done).toResolve(); - }); + var spy = jasmine.createSpy('logger'); + options.logger = spy; - it('loads obj with subdirectories', function(done) { - expect(loadObj(objSubdirectoriesUrl, defaultOptions) + expect(loadObj(objMissingTexturePath, options) .then(function(data) { - var imagePath = getImagePath(objSubdirectoriesUrl, path.join('materials', 'images', 'cesium.png')); - expect(data.images[imagePath]).toBeDefined(); - expect(data.materials.Material.diffuseTexture).toEqual(imagePath); + var baseColorTexture = data.materials[0].pbrMetallicRoughness.baseColorTexture; + expect(baseColorTexture).toBeUndefined(); + expect(spy.calls.argsFor(0)[0].indexOf('Could not read texture file') >= 0).toBe(true); }), done).toResolve(); }); - it('loads obj with complex material', function(done) { - expect(loadObj(objComplexMaterialUrl, defaultOptions) - .then(function(data) { - var images = data.images; - expect(Object.keys(images).length).toBe(4); // Only ambient, diffuse, emission, and specular maps are supported by the converter - }), done).toResolve(); - }); - - function getFirstPosition(data) { - var positions = data.nodes[0].meshes[0].positions; - return new Cartesian3(positions.get(0), positions.get(1), positions.get(2)); - } - - function getFirstNormal(data) { - var normals = data.nodes[0].meshes[0].normals; - return new Cartesian3(normals.get(0), normals.get(1), normals.get(2)); - } - - function checkAxisConversion(inputUpAxis, outputUpAxis, position, normal) { - var sameAxis = (inputUpAxis === outputUpAxis); - var options = clone(defaultOptions); - options.inputUpAxis = inputUpAxis; - options.outputUpAxis = outputUpAxis; - return loadObj(objRotatedUrl, options) - .then(function(data) { - var rotatedPosition = getFirstPosition(data); - var rotatedNormal = getFirstNormal(data); - if (sameAxis) { - expect(rotatedPosition).toEqual(position); - expect(rotatedNormal).toEqual(normal); - } else { - expect(rotatedPosition).not.toEqual(position); - expect(rotatedNormal).not.toEqual(normal); - } - }); - } - - it('performs up axis conversion', function(done) { - expect(loadObj(objRotatedUrl, defaultOptions) + it('loads obj with subdirectories', function(done) { + expect(loadObj(objSubdirectoriesPath, options) .then(function(data) { - var position = getFirstPosition(data); - var normal = getFirstNormal(data); - - var axes = ['X', 'Y', 'Z']; - var axesLength = axes.length; - var promises = []; - for (var i = 0; i < axesLength; ++i) { - for (var j = 0; j < axesLength; ++j) { - promises.push(checkAxisConversion(axes[i], axes[j], position, normal)); - } - } - return Promise.all(promises); + var baseColorTexture = data.materials[0].pbrMetallicRoughness.baseColorTexture; + expect(baseColorTexture.name).toBe('cesium'); + expect(baseColorTexture.source).toBeDefined(); }), done).toResolve(); }); it('throws when file has invalid contents', function(done) { - expect(loadObj(objInvalidContentsUrl, defaultOptions), done).toRejectWith(RuntimeError); + expect(loadObj(objInvalidContentsPath, options), done).toRejectWith(RuntimeError); }); it('throw when reading invalid file', function(done) { - expect(loadObj(objInvalidUrl, defaultOptions), done).toRejectWith(Error); + expect(loadObj(objInvalidPath, options), done).toRejectWith(Error); }); }); diff --git a/specs/lib/loadTextureSpec.js b/specs/lib/loadTextureSpec.js new file mode 100644 index 00000000..b8e33b9c --- /dev/null +++ b/specs/lib/loadTextureSpec.js @@ -0,0 +1,117 @@ +'use strict'; +var loadTexture = require('../../lib/loadTexture'); + +var pngTexturePath = 'specs/data/box-complex-material/shininess.png'; +var jpgTexturePath = 'specs/data/box-complex-material/emission.jpg'; +var jpegTexturePath = 'specs/data/box-complex-material/specular.jpeg'; +var gifTexturePath = 'specs/data/box-complex-material/ambient.gif'; +var grayscaleTexturePath = 'specs/data/box-complex-material/alpha.png'; +var transparentTexturePath = 'specs/data/box-complex-material/diffuse.png'; + +describe('loadTexture', function() { + it('loads png texture', function(done) { + expect(loadTexture(pngTexturePath) + .then(function(texture) { + expect(texture.transparent).toBe(false); + expect(texture.source).toBeDefined(); + expect(texture.name).toBe('shininess'); + expect(texture.extension).toBe('.png'); + expect(texture.path).toBe(pngTexturePath); + expect(texture.pixels).toBeUndefined(); + expect(texture.width).toBeUndefined(); + expect(texture.height).toBeUndefined(); + }), done).toResolve(); + }); + + it('loads jpg texture', function(done) { + expect(loadTexture(jpgTexturePath) + .then(function(texture) { + expect(texture.transparent).toBe(false); + expect(texture.source).toBeDefined(); + expect(texture.name).toBe('emission'); + expect(texture.extension).toBe('.jpg'); + expect(texture.path).toBe(jpgTexturePath); + expect(texture.pixels).toBeUndefined(); + expect(texture.width).toBeUndefined(); + expect(texture.height).toBeUndefined(); + }), done).toResolve(); + }); + + it('loads jpeg texture', function(done) { + expect(loadTexture(jpegTexturePath) + .then(function(texture) { + expect(texture.transparent).toBe(false); + expect(texture.source).toBeDefined(); + expect(texture.name).toBe('specular'); + expect(texture.extension).toBe('.jpeg'); + expect(texture.path).toBe(jpegTexturePath); + expect(texture.pixels).toBeUndefined(); + expect(texture.width).toBeUndefined(); + expect(texture.height).toBeUndefined(); + }), done).toResolve(); + }); + + it('loads gif texture', function(done) { + expect(loadTexture(gifTexturePath) + .then(function(texture) { + expect(texture.transparent).toBe(false); + expect(texture.source).toBeDefined(); + expect(texture.name).toBe('ambient'); + expect(texture.extension).toBe('.gif'); + expect(texture.path).toBe(gifTexturePath); + expect(texture.pixels).toBeUndefined(); + expect(texture.width).toBeUndefined(); + expect(texture.height).toBeUndefined(); + }), done).toResolve(); + }); + + it('loads grayscale texture', function(done) { + expect(loadTexture(grayscaleTexturePath) + .then(function(texture) { + expect(texture.transparent).toBe(false); + expect(texture.source).toBeDefined(); + expect(texture.extension).toBe('.png'); + }), done).toResolve(); + }); + + it('loads texture with alpha channel', function(done) { + expect(loadTexture(transparentTexturePath) + .then(function(texture) { + expect(texture.transparent).toBe(false); + }), done).toResolve(); + }); + + it('loads texture with checkTransparency flag', function(done) { + var options = { + checkTransparency : true + }; + expect(loadTexture(transparentTexturePath, options) + .then(function(texture) { + expect(texture.transparent).toBe(true); + }), done).toResolve(); + }); + + it('loads and decodes png', function(done) { + var options = { + decode : true + }; + expect(loadTexture(pngTexturePath, options) + .then(function(texture) { + expect(texture.pixels).toBeDefined(); + expect(texture.width).toBe(211); + expect(texture.height).toBe(211); + }), done).toResolve(); + }); + + it('loads and decodes jpeg', function(done) { + var options = { + decode : true + }; + expect(loadTexture(jpegTexturePath, options) + .then(function(texture) { + expect(texture.pixels).toBeDefined(); + expect(texture.width).toBe(211); + expect(texture.height).toBe(211); + }), done).toResolve(); + }); +}); diff --git a/specs/lib/obj2gltfSpec.js b/specs/lib/obj2gltfSpec.js index 6ef53b0b..be16cf2d 100644 --- a/specs/lib/obj2gltfSpec.js +++ b/specs/lib/obj2gltfSpec.js @@ -1,141 +1,185 @@ 'use strict'; var fsExtra = require('fs-extra'); -var GltfPipeline = require('gltf-pipeline').Pipeline; -var os = require('os'); var path = require('path'); var Promise = require('bluebird'); var obj2gltf = require('../../lib/obj2gltf'); -var objPath = 'specs/data/box-textured/box-textured.obj'; -var gltfPath = 'specs/data/box-textured/box-textured.gltf'; -var glbPath = 'specs/data/box-textured/box-textured.glb'; -var objPathNonExistent = 'specs/data/non-existent.obj'; +var texturedObjPath = 'specs/data/box-textured/box-textured.obj'; +var complexObjPath = 'specs/data/box-complex-material/box-complex-material.obj'; +var missingMtllibObjPath = 'specs/data/box-missing-mtllib/box-missing-mtllib.obj'; -describe('obj2gltf', function() { - var tempDirectory; - - beforeAll(function() { - expect(obj2gltf._getTempDirectory()).toContain(os.tmpdir()); - tempDirectory = path.join(os.tmpdir(), 'testPath'); - spyOn(obj2gltf, '_getTempDirectory').and.returnValue(tempDirectory); - spyOn(fsExtra, 'outputJson'); - spyOn(fsExtra, 'outputFile'); - spyOn(fsExtra, 'remove'); - }); +var outputDirectory = 'output'; +var textureUrl = 'specs/data/box-textured/cesium.png'; + +describe('obj2gltf', function() { beforeEach(function() { - spyOn(GltfPipeline, 'processJSONToDisk').and.returnValue(Promise.resolve()); + spyOn(fsExtra, 'outputFile').and.returnValue(Promise.resolve()); }); - it('converts an obj to gltf', function(done) { - expect(obj2gltf(objPath, gltfPath) - .then(function() { - var args = GltfPipeline.processJSONToDisk.calls.first().args; - var gltf = args[0]; - var outputPath = args[1]; - var options = args[2]; - expect(path.normalize(outputPath)).toEqual(path.normalize(gltfPath)); + it('converts obj to gltf', function(done) { + expect(obj2gltf(texturedObjPath) + .then(function(gltf) { expect(gltf).toBeDefined(); - expect(gltf.images.cesium).toBeDefined(); - expect(options).toEqual({ - basePath : tempDirectory, - createDirectory : false, - binary : false, - embed : true, - embedImage : true, - encodeNormals : false, - quantize : false, - compressTextureCoordinates : false, - aoOptions : undefined, - kmcOptions : undefined, - smoothNormals : false, - optimizeForCesium : false, - textureCompressionOptions : undefined, - preserve : true - }); + expect(gltf.images.length).toBe(1); }), done).toResolve(); }); - it('sets options', function(done) { - var textureCompressionOptions = { - format : 'dxt1', - quality : 10 + it('converts obj to glb', function(done) { + var options = { + binary : true }; + expect(obj2gltf(texturedObjPath, options) + .then(function(glb) { + var magic = glb.toString('utf8', 0, 4); + expect(magic).toBe('glTF'); + }), done).toResolve(); + }); + + it('convert obj to gltf with separate resources', function(done) { var options = { - binary : true, separate : true, separateTextures : true, - compress : true, - optimize : true, - optimizeForCesium : true, - generateNormals : true, - ao : true, - kmc : true, - textureCompressionOptions : textureCompressionOptions, - checkTransparency : true, - secure : true, - inputUpAxis : 'Z', - outputUpAxis : 'X', - logger : obj2gltf.defaults.logger + outputDirectory : outputDirectory }; + expect(obj2gltf(texturedObjPath, options) + .then(function() { + expect(fsExtra.outputFile.calls.count()).toBe(2); // Saves out .png and .bin + }), done).toResolve(); + }); - expect(obj2gltf(objPath, gltfPath, options) + it('converts obj to glb with separate resources', function(done) { + var options = { + separate : true, + separateTextures : true, + outputDirectory : outputDirectory, + binary : true + }; + expect(obj2gltf(texturedObjPath, options) .then(function() { - var args = GltfPipeline.processJSONToDisk.calls.first().args; - var options = args[2]; - expect(options).toEqual({ - basePath : tempDirectory, - createDirectory : false, - binary : true, - embed : false, - embedImage : false, - encodeNormals : true, - quantize : true, - compressTextureCoordinates : true, - aoOptions : {}, - kmcOptions : {}, - smoothNormals : true, - optimizeForCesium : true, - textureCompressionOptions : textureCompressionOptions, - preserve : false - }); expect(fsExtra.outputFile.calls.count()).toBe(2); // Saves out .png and .bin }), done).toResolve(); }); - it('saves as binary if gltfPath has a .glb extension', function(done) { - expect(obj2gltf(objPath, glbPath) + it('converts obj with multiple textures', function(done) { + var options = { + separateTextures : true, + outputDirectory : outputDirectory + }; + expect(obj2gltf(complexObjPath, options) .then(function() { - var args = GltfPipeline.processJSONToDisk.calls.first().args; - var options = args[2]; - expect(options.binary).toBe(true); + expect(fsExtra.outputFile.calls.count()).toBe(5); // baseColor, metallicRoughness, occlusion, emission, normal }), done).toResolve(); }); - it('bypassPipeline flag bypasses gltf-pipeline', function(done) { + it('sets overriding textures (1)', function(done) { var options = { - bypassPipeline : true + overridingTextures : { + metallicRoughnessOcclusionTexture : textureUrl, + normalTexture : textureUrl, + baseColorTexture : textureUrl, + emissiveTexture : textureUrl + }, + separateTextures : true, + outputDirectory : outputDirectory }; - expect(obj2gltf(objPath, gltfPath, options) + expect(obj2gltf(complexObjPath, options) .then(function() { - expect(fsExtra.outputJson).toHaveBeenCalled(); - expect(GltfPipeline.processJSONToDisk).not.toHaveBeenCalled(); + var args = fsExtra.outputFile.calls.allArgs(); + var length = args.length; + for (var i = 0; i < length; ++i) { + expect(path.basename(args[i][0])).toBe(path.basename(textureUrl)); + } }), done).toResolve(); }); - it('rejects if obj path does not exist', function(done) { - expect(obj2gltf(objPathNonExistent, gltfPath), done).toRejectWith(Error); + it('sets overriding textures (2)', function(done) { + var options = { + overridingTextures : { + specularGlossinessTexture : textureUrl, + occlusionTexture : textureUrl, + normalTexture : textureUrl, + baseColorTexture : textureUrl, + emissiveTexture : textureUrl + }, + separateTextures : true, + outputDirectory : outputDirectory + }; + expect(obj2gltf(complexObjPath, options) + .then(function() { + var args = fsExtra.outputFile.calls.allArgs(); + var length = args.length; + for (var i = 0; i < length; ++i) { + expect(path.basename(args[i][0])).toBe(path.basename(textureUrl)); + } + }), done).toResolve(); + }); + + it('uses a custom logger', function(done) { + var lastMessage; + var options = { + logger : function(message) { + lastMessage = message; + } + }; + expect(obj2gltf(missingMtllibObjPath, options) + .then(function() { + expect(lastMessage.indexOf('Could not read mtl file') >= 0).toBe(true); + }), done).toResolve(); + }); + + it('uses a custom writer', function(done) { + var filePaths = []; + var fileContents = []; + var options = { + separate : true, + writer : function(relativePath, contents) { + filePaths.push(relativePath); + fileContents.push(contents); + } + }; + expect(obj2gltf(texturedObjPath, options) + .then(function() { + expect(filePaths).toEqual(['box-textured.bin', 'cesium.png']); + expect(fileContents[0]).toBeDefined(); + expect(fileContents[1]).toBeDefined(); + }), done).toResolve(); }); it('throws if objPath is undefined', function() { expect(function() { - obj2gltf(undefined, gltfPath); + obj2gltf(undefined); + }).toThrowDeveloperError(); + }); + + it('throws if both options.writer and options.outputDirectory are undefined when writing separate resources', function() { + var options = { + separateTextures : true + }; + expect(function() { + obj2gltf(texturedObjPath, options); }).toThrowDeveloperError(); }); - it('rejects if gltfPath is undefined', function() { + it('throws if more than one material type is set', function() { + var options = { + metallicRoughness : true, + specularGlossiness : true + }; + expect(function() { + obj2gltf(texturedObjPath, options); + }).toThrowDeveloperError(); + }); + + it('throws if metallicRoughnessOcclusionTexture and specularGlossinessTexture are both defined', function() { + var options = { + overridingTextures : { + metallicRoughnessOcclusionTexture : textureUrl, + specularGlossinessTexture : textureUrl + } + }; expect(function() { - obj2gltf(objPath, undefined); + obj2gltf(texturedObjPath, options); }).toThrowDeveloperError(); }); });