Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.mtl files support for .obj files to render color in 3D custom geometry #6710

Merged
merged 39 commits into from
Mar 19, 2024

Conversation

diyaayay
Copy link
Contributor

@diyaayay diyaayay commented Jan 7, 2024

Resolves #6670
This PR is a Work in Progress.
Review is requested.

Changes:

Screenshots of the change:
The output after loading materials before the model is loaded and assigning diffuse colors to vertexColors
The output contains parsedMaterials and model.vertexColors

image

image

PR Checklist

@diyaayay
Copy link
Contributor Author

diyaayay commented Jan 7, 2024

@davepagurek The error above in the screenshots occur only when an obj file with an mtl file associated with it is loaded.
I think the self._decrementPreload() in this case is where I'm unable to catch the correct instance of this to use and assign to self as a lot of asynchronous calls were made implement loading of materials before model.

The sketch used is:

let octa;

function preload() {
   octa = loadModel('octa-color.obj');
}

function setup() {
  createCanvas(400, 400, WEBGL);
}

function draw() {
  background(200);
  rotateX(frameCount * 0.01);
  rotateY(frameCount * 0.01);
  model(octa);
}

Requesting a review so that I can proceed further and also guidance in solving the current error.

@diyaayay
Copy link
Contributor Author

diyaayay commented Jan 7, 2024

Sorry for the commit messages, I thought I had already committed some other changes earlier.

Copy link
Contributor

@davepagurek davepagurek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work so far! I've left a few comments, some about the error you were running into, and some about how we build up the model after that. Let me know what you think!

