obj2gltf/lib/loadObj.js

686 lines
25 KiB
JavaScript

'use strict';
var Cesium = require('cesium');
var path = require('path');
var Promise = require('bluebird');
var ArrayStorage = require('./ArrayStorage');
var loadImage = require('./loadImage');
var loadMtl = require('./loadMtl');
var readLines = require('./readLines');
var Axis = Cesium.Axis;
var Cartesian3 = Cesium.Cartesian3;
var ComponentDatatype = Cesium.ComponentDatatype;
var CoplanarPolygonGeometryLibrary = Cesium.CoplanarPolygonGeometryLibrary;
var defaultValue = Cesium.defaultValue;
var defined = Cesium.defined;
var Matrix4 = Cesium.Matrix4;
var PolygonPipeline = Cesium.PolygonPipeline;
var RuntimeError = Cesium.RuntimeError;
var WindingOrder = Cesium.WindingOrder;
module.exports = loadObj;
// Object name (o) -> node
// Group name (g) -> mesh
// Material name (usemtl) -> primitive
function Node() {
this.name = undefined;
this.meshes = [];
}
function Mesh() {
this.name = undefined;
this.primitives = [];
}
function Primitive() {
this.material = undefined;
this.indices = new ArrayStorage(ComponentDatatype.UNSIGNED_INT);
this.positions = new ArrayStorage(ComponentDatatype.FLOAT);
this.normals = new ArrayStorage(ComponentDatatype.FLOAT);
this.uvs = new ArrayStorage(ComponentDatatype.FLOAT);
}
// OBJ regex patterns are modified from ThreeJS (https://github.com/mrdoob/three.js/blob/master/examples/js/loaders/OBJLoader.js)
var vertexPattern = /v( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+|\-|e|E]+)/; // v float float float
var normalPattern = /vn( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+|\-|e|E]+)( +[\d|\.|\+|\-|e|E]+)/; // vn float float float
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.
*
* @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);
var uvs = new ArrayStorage(ComponentDatatype.FLOAT);
// The current node, mesh, and primitive
var node;
var mesh;
var primitive;
var activeMaterial;
// All nodes seen in the obj
var nodes = [];
// Used to build the indices. The vertex cache is unique to each primitive.
var vertexCache = {};
var vertexCacheLimit = 1000000;
var vertexCacheCount = 0;
var vertexCount = 0;
// All mtl paths seen in the obj
var mtlPaths = [];
// Buffers for face data that spans multiple lines
var lineBuffer = '';
// Used for parsing face data
var faceVertices = [];
var facePositions = [];
var faceUvs = [];
var faceNormals = [];
function clearVertexCache() {
vertexCache = {};
vertexCacheCount = 0;
}
function getName(name) {
return (name === '' ? undefined : name);
}
function addNode(name) {
node = new Node();
node.name = getName(name);
nodes.push(node);
addMesh();
}
function addMesh(name) {
mesh = new Mesh();
mesh.name = getName(name);
node.meshes.push(mesh);
addPrimitive();
}
function addPrimitive() {
primitive = new Primitive();
primitive.material = activeMaterial;
mesh.primitives.push(primitive);
// Clear the vertex cache for each new primitive
clearVertexCache();
vertexCount = 0;
}
function reusePrimitive(callback) {
var primitives = mesh.primitives;
var primitivesLength = primitives.length;
for (var i = 0; i < primitivesLength; ++i) {
if (primitives[i].material === activeMaterial) {
if (!defined(callback) || callback(primitives[i])) {
primitive = primitives[i];
clearVertexCache();
vertexCount = primitive.positions.length / 3;
return;
}
}
}
addPrimitive();
}
function useMaterial(name) {
activeMaterial = getName(name);
reusePrimitive();
}
function faceAndPrimitiveMatch(uvs, normals, primitive) {
var faceHasUvs = uvs[0].length > 0;
var faceHasNormals = normals[0].length > 0;
var primitiveHasUvs = primitive.uvs.length > 0;
var primitiveHasNormals = primitive.normals.length > 0;
return primitiveHasUvs === faceHasUvs && primitiveHasNormals === faceHasNormals;
}
function checkPrimitive(uvs, normals) {
var firstFace = primitive.indices.length === 0;
if (!firstFace && !faceAndPrimitiveMatch(uvs, normals, primitive)) {
reusePrimitive(function(primitive) {
return faceAndPrimitiveMatch(uvs, normals, primitive);
});
}
}
function getOffset(a, attributeData, components) {
var i = parseInt(a);
if (i < 0) {
// Negative vertex indexes reference the vertices immediately above it
return (attributeData.length / components + i) * components;
}
return (i - 1) * components;
}
function createVertex(p, u, n) {
// Positions
if (p.length > 0) {
var pi = getOffset(p, positions, 3);
var px = positions.get(pi + 0);
var py = positions.get(pi + 1);
var pz = positions.get(pi + 2);
primitive.positions.push(px);
primitive.positions.push(py);
primitive.positions.push(pz);
}
// Normals
if (n.length > 0) {
var ni = getOffset(n, normals, 3);
var nx = normals.get(ni + 0);
var ny = normals.get(ni + 1);
var nz = normals.get(ni + 2);
primitive.normals.push(nx);
primitive.normals.push(ny);
primitive.normals.push(nz);
}
// UVs
if (u.length > 0) {
var ui = getOffset(u, uvs, 2);
var ux = uvs.get(ui + 0);
var uy = uvs.get(ui + 1);
primitive.uvs.push(ux);
primitive.uvs.push(uy);
}
}
function addVertex(v, p, u, n) {
var index = vertexCache[v];
if (!defined(index)) {
index = vertexCount++;
vertexCache[v] = index;
createVertex(p, u, n);
// Prevent the vertex cache from growing too large. As a result of clearing the cache there
// may be some duplicate vertices.
vertexCacheCount++;
if (vertexCacheCount > vertexCacheLimit) {
clearVertexCache();
}
}
return index;
}
function getPosition(index, result) {
var pi = getOffset(index, positions, 3);
var px = positions.get(pi + 0);
var py = positions.get(pi + 1);
var pz = positions.get(pi + 2);
return Cartesian3.fromElements(px, py, pz, result);
}
function getNormal(index, result) {
var ni = getOffset(index, normals, 3);
var nx = normals.get(ni + 0);
var ny = normals.get(ni + 1);
var nz = normals.get(ni + 2);
return Cartesian3.fromElements(nx, ny, nz, result);
}
var scratch1 = new Cartesian3();
var scratch2 = new Cartesian3();
var scratch3 = new Cartesian3();
var scratch4 = new Cartesian3();
var scratch5 = new Cartesian3();
var scratchCenter = new Cartesian3();
var scratchAxis1 = new Cartesian3();
var scratchAxis2 = new Cartesian3();
var scratchNormal = new Cartesian3();
var scratchPositions = [new Cartesian3(), new Cartesian3(), new Cartesian3(), new Cartesian3()];
var scratchVertexIndices = [];
var scratchPoints = [];
function checkWindingCorrect(positionIndex1, positionIndex2, positionIndex3, normalIndex) {
if (normalIndex.length === 0) {
// If no face normal, we have to assume the winding is correct.
return true;
}
var normal = getNormal(normalIndex, scratchNormal);
var A = getPosition(positionIndex1, scratch1);
var B = getPosition(positionIndex2, scratch2);
var C = getPosition(positionIndex3, scratch3);
var BA = Cartesian3.subtract(B, A, scratch4);
var CA = Cartesian3.subtract(C, A, scratch5);
var cross = Cartesian3.cross(BA, CA, scratch3);
return (Cartesian3.dot(normal, cross) >= 0);
}
function addTriangle(index1, index2, index3, correctWinding) {
if (correctWinding) {
primitive.indices.push(index1);
primitive.indices.push(index2);
primitive.indices.push(index3);
} else {
primitive.indices.push(index1);
primitive.indices.push(index3);
primitive.indices.push(index2);
}
}
function addFace(vertices, positions, uvs, normals) {
var i;
var isWindingCorrect;
checkPrimitive(uvs, normals);
if (vertices.length === 3) {
isWindingCorrect = checkWindingCorrect(positions[0], positions[1], positions[2], normals[0]);
var index1 = addVertex(vertices[0], positions[0], uvs[0], normals[0]);
var index2 = addVertex(vertices[1], positions[1], uvs[1], normals[1]);
var index3 = addVertex(vertices[2], positions[2], uvs[2], normals[2]);
addTriangle(index1, index2, index3, isWindingCorrect);
} else { // Triangulate if the face is not a triangle
var points = scratchPoints;
var vertexIndices = scratchVertexIndices;
points.length = 0;
vertexIndices.length = 0;
for (i = 0; i < vertices.length; ++i) {
var index = addVertex(vertices[i], positions[i], uvs[i], normals[i]);
vertexIndices.push(index);
if (i === scratchPositions.length) {
scratchPositions.push(new Cartesian3());
}
points.push(getPosition(positions[i], scratchPositions[i]));
}
var validGeometry = CoplanarPolygonGeometryLibrary.computeProjectTo2DArguments(points, scratchCenter, scratchAxis1, scratchAxis2);
if (!validGeometry) {
return;
}
var projectPoints = CoplanarPolygonGeometryLibrary.createProjectPointsTo2DFunction(scratchCenter, scratchAxis1, scratchAxis2);
var points2D = projectPoints(points);
var indices = PolygonPipeline.triangulate(points2D);
isWindingCorrect = PolygonPipeline.computeWindingOrder2D(points2D) !== WindingOrder.CLOCKWISE;
for (i = 0; i < indices.length - 2; i += 3) {
addTriangle(vertexIndices[indices[i]], vertexIndices[indices[i+1]], vertexIndices[indices[i+2]], isWindingCorrect);
}
}
}
function parseLine(line) {
line = line.trim();
var result;
if ((line.length === 0) || (line.charAt(0) === '#')) {
// Don't process empty lines or comments
} else if (/^o\s/i.test(line)) {
var objectName = line.substring(2).trim();
addNode(objectName);
} else if (/^g\s/i.test(line)) {
var groupName = line.substring(2).trim();
addMesh(groupName);
} else if (/^usemtl\s/i.test(line)) {
var materialName = line.substring(7).trim();
useMaterial(materialName);
} else if (/^mtllib/i.test(line)) {
var mtllibLine = line.substring(7).trim();
mtlPaths = mtlPaths.concat(getMtlPaths(mtllibLine));
} 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);
} else if ((result = normalPattern.exec(line) ) !== null) {
var normal = Cartesian3.fromElements(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]), scratchNormal);
if (Cartesian3.equals(normal, Cartesian3.ZERO)) {
Cartesian3.clone(Cartesian3.UNIT_Z, normal);
} else {
Cartesian3.normalize(normal, normal);
}
if (defined(axisTransform)) {
Matrix4.multiplyByPointAsVector(axisTransform, normal, normal);
}
normals.push(normal.x);
normals.push(normal.y);
normals.push(normal.z);
} 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
} else { // face line or invalid line
// Because face lines can contain n vertices, we use a line buffer in case the face data spans multiple lines.
// If there's a line continuation don't create face yet
if (line.slice(-1) === '\\') {
lineBuffer += line.substring(0, line.length-1);
return;
}
lineBuffer += line;
if (lineBuffer.substring(0, 2) === 'f ') {
while ((result = facePattern.exec(lineBuffer)) !== null) {
faceVertices.push(result[0]);
facePositions.push(result[1]);
faceUvs.push(result[2]);
faceNormals.push(result[3]);
}
if (faceVertices.length > 2) {
addFace(faceVertices, facePositions, faceUvs, faceNormals);
}
faceVertices.length = 0;
facePositions.length = 0;
faceNormals.length = 0;
faceUvs.length = 0;
}
lineBuffer = '';
}
}
// Create a default node in case there are no o/g/usemtl lines in the obj
addNode();
// Parse the obj file
return readLines(objPath, parseLine)
.then(function() {
// Unload resources
positions = undefined;
normals = undefined;
uvs = undefined;
// Load materials and images
return finishLoading(nodes, mtlPaths, objPath, defined(activeMaterial), options);
});
}
function getMtlPaths(mtllibLine) {
// Handle paths with spaces. E.g. mtllib my material file.mtl
var mtlPaths = [];
var splits = mtllibLine.split(' ');
var length = splits.length;
var startIndex = 0;
for (var i = 0; i < length; ++i) {
if (path.extname(splits[i]) !== '.mtl') {
continue;
}
var mtlPath = splits.slice(startIndex, i + 1).join(' ');
mtlPaths.push(mtlPath);
startIndex = i + 1;
}
return mtlPaths;
}
function finishLoading(nodes, mtlPaths, objPath, usesMaterials, options) {
nodes = cleanNodes(nodes);
if (nodes.length === 0) {
throw new RuntimeError(objPath + ' does not have any geometry data');
}
return loadMtls(mtlPaths, objPath, options)
.then(function(materials) {
if (Object.keys(materials).length > 0 && !usesMaterials) {
assignDefaultMaterial(nodes, materials);
}
var imagePaths = getImagePaths(materials);
return loadImages(imagePaths, objPath, options)
.then(function(images) {
return {
nodes : nodes,
materials : materials,
images : images
};
});
});
}
function normalizeMtlPath(mtlPath, objDirectory) {
mtlPath = mtlPath.replace(/\\/g, '/');
return path.normalize(path.join(objDirectory, mtlPath));
}
function outsideDirectory(file, directory) {
return (path.relative(directory, file).indexOf('..') === 0);
}
function loadMtls(mtlPaths, objPath, options) {
var objDirectory = path.dirname(objPath);
var materials = {};
// Remove duplicates
mtlPaths = mtlPaths.filter(function(value, index, self) {
return self.indexOf(value) === index;
});
return Promise.map(mtlPaths, function(mtlPath) {
mtlPath = normalizeMtlPath(mtlPath, objDirectory);
var shallowPath = path.join(objDirectory, path.basename(mtlPath));
if (options.secure && outsideDirectory(mtlPath, objDirectory)) {
// Try looking for the .mtl in the same directory as the obj
options.logger('The material file is outside of the obj directory and the secure flag is true. Attempting to read the material file from within the obj directory instead.');
return loadMtl(shallowPath)
.then(function(materialsInMtl) {
Object.assign(materials, materialsInMtl);
})
.catch(function(error) {
options.logger(error.message);
options.logger('Could not read material file at ' + shallowPath + '. Using default material instead.');
});
}
return loadMtl(mtlPath)
.catch(function(error) {
// Try looking for the .mtl in the same directory as the obj
options.logger(error.message);
options.logger('Could not read material file at ' + mtlPath + '. Attempting to read the material file from within the obj directory instead.');
return loadMtl(shallowPath);
})
.then(function(materialsInMtl) {
Object.assign(materials, materialsInMtl);
})
.catch(function(error) {
options.logger(error.message);
options.logger('Could not read material file at ' + shallowPath + '. Using default material instead.');
});
}, {concurrency : 10})
.then(function() {
return materials;
});
}
function loadImagePath(imagePath, objPath, options) {
var objDirectory = path.dirname(objPath);
var shallowPath = path.join(objDirectory, path.basename(imagePath));
if (options.secure && outsideDirectory(imagePath, objDirectory)) {
// Try looking for the image in the same directory as the obj
options.logger('Image file is outside of the obj directory and the secure flag is true. Attempting to read the image file from within the obj directory instead.');
return loadImage(shallowPath, options)
.catch(function(error) {
options.logger(error.message);
options.logger('Could not read image file at ' + shallowPath + '. This image will be ignored');
});
}
return loadImage(imagePath, options)
.catch(function(error) {
// Try looking for the image in the same directory as the obj
options.logger(error.message);
options.logger('Could not read image file at ' + imagePath + '. Attempting to read the image file from within the obj directory instead.');
return loadImage(shallowPath, options);
})
.catch(function(error) {
options.logger(error.message);
options.logger('Could not read image file at ' + shallowPath + '. This image will be ignored.');
});
}
function loadImages(imagePaths, objPath, options) {
var images = {};
return Promise.map(imagePaths, function(imagePath) {
return loadImagePath(imagePath, objPath, options)
.then(function(image) {
images[imagePath] = image;
});
}, {concurrency : 10})
.then(function() {
return 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);
}
function assignDefaultMaterial(nodes, materials) {
var defaultMaterial = Object.keys(materials)[0];
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) {
var primitives = meshes[j].primitives;
var primitivesLength = primitives.length;
for (var k = 0; k < primitivesLength; ++k) {
var primitive = primitives[k];
primitive.material = defaultValue(primitive.material, defaultMaterial);
}
}
}
}
function removeEmptyMeshes(meshes) {
return meshes.filter(function(mesh) {
// Remove empty primitives
mesh.primitives = mesh.primitives.filter(function(primitive) {
return primitive.indices.length > 0 && primitive.positions.length > 0;
});
// Valid meshes must have at least one primitive
return (mesh.primitives.length > 0);
});
}
function meshesHaveNames(meshes) {
var meshesLength = meshes.length;
for (var i = 0; i < meshesLength; ++i) {
if (defined(meshes[i].name)) {
return true;
}
}
return false;
}
function removeEmptyNodes(nodes) {
var final = [];
var nodesLength = nodes.length;
for (var i = 0; i < nodesLength; ++i) {
var node = nodes[i];
var meshes = removeEmptyMeshes(node.meshes);
if (meshes.length === 0) {
continue;
}
node.meshes = meshes;
if (!defined(node.name) && meshesHaveNames(meshes)) {
// If the obj has groups (g) but not object groups (o) then convert meshes to nodes
var meshesLength = meshes.length;
for (var j = 0; j < meshesLength; ++j) {
var mesh = meshes[j];
var convertedNode = new Node();
convertedNode.name = mesh.name;
convertedNode.meshes = [mesh];
final.push(convertedNode);
}
} else {
final.push(node);
}
}
return final;
}
function setDefaultNames(items, defaultName, usedNames) {
var itemsLength = items.length;
for (var i = 0; i < itemsLength; ++i) {
var item = items[i];
var name = defaultValue(item.name, defaultName);
var occurrences = usedNames[name];
if (defined(occurrences)) {
usedNames[name]++;
name = name + '_' + occurrences;
} else {
usedNames[name] = 1;
}
item.name = name;
}
}
function setDefaults(nodes) {
var usedNames = {};
setDefaultNames(nodes, 'Node', usedNames);
var nodesLength = nodes.length;
for (var i = 0; i < nodesLength; ++i) {
var node = nodes[i];
setDefaultNames(node.meshes, node.name + '-Mesh', usedNames);
}
}
function cleanNodes(nodes) {
nodes = removeEmptyNodes(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;
}
}