diff --git a/test/unit/assets/missing_texture.mtl b/test/unit/assets/missing_texture.mtl new file mode 100644 index 0000000000..d0a94874de --- /dev/null +++ b/test/unit/assets/missing_texture.mtl @@ -0,0 +1,6 @@ +newmtl m0 +Kd 1 0 0 +map_Kd does_not_exist.png + +newmtl m1 +Kd 0 0 1 diff --git a/test/unit/assets/missing_texture.obj b/test/unit/assets/missing_texture.obj new file mode 100644 index 0000000000..6e70707c38 --- /dev/null +++ b/test/unit/assets/missing_texture.obj @@ -0,0 +1,13 @@ +mtllib missing_texture.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +v 1 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +vt 1 1 +usemtl m0 +f 1/1 2/2 3/3 +usemtl m1 +f 2/2 4/4 3/3 diff --git a/test/unit/assets/multi_material_12.mtl b/test/unit/assets/multi_material_12.mtl new file mode 100644 index 0000000000..eb05a5f7ab --- /dev/null +++ b/test/unit/assets/multi_material_12.mtl @@ -0,0 +1,36 @@ +newmtl m00 +Kd 0.000 0.876 0.844 + +newmtl m01 +Kd 0.500 0.518 0.999 + +newmtl m02 +Kd 0.866 0.021 0.886 + +newmtl m03 +Kd 1.000 0.482 0.536 + +newmtl m04 +Kd 0.866 0.855 0.042 + +newmtl m05 +Kd 0.500 1.000 0.463 + +newmtl m06 +Kd 0.000 0.876 0.844 + +newmtl m07 +Kd 0.500 0.518 0.999 + +newmtl m08 +Kd 0.866 0.021 0.886 + +newmtl m09 +Kd 1.000 0.482 0.536 + +newmtl m10 +Kd 0.866 0.855 0.042 + +newmtl m11 +Kd 0.500 1.000 0.463 + diff --git a/test/unit/assets/multi_material_12.obj b/test/unit/assets/multi_material_12.obj new file mode 100644 index 0000000000..3fa749b5ab --- /dev/null +++ b/test/unit/assets/multi_material_12.obj @@ -0,0 +1,61 @@ +mtllib multi_material_12.mtl +v -1.5 -1 0 +v -0.7 -1 0 +v -1.5 -0.19999999999999996 0 +v -0.5 -1 0 +v 0.30000000000000004 -1 0 +v -0.5 -0.19999999999999996 0 +v 0.5 -1 0 +v 1.3 -1 0 +v 0.5 -0.19999999999999996 0 +v 1.5 -1 0 +v 2.3 -1 0 +v 1.5 -0.19999999999999996 0 +v -1.5 0 0 +v -0.7 0 0 +v -1.5 0.8 0 +v -0.5 0 0 +v 0.30000000000000004 0 0 +v -0.5 0.8 0 +v 0.5 0 0 +v 1.3 0 0 +v 0.5 0.8 0 +v 1.5 0 0 +v 2.3 0 0 +v 1.5 0.8 0 +v -1.5 1 0 +v -0.7 1 0 +v -1.5 1.8 0 +v -0.5 1 0 +v 0.30000000000000004 1 0 +v -0.5 1.8 0 +v 0.5 1 0 +v 1.3 1 0 +v 0.5 1.8 0 +v 1.5 1 0 +v 2.3 1 0 +v 1.5 1.8 0 +usemtl m00 +f 1 2 3 +usemtl m01 +f 4 5 6 +usemtl m02 +f 7 8 9 +usemtl m03 +f 10 11 12 +usemtl m04 +f 13 14 15 +usemtl m05 +f 16 17 18 +usemtl m06 +f 19 20 21 +usemtl m07 +f 22 23 24 +usemtl m08 +f 25 26 27 +usemtl m09 +f 28 29 30 +usemtl m10 +f 31 32 33 +usemtl m11 +f 34 35 36 diff --git a/test/unit/assets/single_material.mtl b/test/unit/assets/single_material.mtl new file mode 100644 index 0000000000..74b68eb8b4 --- /dev/null +++ b/test/unit/assets/single_material.mtl @@ -0,0 +1,3 @@ +newmtl only +Kd 0.2 0.7 0.9 +Ns 40 diff --git a/test/unit/assets/single_material.obj b/test/unit/assets/single_material.obj new file mode 100644 index 0000000000..f6397bf84e --- /dev/null +++ b/test/unit/assets/single_material.obj @@ -0,0 +1,22 @@ +mtllib single_material.mtl +v -0.5 -0.5 -0.5 +v 0.5 -0.5 -0.5 +v 0.5 0.5 -0.5 +v -0.5 0.5 -0.5 +v -0.5 -0.5 0.5 +v 0.5 -0.5 0.5 +v 0.5 0.5 0.5 +v -0.5 0.5 0.5 +usemtl only +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 5 8 +f 1 8 4 +f 2 6 7 +f 2 7 3 +f 4 8 7 +f 4 7 3 +f 1 5 6 +f 1 6 2 diff --git a/test/unit/io/loadModel.js b/test/unit/io/loadModel.js index 0c2efa9cf7..87b40d9aab 100644 --- a/test/unit/io/loadModel.js +++ b/test/unit/io/loadModel.js @@ -1,6 +1,7 @@ import { mockP5, mockP5Prototype, httpMock } from '../../js/mocks'; import loading from '../../../src/webgl/loading'; import { Geometry } from '../../../src/webgl/p5.Geometry'; +import { vi } from 'vitest'; suite('loadModel', function () { const invalidFile = '404file'; @@ -131,6 +132,73 @@ suite('loadModel', function () { } }); + test('a single-material OBJ stays one part', async function () { + // eg1.obj has one real material, so it is not split + const model = await mockP5Prototype.loadModel(inconsistentColorObjFile); + assert.equal(model.parts.length, 1); + assert.equal(model.parts[0], model, 'the geometry is its own single part'); + }); + + test('a 12-material OBJ splits into 12 parts', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/multi_material_12.obj' + ); + assert.equal(model.parts.length, 12); + // every face still lands in exactly one part + const totalFaces = model.parts.reduce((s, p) => s + p.faces.length, 0); + assert.equal(totalFaces, model.faces.length); + }); + + test('parts get computed normals when the OBJ has none', async function () { + // textured.obj has no vn lines, so normals are computed before the split + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/textured.obj' + ); + assert.equal(model.parts.length, 2); + for (const part of model.parts) { + assert.equal( + part.vertexNormals.length, + part.vertices.length, + 'each part has one computed normal per vertex' + ); + } + }); + + test('each part carries its own localised uvs', async function () { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/textured.obj' + ); + assert.equal(model.parts.length, 2); + for (const part of model.parts) { + assert.equal( + part.uvs.length, + part.vertices.length, + 'each part has one uv per localised vertex' + ); + } + }); + + test('a failed texture load warns instead of failing silently', async function () { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockP5Prototype.loadImage = async () => { + throw new Error('Not Found'); + }; + try { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/textured.obj' + ); + // the model still loads with both material parts + assert.equal(model.parts.length, 2); + // the failed texture is skipped, so no part carries it + assert.ok(model.parts.every(p => p.partState.texture == null)); + // and the failure is surfaced, not silent + assert.ok(warnSpy.mock.calls.length > 0, 'a warning is emitted'); + } finally { + delete mockP5Prototype.loadImage; + warnSpy.mockRestore(); + } + }); + test('mixed material coloring loads model with sentinel colors for uncolored vertices', async function () { const model = await mockP5Prototype.loadModel(inconsistentColorObjFile); assert.instanceOf(model, Geometry); diff --git a/test/unit/io/parseMtl.js b/test/unit/io/parseMtl.js index c01ef7947a..7e094e764b 100644 --- a/test/unit/io/parseMtl.js +++ b/test/unit/io/parseMtl.js @@ -48,6 +48,21 @@ suite('parseMtlData', function () { expect(materials.a.diffuseColor).toEqual([0, 0, 0]); expect(Object.keys(materials)).toEqual(['a']); }); + + test('a Kd-only material parses without error', function () { + // the common case: a material that only sets a diffuse colour + const materials = parseMtlData('newmtl plain\nKd 0.2 0.4 0.6'); + expect(materials.plain.diffuseColor).toEqual([0.2, 0.4, 0.6]); + expect(materials.plain.texturePath).toBeUndefined(); + }); + + test('a malformed mtl does not throw', function () { + // an unknown token, a token missing its value, and a blank line + const mtl = 'newmtl weird\nKd 1 0 0\nfoo bar baz\nNs\n\nsomething_else'; + expect(() => parseMtlData(mtl)).not.toThrow(); + // the valid tokens around the junk are still read + expect(parseMtlData(mtl).weird.diffuseColor).toEqual([1, 0, 0]); + }); }); suite('mtlToPartState', function () { diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index ed2af5fdc6..798efa6e52 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -411,6 +411,70 @@ visualSuite('WebGL', function () { screenshot(); } ); + visualTest( + 'multi-material OBJ renders each material part', + async function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + // textured.obj has two materials: a cat texture and a plain colour + const model = await new Promise(resolve => + p5.loadModel('test/unit/assets/textured.obj', resolve) + ); + model.normalize(); + p5.background(255); + p5.rotateX(0.4); + p5.rotateY(0.4); + p5.noStroke(); + p5.model(model); + screenshot(); + } + ); + visualTest( + 'a slice with a missing texture falls back to its colour', + async function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + // missing_texture.obj: m0 has a 404 map_Kd (falls back to red), m1 blue + const model = await new Promise(resolve => + p5.loadModel('test/unit/assets/missing_texture.obj', resolve) + ); + model.normalize(); + p5.background(255); + p5.rotateX(0.4); + p5.rotateY(0.4); + p5.noStroke(); + p5.model(model); + screenshot(); + } + ); + visualTest( + 'a 12-material OBJ renders every material', + async function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const model = await new Promise(resolve => + p5.loadModel('test/unit/assets/multi_material_12.obj', resolve) + ); + model.normalize(); + p5.background(255); + p5.noStroke(); + p5.model(model); + screenshot(); + } + ); + visualTest( + 'a single-material OBJ renders through the part path', + async function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const model = await new Promise(resolve => + p5.loadModel('test/unit/assets/single_material.obj', resolve) + ); + model.normalize(); + p5.background(255); + p5.rotateX(0.4); + p5.rotateY(0.4); + p5.noStroke(); + p5.model(model); + screenshot(); + } + ); }); visualSuite('vertexProperty', function () { diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a 12-material OBJ renders every material/000.png b/test/unit/visual/screenshots/WebGL/3DModel/a 12-material OBJ renders every material/000.png new file mode 100644 index 0000000000..bb5fc67e44 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/a 12-material OBJ renders every material/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a 12-material OBJ renders every material/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/a 12-material OBJ renders every material/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/a 12-material OBJ renders every material/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a single-material OBJ renders through the part path/000.png b/test/unit/visual/screenshots/WebGL/3DModel/a single-material OBJ renders through the part path/000.png new file mode 100644 index 0000000000..49140f8bf9 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/a single-material OBJ renders through the part path/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a single-material OBJ renders through the part path/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/a single-material OBJ renders through the part path/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/a single-material OBJ renders through the part path/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a slice with a missing texture falls back to its colour/000.png b/test/unit/visual/screenshots/WebGL/3DModel/a slice with a missing texture falls back to its colour/000.png new file mode 100644 index 0000000000..211724fc49 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/a slice with a missing texture falls back to its colour/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a slice with a missing texture falls back to its colour/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/a slice with a missing texture falls back to its colour/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/a slice with a missing texture falls back to its colour/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/3DModel/multi-material OBJ renders each material part/000.png b/test/unit/visual/screenshots/WebGL/3DModel/multi-material OBJ renders each material part/000.png new file mode 100644 index 0000000000..f6bbe85a95 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/multi-material OBJ renders each material part/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/multi-material OBJ renders each material part/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/multi-material OBJ renders each material part/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/multi-material OBJ renders each material part/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file