return new Promise((resolve,reject)=>{
// console.log('parser is called');
let currentMaterial = null;
p5.prototype.loadStrings(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think rather than calling this on p5.prototype, which will have no this, you'd want to call loadStrings on a p5 instance. That could mean calling await parseMtl(this, mtlPath); inside loadModel and then call loadStrings on the first argument, although it might be a nicer separation of concerns to separate loading the strings and the parsing, so it becomes:

this.loadStrings(
  mtlPath,
  (mtlLines) => {
    const materials = parseMtl(mtlLines);
    parseObj(model, lines, materials)
  },
  () => {
    // handle failure
  }
)

Another benefit of this is that you maybe don't need parseMtl to resolve or reject, since the loadStrings part handles the case where it can't fetch the file, so parseMtl can look more like parseObj.

@@ -103,6 +103,8 @@ import './p5.Geometry';
* @param {String} [fileType]
* @return {p5.Geometry} the <a href="#/p5.Geometry">p5.Geometry</a> object
*/

let materials = {}; //currently local
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we use this btw? Looks like we always pass in materials as a parameter

Copy link
Contributor Author

@diyaayay diyaayay Jan 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes I had to remove it , missed it though.

if (normalize) {
model.normalize();
async lines => {
const mtlLine = lines.find(line => line.startsWith('mtllib '));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the obj spec supports multiple mtls, so we might need to filter to get all the lines that have mtllib and then parse all of them (maybe waiting for them all via Promise.all(...)?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought there could be just one mtl file to an obj file. After searching it through, yes there can be more. filter should be used . Thanks.

lines => {
for (let line = 0; line < lines.length; ++line){
const tokens = lines[line].trim().split(/\s+/);
if(tokens[0] === 'newmtl') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this parser, looks great!

currentMaterial = tokens[1];
if (currentMaterial && materials[currentMaterial]) {
const diffuseColor = materials[currentMaterial].diffuseColor;
model.vertexColors.push([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to add to the diffuse colors for each vertex, where currently this is adding to the array once for each usemtl line. So maybe that means just setting the currentMaterial in this if branch, and in the branch below where we add to loadedVerts, add to vertexColors based on the material.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, the logic in my head was a little haywire and that's why I added to the array to vertexColors for each usemtl . Got your point, thanks for explaining the changes to be made also.

diffuseColor[2]
]);
} else {
model.vertexColors.push([1, 1, 1]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking this through: when a model has vertex colors, then fill() has no effect on the model, unless you call .clearColors() on it. Currently, all imported models have no colors, so they take on whatever fill is used. We want to keep that behaviour for objs that have no materials, so maybe that means if no materials are present in the obj, rather than pushing to vertexColors, we just leave it empty.

This would make for a good unit test btw: testing that if you import an obj that has materials, it uses the material colors when you draw it with model(), and when you import an obj that has no materials, it draws using whatever the fill color is.

@@ -0,0 +1,23 @@
mtllib octa-color.mtl
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plant obj is 9mb, which might slow down tests. Maybe for the missing material test, we can make a duplicate of this colored obj, but change the path from octa-color.mtl to something that doesn't exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's what I had thought of too, made the same changes already for plant.obj and will push them asap.

@davepagurek
Copy link
Contributor

Btw just a heads up, looks like this test is failing:
image

Copy link
Contributor

@davepagurek davepagurek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, we also recently merged in support for visual tests. That might be a good fit here to check that vertex colors are visible on canvas when drawing.

mtlPath=tokens[1];
}
// Check if the MTL file has already been loaded
const existingMtl = mtlFiles.find(mtl => mtl.path === mtlPath);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bit of a race condition, since we only push to the array after awaiting an asynchronous function. Maybe you can synchronously push an object that just includes the path, and then after awaiting, add the materials to it? something like:

const file = { path: mtlPath };
mtlFiles.push(file);
const parsedMaterials = await parseMtl(self, mtlPath)
file.materials = parsedMaterials;

//Texture path
materials[currentMaterial].texturePath = tokens[1];
}
async function parseMtl(self,mtlPath){ //accepts mtlPath to load file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can call self p5 instead so it's clearer what its type should be?

async function parseMtl(self,mtlPath){ //accepts mtlPath to load file
let materials= {};
let currentMaterial = null;
self.loadStrings(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this function completes without waiting for the result of loadStrings now that it no longer returns a promise. So I think that means it will immediately return {}, and then asynchronously, properties will get added to it. I'm guessing it's important to have the complete materials object before continuing to parse .obj files later, so maybe it would make sense to add back in something like you had before?

const materials = {};
const doneLoading = new Promise((resolve, reject) => {
  self.loadStrings(
    mtlPath,
    lines => {
      // do parsing and add to materials here
      resolve();
    },
    reject
  )
});
await doneLoading;
return materials;

Copy link
Contributor Author

@diyaayay diyaayay Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davepagurek I changed my multiple mtl file support functionality to this:

else if (fileType.match(/\.obj$/i)) {
    this.loadStrings(
      path,
      async lines => {
        const parsedMaterialPromises=[];
        try{
          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;
              }
              const parsedMaterialsIndividual = await parseMtl(self, mtlPath);
              parsedMaterialPromises.push(parsedMaterialsIndividual);
            }
          }
          if(parsedMaterialPromises.length>0){
            const parsedMaterialsArray=
            await Promise.all(parsedMaterialPromises);
            const parsedMaterials = Object.assign({}, ...parsedMaterialsArray);
            await parseObj(model, lines, parsedMaterials);
          } }catch (error) {
          if (failureCallback) {
            failureCallback(error);
          } else {
            p5._friendlyError('Error during parsing: ' + error.message);
          }
        }
        finally{
          if (normalize) {
            model.normalize();
          }

          self._decrementPreload();
          if (typeof successCallback === 'function') {
            successCallback(model);
          }
        }
      },
      failureCallback
    );
  } else {
    p5._friendlyFileLoadError(3, path);
    if (failureCallback) {
      failureCallback();
    } else {
      p5._friendlyError(
        'Sorry, the file type is invalid. Only OBJ and STL files are supported.'
      );
    }
  }
  return model;
};

now single mtl file' materials are fully loaded first and then only passed to parseObj but for multiple files, materials are still being passed before fully loading thus the error: p5.js says: Error during parsing: Cannot read properties of undefined (reading 'diffuseColor')

In the earlier approach even after adding resolve, reject back and some other changes too, still the parseObj was called beforehand.

where is the logic breaking in the current code that single mtl files are passed after materials fully loading but not multiple mtl files? I'm trying to still find since I used Promise.all also.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i tried using a counter to ensure parseObj is called after parsedMaterials are loaded like this:

const parsedMaterialsIndividual = await parseMtl(self, mtlPath);
              mtlPaths.push(mtlPath);
              mtlfileno++;
              parsedMaterialPromises.push(parsedMaterialsIndividual);

and then,

const parsedMaterials =
            await Object.assign({}, ...parsedMaterialsArray);
            console.log(parsedMaterials);
            if(mtlfileno===mtlPaths.length){
              parseObj(model, lines, parsedMaterials);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few little things:

  • Right now you do const parsedMaterialsIndividual = await parseMtl(self, mtlPath);. By awaiting this, this means synchronously waiting for the material to be loaded/parsed, and the returned value is the actual result and not a promise. This works, but it means all mtl files are loaded one after another, and the Promise.all has no effect due to the results not being promises any more. If you remove the await on this line, the result is a promise, and the code only waits for them to finish at the Promise.all, meaning they all get loaded in parallel, which seems more like what you want.
  • It sounds like right now, awaiting parseMtl doesn't fully wait for it to be loaded. What does your parseMtl implementation look like right now? In the first comment in this chain, I had added some suggestions on how to update parseMtl to await for loading to complete, have you tried any of that out yet?

Let me know if I can help elaborate on any of those points!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davepagurek below is the parseMtl function:
you had suggested using doneLoading and this is how I'm currently implementing the function:

function parseMtl(p5,mtlPath){ //accepts mtlPath to load file
  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
    );
  });
}

@davepagurek davepagurek mentioned this pull request Jan 18, 2024
17 tasks
@@ -312,8 +410,24 @@ function parseObj(model, lines) {
model.vertexNormals.push(loadedVerts.vn[vertParts[2]].copy());
}
}
if (usedVerts[vertIndex] && usedVerts[vertIndex]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now I think we just store the last used material for each vertex. I think this means that if we reuse the same vertex first with material1, then with material2, then a third time with material1 again, we'll end up with three duplicates of the vertex instead of just two, since we make a new duplicate if the current material is not the same as the last used material.

Maybe instead of storing a map of source index → material, we can store a map from source index → another map of material → destination index. In that world, this if statement would check if usedVerts[vertIndex][currentMaterial] is defined yet, and create it if not. (This would maybe have to also absorb the else branch right above it maybe.) Then, below, we would add usedVerts[vertIndex][currentMaterial] to the current face.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I missed out on how I could have optimized the logic here and thus have lesser duplication like in the example you mentioned above. Will work on it now. Thanks for pointing this out. @davepagurek

@@ -331,7 +455,7 @@ function parseObj(model, lines) {
if (model.vertexNormals.length === 0) {
model.computeNormals();
}

console.log(model.vertexColors);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is for debugging, although we should probably take this out before merging.

@diyaayay
Copy link
Contributor Author

diyaayay commented Feb 1, 2024

@davepagurek Made a few changes in the duplication of vertices and the number of vertices in the model.vertices is lesser , only copying the vertex a minimum number of times as per the requirement, I think?

Copy link
Contributor

@davepagurek davepagurek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this! I think we're getting really close.

}
const duplicatedVertIndex = model.vertices.length;

if (usedVerts[vertParts[0]][currentMaterial] === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

materialDiffuseColor[1],
materialDiffuseColor[2]
]);
if (currentMaterial && materials[currentMaterial]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now this will break if we have some vertices that don't have a material but others that do (since it means the earlier vertices won't have pushed colors to the array.) Our data needs it to be all or nothing.

I don't think we need to actually support that case, but maybe we can try to detect it and throw an error if we encounter it? i.e. if we've added any vertices with no colors, and then find ourselves trying to add a vertex with color later on, throw an error letting people know they need colors for all their vertices or none.

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 => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the screenshot looks like just a dot. Maybe to make this clearer we can call model.normalize() to normalize its size to, I think, 200px?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can also add one more test importing an object with no colors, and then drawing it with a colored fill() to show that it can take on new colors.

@diyaayay
Copy link
Contributor Author

diyaayay commented Feb 2, 2024

@davepagurek visual test passes in the terminal but on localhost still shows some discrepancy.

@diyaayay
Copy link
Contributor Author

diyaayay commented Feb 4, 2024

@davepagurek Let me know what you think, what I've tried to do is for eg: if there are 6 unique vertices at 6 coordinate positions and all of them have color at least once, the model is valid. If none of them has color, the model is valid. Otherwise, throw an error for inconsistency.

src/webgl/loading.js Show resolved Hide resolved
src/webgl/loading.js Outdated Show resolved Hide resolved
@davepagurek
Copy link
Contributor

Great work on this so far! I think we're almost done -- I left some comments about a possible simplification, and a test for the error case. The last thing I can think of is, how should we handle it if e.g. you export an obj + mtl from Blender but only upload the obj? Right now we throw an error, but maybe it would be better to log a warning without actually throwing an error, and then treat it like it just has no materials? (Would that just involve parsing the obj in the catch block with no materials instead of rejecting the promise or would it be more complicated?)

@diyaayay
Copy link
Contributor Author

@davepagurek Have a look when you find the time. Let me know what else should be done, Thanks.

}
}catch (error) {
console.error(error.message);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to console.error here when we _friendlyError below?

Copy link
Contributor Author

@diyaayay diyaayay Feb 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes! I thought of it but was only checking all errors in console while making this work. Sorry for the oversight while pushing. I'll make the changes.

}
try {
if(await fileExists(mtlPath)){
const parsedMaterialsIndividual = await parseMtl(self, mtlPath);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By adding await here, we're waiting for each material to load one at a time rather than waiting for all of them to load in parallel at the Promise.all below on line 195. Does this still work if we take out the await on this line, given that we already wait for all of them later?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing await here would result in parallel loading of all mtl filled and then await with promise.all which is more efficient in handling all files together , better than sequential parsing of each mtl file.

mtlPath = mtlFilename;
}
try {
if(await fileExists(mtlPath)){
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This await will also cause us to do these HEAD requests one at a time instead of parallel. Maybe we can do something like:

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.');
      return {};
    }
  }).catch(error => {
    console.warn('Error loading MTL file:', error);
    return {};
  })
);

(Also, while we're at it, maybe we can add the URL for the mtl file that we couldn't find to the warning we log?)

}
async function fileExists(url) {
try {
const response = await fetch(url, { method: 'HEAD' });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@diyaayay
Copy link
Contributor Author

@davepagurek Does this seem better? Thanks for the suggestions.

Copy link
Contributor

@davepagurek davepagurek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your hard work on this and all the iterations you've made! I think this is good to go now!

Right now we're in the middle of releasing v1.9.1, and have a beta build that we're bug testing, so we're just merging in bug fixes until we finish the release. I've approved this PR though, so as soon as we finish the release, I'll merge this in!

@diyaayay
Copy link
Contributor Author

@davepagurek Had a great time working on this, as a university student this PR improved my vanilla javascript concepts a lot. Thank you for putting in all the time and reviewing it so many times✨

@davepagurek davepagurek mentioned this pull request Feb 22, 2024
3 tasks
@Qianqianye Qianqianye merged commit 316a5c0 into processing:main Mar 19, 2024
2 checks passed
@Qianqianye
Copy link
Contributor

Looks great! Thanks @diyaayay and @davepagurek!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

The Support for colored STL and OBJ files is not present.
3 participants