diff --git a/src/webgl/loading.js b/src/webgl/loading.js index 51d12ced9b..2ae734d6a3 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -158,6 +158,56 @@ p5.prototype.loadModel = function(path,options) { model.gid = `${path}|${normalize}`; const self = this; + async function getMaterials(lines){ + const parsedMaterialPromises=[]; + + for (let i = 0; i < lines.length; i++) { + const mtllibMatch = lines[i].match(/^mtllib (.+)/); + if (mtllibMatch) { + let mtlPath=''; + const mtlFilename = mtllibMatch[1]; + const objPathParts = path.split('/'); + if(objPathParts.length > 1){ + objPathParts.pop(); + const objFolderPath = objPathParts.join('/'); + mtlPath = objFolderPath + '/' + mtlFilename; + }else{ + mtlPath = mtlFilename; + } + parsedMaterialPromises.push( + fileExists(mtlPath).then(exists => { + if (exists) { + return parseMtl(self, mtlPath); + } else { + console.warn(`MTL file not found or error in parsing; proceeding without materials: ${mtlPath}`); + return {}; + + } + }).catch(error => { + console.warn(`Error loading MTL file: ${mtlPath}`, error); + return {}; + }) + ); + } + } + try { + const parsedMaterials = await Promise.all(parsedMaterialPromises); + const materials= Object.assign({}, ...parsedMaterials); + return materials ; + } catch (error) { + return {}; + } + } + + + async function fileExists(url) { + try { + const response = await fetch(url, { method: 'HEAD' }); + return response.ok; + } catch (error) { + return false; + } + } if (fileType.match(/\.stl$/i)) { this.httpDo( path, @@ -188,31 +238,41 @@ p5.prototype.loadModel = function(path,options) { } else if (fileType.match(/\.obj$/i)) { this.loadStrings( path, - strings => { - parseObj(model, strings); + async lines => { + try{ + const parsedMaterials=await getMaterials(lines); - if (normalize) { - model.normalize(); - } - - if (flipU) { - model.flipU(); - } + parseObj(model, lines, parsedMaterials); - if (flipV) { - model.flipV(); + }catch (error) { + if (failureCallback) { + failureCallback(error); + } else { + p5._friendlyError('Error during parsing: ' + error.message); + } + return; } + finally{ + if (normalize) { + model.normalize(); + } + if (flipU) { + model.flipU(); + } + if (flipV) { + model.flipV(); + } - self._decrementPreload(); - if (typeof successCallback === 'function') { - successCallback(model); + self._decrementPreload(); + if (typeof successCallback === 'function') { + successCallback(model); + } } }, failureCallback ); } else { p5._friendlyFileLoadError(3, path); - if (failureCallback) { failureCallback(); } else { @@ -224,6 +284,52 @@ p5.prototype.loadModel = function(path,options) { return model; }; +function parseMtl(p5,mtlPath){ + return new Promise((resolve, reject)=>{ + let currentMaterial = null; + let materials= {}; + p5.loadStrings( + mtlPath, + lines => { + for (let line = 0; line < lines.length; ++line){ + const tokens = lines[line].trim().split(/\s+/); + if(tokens[0] === 'newmtl') { + const materialName = tokens[1]; + currentMaterial = materialName; + materials[currentMaterial] = {}; + }else if (tokens[0] === 'Kd'){ + //Diffuse color + materials[currentMaterial].diffuseColor = [ + parseFloat(tokens[1]), + parseFloat(tokens[2]), + parseFloat(tokens[3]) + ]; + } else if (tokens[0] === 'Ka'){ + //Ambient Color + materials[currentMaterial].ambientColor = [ + parseFloat(tokens[1]), + parseFloat(tokens[2]), + parseFloat(tokens[3]) + ]; + }else if (tokens[0] === 'Ks'){ + //Specular color + materials[currentMaterial].specularColor = [ + parseFloat(tokens[1]), + parseFloat(tokens[2]), + parseFloat(tokens[3]) + ]; + + }else if (tokens[0] === 'map_Kd') { + //Texture path + materials[currentMaterial].texturePath = tokens[1]; + } + } + resolve(materials); + },reject + ); + }); +} + /** * Parse OBJ lines into model. For reference, this is what a simple model of a * square might look like: @@ -235,7 +341,7 @@ p5.prototype.loadModel = function(path,options) { * * f 4 3 2 1 */ -function parseObj(model, lines) { +function parseObj(model, lines, materials= {}) { // OBJ allows a face to specify an index for a vertex (in the above example), // but it also allows you to specify a custom combination of vertex, UV // coordinate, and vertex normal. So, "3/4/3" would mean, "use vertex 3 with @@ -250,8 +356,14 @@ function parseObj(model, lines) { vt: [], vn: [] }; - const indexedVerts = {}; + + // Map from source index → Map of material → destination index + const usedVerts = {}; // Track colored vertices + let currentMaterial = null; + const coloredVerts = new Set(); //unique vertices with color + let hasColoredVertices = false; + let hasColorlessVertices = false; for (let line = 0; line < lines.length; ++line) { // Each line is a separate object (vertex, face, vertex normal, etc) // For each line, split it into tokens on whitespace. The first token @@ -259,7 +371,10 @@ function parseObj(model, lines) { const tokens = lines[line].trim().split(/\b\s+/); if (tokens.length > 0) { - if (tokens[0] === 'v' || tokens[0] === 'vn') { + if (tokens[0] === 'usemtl') { + // Switch to a new material + currentMaterial = tokens[1]; + }else if (tokens[0] === 'v' || tokens[0] === 'vn') { // Check if this line describes a vertex or vertex normal. // It will have three numeric parameters. const vertex = new p5.Vector( @@ -280,40 +395,44 @@ function parseObj(model, lines) { // OBJ faces can have more than three points. Triangulate points. for (let tri = 3; tri < tokens.length; ++tri) { const face = []; - const vertexTokens = [1, tri - 1, tri]; for (let tokenInd = 0; tokenInd < vertexTokens.length; ++tokenInd) { // Now, convert the given token into an index const vertString = tokens[vertexTokens[tokenInd]]; - let vertIndex = 0; + let vertParts=vertString.split('/'); // TODO: Faces can technically use negative numbers to refer to the // previous nth vertex. I haven't seen this used in practice, but // it might be good to implement this in the future. - if (indexedVerts[vertString] !== undefined) { - vertIndex = indexedVerts[vertString]; - } else { - const vertParts = vertString.split('/'); - for (let i = 0; i < vertParts.length; i++) { - vertParts[i] = parseInt(vertParts[i]) - 1; - } + for (let i = 0; i < vertParts.length; i++) { + vertParts[i] = parseInt(vertParts[i]) - 1; + } - vertIndex = indexedVerts[vertString] = model.vertices.length; - model.vertices.push(loadedVerts.v[vertParts[0]].copy()); - if (loadedVerts.vt[vertParts[1]]) { - model.uvs.push(loadedVerts.vt[vertParts[1]].slice()); - } else { - model.uvs.push([0, 0]); - } + if (!usedVerts[vertParts[0]]) { + usedVerts[vertParts[0]] = {}; + } - if (loadedVerts.vn[vertParts[2]]) { - model.vertexNormals.push(loadedVerts.vn[vertParts[2]].copy()); + if (usedVerts[vertParts[0]][currentMaterial] === undefined) { + const vertIndex = model.vertices.length; + model.vertices.push(loadedVerts.v[vertParts[0]].copy()); + model.uvs.push(loadedVerts.vt[vertParts[1]] ? + loadedVerts.vt[vertParts[1]].slice() : [0, 0]); + model.vertexNormals.push(loadedVerts.vn[vertParts[2]] ? + loadedVerts.vn[vertParts[2]].copy() : new p5.Vector()); + + usedVerts[vertParts[0]][currentMaterial] = vertIndex; + face.push(vertIndex); + if (currentMaterial + && materials[currentMaterial] + && materials[currentMaterial].diffuseColor) { + // Mark this vertex as colored + coloredVerts.add(loadedVerts.v[vertParts[0]]); //since a set would only push unique values } + } else { + face.push(usedVerts[vertParts[0]][currentMaterial]); } - - face.push(vertIndex); } if ( @@ -322,6 +441,23 @@ function parseObj(model, lines) { face[1] !== face[2] ) { model.faces.push(face); + //same material for all vertices in a particular face + if (currentMaterial + && materials[currentMaterial] + && materials[currentMaterial].diffuseColor) { + hasColoredVertices=true; + //flag to track color or no color model + hasColoredVertices = true; + const materialDiffuseColor = + materials[currentMaterial].diffuseColor; + for (let i = 0; i < face.length; i++) { + model.vertexColors.push(materialDiffuseColor[0]); + model.vertexColors.push(materialDiffuseColor[1]); + model.vertexColors.push(materialDiffuseColor[2]); + } + }else{ + hasColorlessVertices=true; + } } } } @@ -331,7 +467,10 @@ function parseObj(model, lines) { if (model.vertexNormals.length === 0) { model.computeNormals(); } - + if (hasColoredVertices === hasColorlessVertices) { + // If both are true or both are false, throw an error because the model is inconsistent + throw new Error('Model coloring is inconsistent. Either all vertices should have colors or none should.'); + } return model; } diff --git a/test/unit/assets/cube.obj b/test/unit/assets/cube.obj new file mode 100644 index 0000000000..ebb992ae1f --- /dev/null +++ b/test/unit/assets/cube.obj @@ -0,0 +1,19 @@ +# Simple Cube OBJ File + +# Vertices +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 1.0 1.0 0.0 +v 0.0 1.0 0.0 +v 0.0 0.0 1.0 +v 1.0 0.0 1.0 +v 1.0 1.0 1.0 +v 0.0 1.0 1.0 + +# Faces +f 1 2 3 4 +f 5 6 7 8 +f 1 5 8 4 +f 2 6 7 3 +f 4 3 7 8 +f 1 2 6 5 diff --git a/test/unit/assets/eg1.mtl b/test/unit/assets/eg1.mtl new file mode 100644 index 0000000000..773822a62c --- /dev/null +++ b/test/unit/assets/eg1.mtl @@ -0,0 +1,9 @@ +newmtl coloredMaterial +Ns 96.078431 +Ka 1.000000 1.000000 1.000000 +Kd 1.000000 0.000000 0.000000 # Only this material has a diffuse color +Ks 0.500000 0.500000 0.500000 +Ke 0.0 0.0 0.0 +Ni 1.000000 +d 1.000000 +illum 2 diff --git a/test/unit/assets/eg1.obj b/test/unit/assets/eg1.obj new file mode 100644 index 0000000000..9402ecc686 --- /dev/null +++ b/test/unit/assets/eg1.obj @@ -0,0 +1,16 @@ +mtllib eg1.mtl + +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0.5 0.5 1 + +# Define faces without material +f 1 2 5 +f 2 3 5 + +# Apply material to subsequent faces +usemtl coloredMaterial +f 1 4 5 +f 4 1 5 diff --git a/test/unit/assets/objMtlMissing.obj b/test/unit/assets/objMtlMissing.obj new file mode 100644 index 0000000000..11f482db19 --- /dev/null +++ b/test/unit/assets/objMtlMissing.obj @@ -0,0 +1,23 @@ +mtllib octa.mtl +v 1 0 0 +v 0 1 0 +v 0 0 1 +v -1 0 0 +v 0 -1 0 +v 0 0 -1 +usemtl m000001 +f 1 5 6 +usemtl m003627 +f 2 1 6 +usemtl m010778 +f 1 2 3 +usemtl m012003 +f 4 2 6 +usemtl m019240 +f 4 5 3 +usemtl m021392 +f 5 4 6 +usemtl m022299 +f 2 4 3 +usemtl m032767 +f 5 1 3 diff --git a/test/unit/assets/octa-color.mtl b/test/unit/assets/octa-color.mtl new file mode 100644 index 0000000000..344e5dc6b1 --- /dev/null +++ b/test/unit/assets/octa-color.mtl @@ -0,0 +1,72 @@ +newmtl m000001 +Ns 100 +Kd 0 0 0.5 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m003627 +Ns 100 +Kd 0 0 0.942654 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m010778 +Ns 100 +Kd 0 0.815632 1 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m012003 +Ns 100 +Kd 0 0.965177 1 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m019240 +Ns 100 +Kd 0.848654 1 0.151346 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m021392 +Ns 100 +Kd 1 0.888635 0 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m022299 +Ns 100 +Kd 1 0.77791 0 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + +newmtl m032767 +Ns 100 +Kd 0.5 0 0 +Ks 1 1 1 +Ka 0 0 0 +Ni 1 +d 1 +illum 2 + diff --git a/test/unit/assets/octa-color.obj b/test/unit/assets/octa-color.obj new file mode 100644 index 0000000000..7a69f088d0 --- /dev/null +++ b/test/unit/assets/octa-color.obj @@ -0,0 +1,23 @@ +mtllib octa-color.mtl +v 1 0 0 +v 0 1 0 +v 0 0 1 +v -1 0 0 +v 0 -1 0 +v 0 0 -1 +usemtl m000001 +f 1 5 6 +usemtl m003627 +f 2 1 6 +usemtl m010778 +f 1 2 3 +usemtl m012003 +f 4 2 6 +usemtl m019240 +f 4 5 3 +usemtl m021392 +f 5 4 6 +usemtl m022299 +f 2 4 3 +usemtl m032767 +f 5 1 3 diff --git a/test/unit/assets/plant.obj b/test/unit/assets/plant.obj new file mode 100644 index 0000000000..dcb7518df2 --- /dev/null +++ b/test/unit/assets/plant.obj @@ -0,0 +1,23 @@ +mtllib plant.mtl +v 1 0 0 +v 0 1 0 +v 0 0 1 +v -1 0 0 +v 0 -1 0 +v 0 0 -1 +usemtl m000001 +f 1 5 6 +usemtl m003627 +f 2 1 6 +usemtl m010778 +f 1 2 3 +usemtl m012003 +f 4 2 6 +usemtl m019240 +f 4 5 3 +usemtl m021392 +f 5 4 6 +usemtl m022299 +f 2 4 3 +usemtl m032767 +f 5 1 3 diff --git a/test/unit/io/loadModel.js b/test/unit/io/loadModel.js index 7c5ede56e9..3952ec5450 100644 --- a/test/unit/io/loadModel.js +++ b/test/unit/io/loadModel.js @@ -1,7 +1,10 @@ suite('loadModel', function() { var invalidFile = '404file'; var validFile = 'unit/assets/teapot.obj'; + var validObjFileforMtl='unit/assets/octa-color.obj'; var validSTLfile = 'unit/assets/ascii.stl'; + var inconsistentColorObjFile = 'unit/assets/eg1.obj'; + var objMtlMissing = 'unit/assets/objMtlMissing.obj'; var validSTLfileWithoutExtension = 'unit/assets/ascii'; test('_friendlyFileLoadError is called', async function() { @@ -75,8 +78,7 @@ suite('loadModel', function() { testSketchWithPromise('success callback is called', function( sketch, - resolve, - reject + done ) { var hasBeenCalled = false; sketch.preload = function() { @@ -84,22 +86,83 @@ suite('loadModel', function() { validFile, function() { hasBeenCalled = true; + done(); }, function(err) { - reject(new Error('Error callback was entered: ' + err)); + done(new Error('Error callback was entered: ' + err)); } ); }; sketch.setup = function() { if (!hasBeenCalled) { - reject(new Error('Setup called prior to success callback')); - } else { - setTimeout(resolve, 50); + done(new Error('Setup called prior to success callback')); } }; }); + test('loads OBJ file with associated MTL file correctly', async function(){ + const model = await promisedSketch(function (sketch,resolve,reject){ + sketch.preload=function(){ + sketch.loadModel(validObjFileforMtl,resolve,reject); + }; + }); + const expectedColors=[ + 0, 0, 0.5, + 0, 0, 0.5, + 0, 0, 0.5, + 0, 0, 0.942654, + 0, 0, 0.942654, + 0, 0, 0.942654, + 0, 0.815632, 1, + 0, 0.815632, 1, + 0, 0.815632, 1, + 0, 0.965177, 1, + 0, 0.965177, 1, + 0, 0.965177, 1, + 0.848654, 1, 0.151346, + 0.848654, 1, 0.151346, + 0.848654, 1, 0.151346, + 1, 0.888635, 0, + 1, 0.888635, 0, + 1, 0.888635, 0, + 1, 0.77791, 0, + 1, 0.77791, 0, + 1, 0.77791, 0, + 0.5, 0, 0, + 0.5, 0, 0, + 0.5, 0, 0 + ]; + assert.deepEqual(model.vertexColors,expectedColors); + }); + test('inconsistent vertex coloring throws error', async function() { + // Attempt to load the model and catch the error + let errorCaught = null; + try { + await promisedSketch(function(sketch, resolve, reject) { + sketch.preload = function() { + sketch.loadModel(inconsistentColorObjFile, resolve, reject); + }; + }); + } catch (error) { + errorCaught = error; + } + + // Assert that an error was caught and that it has the expected message + assert.instanceOf(errorCaught, Error, 'No error thrown for inconsistent vertex coloring'); + assert.equal(errorCaught.message, 'Model coloring is inconsistent. Either all vertices should have colors or none should.', 'Unexpected error message for inconsistent vertex coloring'); + }); + + test('missing MTL file shows OBJ model without vertexColors', async function() { + const model = await promisedSketch(function(sketch, resolve, reject) { + sketch.preload = function() { + sketch.loadModel(objMtlMissing, resolve, reject); + }; + }); + assert.instanceOf(model, p5.Geometry); + assert.equal(model.vertexColors.length, 0, 'Model should not have vertex colors'); + }); + test('returns an object with correct data', async function() { const model = await promisedSketch(function(sketch, resolve, reject) { var _model; diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index 0e1b7269e7..6b811807d8 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -80,4 +80,36 @@ visualSuite('WebGL', function() { screenshot(); }); }); -}); + + visualSuite('3DModel', function() { + visualTest('OBJ model with MTL file displays diffuse colors correctly', function(p5, screenshot) { + return new Promise(resolve => { + p5.createCanvas(50, 50, p5.WEBGL); + p5.loadModel('unit/assets/octa-color.obj', model => { + p5.background(255); + p5.rotateX(10 * 0.01); + p5.rotateY(10 * 0.01); + model.normalize(); + p5.model(model); + screenshot(); + resolve(); + }); + }); + }); + visualTest('Object with no colors takes on fill color', function(p5, screenshot) { + return new Promise(resolve => { + p5.createCanvas(50, 50, p5.WEBGL); + p5.loadModel('unit/assets/cube.obj', model => { + p5.background(255); + p5.fill('blue'); // Setting a fill color + p5.rotateX(p5.frameCount * 0.01); + p5.rotateY(p5.frameCount * 0.01); + model.normalize(); + p5.model(model); + screenshot(); + resolve(); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/3DModel/OBJ model with MTL file displays diffuse colors correctly/000.png b/test/unit/visual/screenshots/WebGL/3DModel/OBJ model with MTL file displays diffuse colors correctly/000.png new file mode 100644 index 0000000000..0426415f31 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/OBJ model with MTL file displays diffuse colors correctly/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/OBJ model with MTL file displays diffuse colors correctly/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/OBJ model with MTL file displays diffuse colors correctly/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/OBJ model with MTL file displays diffuse colors correctly/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/3DModel/Object with no colors takes on fill color/000.png b/test/unit/visual/screenshots/WebGL/3DModel/Object with no colors takes on fill color/000.png new file mode 100644 index 0000000000..5d7801aa04 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/Object with no colors takes on fill color/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/Object with no colors takes on fill color/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/Object with no colors takes on fill color/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/Object with no colors takes on fill color/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file