From 38f55df15d554bb28b8a393165dc84d488823cf1 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 17 Aug 2026 06:08:10 +0530 Subject: [PATCH 1/2] resolve windows style texture paths in mtl files --- src/webgl/loading.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/webgl/loading.js b/src/webgl/loading.js index 17f26b2604..cb29bae4bc 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -157,7 +157,12 @@ async function loadMaterialTextures(materials, modelPath, instance) { const slash = modelPath.lastIndexOf('/'); const folder = slash >= 0 ? modelPath.slice(0, slash) : ''; - const resolve = file => (folder ? `${folder}/${file}` : file); + // mtl files exported on windows can use backslashes, which mean nothing to a + // url, so swap them for the separator the fetch actually needs + const resolve = file => { + const path = file.replace(/\\/g, '/'); + return folder ? `${folder}/${path}` : path; + }; const jobs = []; for (const name in materials) { From b13305c700bba06a4267c4b86f24043f9686a6e6 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 17 Aug 2026 06:08:10 +0530 Subject: [PATCH 2/2] cover multi-material edge cases and model() parity --- test/unit/assets/duplicate_usemtl.mtl | 5 ++ test/unit/assets/duplicate_usemtl.obj | 17 ++++++ test/unit/assets/multi_material_2.mtl | 5 ++ test/unit/assets/multi_material_2.obj | 20 +++++++ test/unit/assets/no_mtl.obj | 5 ++ test/unit/assets/no_normals.mtl | 5 ++ test/unit/assets/no_normals.obj | 11 ++++ test/unit/assets/unknown_material.mtl | 2 + test/unit/assets/unknown_material.obj | 12 +++++ test/unit/assets/windows_path.mtl | 6 +++ test/unit/assets/windows_path.obj | 15 ++++++ test/unit/io/loadModel.js | 75 +++++++++++++++++++++++++++ test/unit/webgl/p5.Geometry.js | 46 ++++++++++++++++ 13 files changed, 224 insertions(+) create mode 100644 test/unit/assets/duplicate_usemtl.mtl create mode 100644 test/unit/assets/duplicate_usemtl.obj create mode 100644 test/unit/assets/multi_material_2.mtl create mode 100644 test/unit/assets/multi_material_2.obj create mode 100644 test/unit/assets/no_mtl.obj create mode 100644 test/unit/assets/no_normals.mtl create mode 100644 test/unit/assets/no_normals.obj create mode 100644 test/unit/assets/unknown_material.mtl create mode 100644 test/unit/assets/unknown_material.obj create mode 100644 test/unit/assets/windows_path.mtl create mode 100644 test/unit/assets/windows_path.obj diff --git a/test/unit/assets/duplicate_usemtl.mtl b/test/unit/assets/duplicate_usemtl.mtl new file mode 100644 index 0000000000..c358a0e525 --- /dev/null +++ b/test/unit/assets/duplicate_usemtl.mtl @@ -0,0 +1,5 @@ +newmtl a +Kd 1 0 0 + +newmtl b +Kd 0 0 1 diff --git a/test/unit/assets/duplicate_usemtl.obj b/test/unit/assets/duplicate_usemtl.obj new file mode 100644 index 0000000000..40f4b8b5a0 --- /dev/null +++ b/test/unit/assets/duplicate_usemtl.obj @@ -0,0 +1,17 @@ +mtllib duplicate_usemtl.mtl +v -1 -1 0 +v 1 -1 0 +v 1 1 0 +v -1 -1 2 +v 1 -1 2 +v 1 1 2 +v -1 -1 4 +v 1 -1 4 +v 1 1 4 +vn 0 0 1 +usemtl a +f 1//1 2//1 3//1 +usemtl b +f 4//1 5//1 6//1 +usemtl a +f 7//1 8//1 9//1 diff --git a/test/unit/assets/multi_material_2.mtl b/test/unit/assets/multi_material_2.mtl new file mode 100644 index 0000000000..125f41bf2d --- /dev/null +++ b/test/unit/assets/multi_material_2.mtl @@ -0,0 +1,5 @@ +newmtl red +Kd 1 0 0 + +newmtl blue +Kd 0 0 1 diff --git a/test/unit/assets/multi_material_2.obj b/test/unit/assets/multi_material_2.obj new file mode 100644 index 0000000000..f82b6c839d --- /dev/null +++ b/test/unit/assets/multi_material_2.obj @@ -0,0 +1,20 @@ +mtllib multi_material_2.mtl +v -1 -1 0 +v 1 -1 0 +v 1 1 0 +v -1 1 0 +v -1 -1 2 +v 1 -1 2 +v 1 1 2 +v -1 1 2 +vt 0 0 +vt 1 0 +vt 1 1 +vt 0 1 +vn 0 0 1 +usemtl red +f 1/1/1 2/2/1 3/3/1 +f 1/1/1 3/3/1 4/4/1 +usemtl blue +f 5/1/1 6/2/1 7/3/1 +f 5/1/1 7/3/1 8/4/1 diff --git a/test/unit/assets/no_mtl.obj b/test/unit/assets/no_mtl.obj new file mode 100644 index 0000000000..0504827326 --- /dev/null +++ b/test/unit/assets/no_mtl.obj @@ -0,0 +1,5 @@ +v -1 -1 0 +v 1 -1 0 +v 1 1 0 +vn 0 0 1 +f 1//1 2//1 3//1 diff --git a/test/unit/assets/no_normals.mtl b/test/unit/assets/no_normals.mtl new file mode 100644 index 0000000000..9b3828754e --- /dev/null +++ b/test/unit/assets/no_normals.mtl @@ -0,0 +1,5 @@ +newmtl one +Kd 1 0 0 + +newmtl two +Kd 0 1 0 diff --git a/test/unit/assets/no_normals.obj b/test/unit/assets/no_normals.obj new file mode 100644 index 0000000000..56bdaa5001 --- /dev/null +++ b/test/unit/assets/no_normals.obj @@ -0,0 +1,11 @@ +mtllib no_normals.mtl +v -1 -1 0 +v 1 -1 0 +v 1 1 0 +v -1 -1 2 +v 1 -1 2 +v 1 1 2 +usemtl one +f 1 2 3 +usemtl two +f 4 5 6 diff --git a/test/unit/assets/unknown_material.mtl b/test/unit/assets/unknown_material.mtl new file mode 100644 index 0000000000..f2f5005861 --- /dev/null +++ b/test/unit/assets/unknown_material.mtl @@ -0,0 +1,2 @@ +newmtl known +Kd 0 1 0 diff --git a/test/unit/assets/unknown_material.obj b/test/unit/assets/unknown_material.obj new file mode 100644 index 0000000000..5d3a829289 --- /dev/null +++ b/test/unit/assets/unknown_material.obj @@ -0,0 +1,12 @@ +mtllib unknown_material.mtl +v -1 -1 0 +v 1 -1 0 +v 1 1 0 +v -1 -1 2 +v 1 -1 2 +v 1 1 2 +vn 0 0 1 +usemtl known +f 1//1 2//1 3//1 +usemtl ghost +f 4//1 5//1 6//1 diff --git a/test/unit/assets/windows_path.mtl b/test/unit/assets/windows_path.mtl new file mode 100644 index 0000000000..8fb678d439 --- /dev/null +++ b/test/unit/assets/windows_path.mtl @@ -0,0 +1,6 @@ +newmtl winA +Kd 1 1 1 +map_Kd textures\cat.jpg + +newmtl winB +Kd 0 1 1 diff --git a/test/unit/assets/windows_path.obj b/test/unit/assets/windows_path.obj new file mode 100644 index 0000000000..166af1f29a --- /dev/null +++ b/test/unit/assets/windows_path.obj @@ -0,0 +1,15 @@ +mtllib windows_path.mtl +v -1 -1 0 +v 1 -1 0 +v 1 1 0 +v -1 -1 2 +v 1 -1 2 +v 1 1 2 +vt 0 0 +vt 1 0 +vt 1 1 +vn 0 0 1 +usemtl winA +f 1/1/1 2/2/1 3/3/1 +usemtl winB +f 4/1/1 5/2/1 6/3/1 diff --git a/test/unit/io/loadModel.js b/test/unit/io/loadModel.js index af6fe6f48a..8f640f85f9 100644 --- a/test/unit/io/loadModel.js +++ b/test/unit/io/loadModel.js @@ -307,4 +307,79 @@ suite('loadModel', function () { 'Face count should match' ); }); + + suite('multi-material edge cases', function () { + test('a two-material OBJ splits into one part per material', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/multi_material_2.obj' + ); + assert.equal(model.parts.length, 2); + assert.deepEqual(model.parts[0].partState.fill, [1, 0, 0, 1]); + assert.deepEqual(model.parts[1].partState.fill, [0, 0, 1, 1]); + // every face lands in exactly one part + const total = model.parts.reduce((sum, p) => sum + p.faces.length, 0); + assert.equal(total, model.faces.length); + }); + + test('usemtl naming a material the mtl does not define still loads', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/unknown_material.obj' + ); + // the unknown group falls back to an empty material instead of throwing + assert.equal(model.parts.length, 2); + assert.deepEqual(model.parts[0].partState.fill, [0, 1, 0, 1]); + assert.isNull(model.parts[1].partState.fill); + const total = model.parts.reduce((sum, p) => sum + p.faces.length, 0); + assert.equal(total, model.faces.length); + }); + + test('a material used in two separate groups collects into one part', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/duplicate_usemtl.obj' + ); + // grouping is by material name, so the two 'a' groups share a part + assert.equal(model.parts.length, 2); + assert.deepEqual( + model.parts.map(p => p.faces.length).sort(), + [1, 2] + ); + const total = model.parts.reduce((sum, p) => sum + p.faces.length, 0); + assert.equal(total, model.faces.length); + }); + + test('an OBJ with no vn lines gets normals computed per part', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/no_normals.obj' + ); + assert.equal(model.parts.length, 2); + for (const part of model.parts) { + assert.equal(part.vertexNormals.length, part.vertices.length); + } + }); + + test('an OBJ with no mtllib loads as plain geometry', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/no_mtl.obj' + ); + assert.isAbove(model.vertices.length, 0); + assert.isAbove(model.faces.length, 0); + }); + + test('windows-style texture paths are resolved with forward slashes', async function () { + const requested = []; + mockP5Prototype.loadImage = async url => { + requested.push(url); + return { width: 1, height: 1 }; + }; + try { + await mockP5Prototype.loadModel('/test/unit/assets/windows_path.obj'); + // the mtl writes `textures\cat.jpg`, which no server would resolve + assert.equal(requested.length, 1); + assert.notInclude(requested[0], '\\'); + assert.include(requested[0], 'textures/cat.jpg'); + } finally { + delete mockP5Prototype.loadImage; + } + }); + }); }); diff --git a/test/unit/webgl/p5.Geometry.js b/test/unit/webgl/p5.Geometry.js index e1848231c7..035237b091 100644 --- a/test/unit/webgl/p5.Geometry.js +++ b/test/unit/webgl/p5.Geometry.js @@ -507,5 +507,51 @@ suite('p5.Geometry', function () { fillSpy.mockRestore(); } ); + + test('drawing a multi-material model leaves the caller material intact', + function() { + const renderer = myp5.createCanvas(50, 50, myp5.WEBGL); + const geom = myp5.buildGeometry(() => { + myp5.texture(myp5.createGraphics(10, 10)); + myp5.box(8); + myp5.specularMaterial(255, 0, 0); + myp5.shininess(99); + myp5.sphere(8); + }); + + // whatever the sketch set before the call has to survive it, otherwise + // the next shape silently picks up the model's material + const callerTex = myp5.createGraphics(10, 10); + myp5.background(255); + myp5.texture(callerTex); + myp5.shininess(7); + myp5.model(geom); + + expect(renderer.states._tex).toBe(callerTex); + expect(renderer.states._useShininess).toEqual(7); + } + ); + + test('a multi-material model can be drawn inside buildGeometry', + function() { + myp5.createCanvas(50, 50, myp5.WEBGL); + const inner = myp5.buildGeometry(() => { + myp5.texture(myp5.createGraphics(10, 10)); + myp5.box(8); + myp5.texture(myp5.createGraphics(10, 10)); + myp5.sphere(8); + }); + expect(inner.parts.length).toEqual(2); + + const outer = myp5.buildGeometry(() => { + myp5.model(inner); + }); + // the geometry comes through whole. the materials flatten to whatever + // the callback had set, since the builder follows renderer state rather + // than the parts of a model handed to it + expect(outer.vertices.length).toEqual(inner.vertices.length); + expect(outer.faces.length).toEqual(inner.faces.length); + } + ); }); });