Skip to content

Commit 5f54724

Browse files
authored
Fix out of range mesh when assigning variant userData (#3195)
* Quick fix to make sure the mesh index is valid when assigning variant info to userData on model load * VariantMaterialLoaderPlugin afterRoot refactor to avoid looping over the primitives when traversing single mesh and avoid out-of-bounds errors * Remove ts-ignore from VariantMaterialLoaderPlugin.mappingsArrayToTable definition * Added Some tests for the fix * Adding new glb asset to tests single mesh with primitive and variants edge case * Remove duplicated test
1 parent f734e7a commit 5f54724

File tree

7 files changed

+165
-42
lines changed

7 files changed

+165
-42
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
.DS_Store
44
lib
55
node_modules
6-
dist
6+
dist
7+
.idea

packages/model-viewer/src/test/features/scene-graph-spec.ts

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/Mode
2323
import {waitForEvent} from '../../utilities.js';
2424
import {assetPath, rafPasses} from '../helpers.js';
2525
import {BasicSpecTemplate} from '../templates.js';
26+
import {ModelScene} from "../../three-components/ModelScene";
2627

2728

2829

@@ -31,11 +32,18 @@ const expect = chai.expect;
3132
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
3233
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
3334
const CUBES_GLB_PATH = assetPath('models/cubes.gltf'); // has variants
35+
const MESH_PRIMITIVES_GLB_PATH = assetPath('models/MeshPrimitivesVariants.glb'); // has variants
3436
const CUBE_GLB_PATH = assetPath('models/cube.gltf'); // has UV coords
3537
const SUNRISE_IMG_PATH = assetPath('environments/spruit_sunrise_1k_LDR.jpg');
3638
const RIGGEDFIGURE_GLB_PATH = assetPath(
3739
'models/glTF-Sample-Models/2.0/RiggedFigure/glTF-Binary/RiggedFigure.glb');
3840

41+
function getGLTFRoot(scene: ModelScene, hasBeenExportedOnce = false) {
42+
// TODO: export is putting in an extra node layer, because the loader
43+
// gives us a Group, but if the exporter doesn't get a Scene, then it
44+
// wraps everything in an "AuxScene" node. Feels like a three.js bug.
45+
return hasBeenExportedOnce ? scene.modelContainer.children[0].children[0] : scene.modelContainer.children[0];
46+
}
3947

4048
suite('ModelViewerElementBase with SceneGraphMixin', () => {
4149
let nextId = 0;
@@ -88,13 +96,13 @@ suite('ModelViewerElementBase with SceneGraphMixin', () => {
8896
test('has variants', () => {
8997
expect(element[$scene].currentGLTF!.userData.variants.length)
9098
.to.be.eq(3);
91-
const glTFroot = element[$scene].modelContainer.children[0];
92-
expect(glTFroot.children[0].userData.variantMaterials.size).to.be.eq(3);
93-
expect(glTFroot.children[1].userData.variantMaterials.size).to.be.eq(3);
99+
const gltfRoot = getGLTFRoot(element[$scene]);
100+
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
101+
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
94102
});
95103

96104
test(
97-
`Setting varianName to null results in primitive
105+
`Setting variantName to null results in primitive
98106
reverting to default/initial material`,
99107
async () => {
100108
let primitiveNode: PrimitiveNode|null = null
@@ -122,7 +130,7 @@ suite('ModelViewerElementBase with SceneGraphMixin', () => {
122130
.equal('purple');
123131
});
124132

125-
test('exports and reimports the model with variants', async () => {
133+
test('exports and re-imports the model with variants', async () => {
126134
const exported = await element.exportScene({binary: true});
127135
const url = URL.createObjectURL(exported);
128136
element.src = url;
@@ -131,12 +139,70 @@ suite('ModelViewerElementBase with SceneGraphMixin', () => {
131139

132140
expect(element[$scene].currentGLTF!.userData.variants.length)
133141
.to.be.eq(3);
134-
// TODO: export is putting in an extra node layer, because the loader
135-
// gives us a Group, but if the exporter doesn't get a Scene, then it
136-
// wraps everything in an "AuxScene" node. Feels like a three.js bug.
137-
const glTFroot = element[$scene].modelContainer.children[0].children[0];
138-
expect(glTFroot.children[0].userData.variantMaterials.size).to.be.eq(3);
139-
expect(glTFroot.children[1].userData.variantMaterials.size).to.be.eq(3);
142+
const gltfRoot = getGLTFRoot(element[$scene], true);
143+
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
144+
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
145+
});
146+
});
147+
148+
suite('with a loaded model containing a mesh with multiple primitives', () => {
149+
setup(async () => {
150+
element.src = MESH_PRIMITIVES_GLB_PATH;
151+
152+
await waitForEvent(element, 'load');
153+
await rafPasses();
154+
});
155+
156+
test('has variants', () => {
157+
expect(element[$scene].currentGLTF!.userData.variants.length)
158+
.to.be.eq(2);
159+
const gltfRoot = getGLTFRoot(element[$scene]);
160+
expect(gltfRoot.children[0].children[0].userData.variantMaterials.size).to.be.eq(2);
161+
expect(gltfRoot.children[0].children[1].userData.variantMaterials.size).to.be.eq(2);
162+
expect(gltfRoot.children[0].children[2].userData.variantMaterials.size).to.be.eq(2);
163+
});
164+
165+
test(`Setting variantName to null results in primitive
166+
reverting to default/initial material`, async () => {
167+
let primitiveNode: PrimitiveNode|null = null
168+
// Finds the first primitive with material 0 assigned.
169+
for (const primitive of element.model![$primitivesList]) {
170+
if (primitive.variantInfo != null &&
171+
primitive[$initialMaterialIdx] == 0) {
172+
primitiveNode = primitive;
173+
return;
174+
}
175+
}
176+
177+
expect(primitiveNode).to.not.be.null;
178+
179+
// Switches to a new variant.
180+
element.variantName = 'Inverse';
181+
await waitForEvent(element, 'variant-applied');
182+
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
183+
.equal('STEEL RED X');
184+
185+
// Switches to null variant.
186+
element.variantName = null;
187+
await waitForEvent(element, 'variant-applied');
188+
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
189+
.equal('STEEL METALLIC');
190+
});
191+
192+
test('exports and re-imports the model with variants', async () => {
193+
const exported = await element.exportScene({binary: true});
194+
const url = URL.createObjectURL(exported);
195+
element.src = url;
196+
await waitForEvent(element, 'load');
197+
await rafPasses();
198+
199+
expect(element[$scene].currentGLTF!.userData.variants.length)
200+
.to.be.eq(2);
201+
202+
const gltfRoot = getGLTFRoot(element[$scene], true);
203+
expect(gltfRoot.children[0].children[0].userData.variantMaterials.size).to.be.eq(2);
204+
expect(gltfRoot.children[0].children[1].userData.variantMaterials.size).to.be.eq(2);
205+
expect(gltfRoot.children[0].children[2].userData.variantMaterials.size).to.be.eq(2);
140206
});
141207
});
142208

packages/model-viewer/src/test/features/scene-graph/nodes/primitive-node-spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const BRAIN_STEM_GLB_PATH = assetPath(
3030
'models/glTF-Sample-Models/2.0/BrainStem/glTF-Binary/BrainStem.glb');
3131
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
3232
const CUBE_GLTF_PATH = assetPath('models/cube.gltf');
33+
const MESH_PRIMITIVES_GLB_PATH = assetPath('models/MeshPrimitivesVariants.glb');
3334
const KHRONOS_TRIANGLE_GLB_PATH =
3435
assetPath('models/glTF-Sample-Models/2.0/Triangle/glTF/Triangle.gltf');
3536

@@ -170,6 +171,69 @@ suite('scene-graph/model/mesh-primitives', () => {
170171
});
171172
});
172173

174+
suite('Mesh with multiple primitives each with variants', () => {
175+
let model: Model;
176+
setup(async () => {
177+
const threeGLTF = await loadThreeGLTF(MESH_PRIMITIVES_GLB_PATH);
178+
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
179+
});
180+
181+
test('Primitive count matches glTF file', async () => {
182+
expect(model![$primitivesList].length).to.equal(3) ;
183+
});
184+
185+
test('Primitives should have expected variant names', async () => {
186+
expect(findPrimitivesWithVariant(model, 'Normal')).to.not.be.null;
187+
expect(findPrimitivesWithVariant(model, 'Inverse')).to.not.be.null;
188+
});
189+
190+
test('Switching to incorrect variant name', async () => {
191+
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
192+
const material = await primitive.enableVariant('Does not exist');
193+
expect(material).to.be.null;
194+
});
195+
196+
test('Switching to variant and then switch back', async () => {
197+
const MATERIAL_NAME = 'STEEL BLACK';
198+
const primitives = findPrimitivesWithVariant(model, 'Normal')!;
199+
let materials = new Array<MeshStandardMaterial>();
200+
for (const primitive of primitives) {
201+
materials.push(
202+
await primitive.enableVariant('Inverse') as
203+
MeshStandardMaterial);
204+
}
205+
206+
expect(materials).to.not.be.empty;
207+
expect(materials.find((material: MeshStandardMaterial) => {
208+
return material.name === MATERIAL_NAME;
209+
})).to.be.undefined;
210+
211+
materials = new Array<MeshStandardMaterial>();
212+
for (const primitive of primitives) {
213+
materials.push(
214+
await primitive.enableVariant('Normal') as
215+
MeshStandardMaterial);
216+
}
217+
218+
expect(materials.find((material: MeshStandardMaterial) => {
219+
return material.name === MATERIAL_NAME;
220+
})).to.be.ok;
221+
});
222+
223+
test('Primitive switches to initial material', async () => {
224+
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
225+
226+
// Gets current material.
227+
const initialMaterial = await primitive.enableVariant('Normal');
228+
// Switches to variant.
229+
const variantMaterial = await primitive.enableVariant('Inverse');
230+
expect(initialMaterial).to.not.equal(variantMaterial)
231+
// Switches to initial material.
232+
const resetMaterial = await primitive.enableVariant(null);
233+
expect(resetMaterial).to.equal(initialMaterial);
234+
});
235+
});
236+
173237
suite('Skinned Primitive Without Variant', () => {
174238
test('Primitive count matches glTF file', async () => {
175239
const threeGLTF = await loadThreeGLTF(BRAIN_STEM_GLB_PATH);

packages/model-viewer/src/three-components/CachingGLTFLoader.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,10 @@ export class CachingGLTFLoader<T extends GLTFInstanceConstructor =
212212
.then((preparedGLTF) => {
213213
progressCallback(0.9);
214214
return new GLTFInstance(preparedGLTF);
215-
});
216-
215+
}).catch((reason => {
216+
console.error(reason);
217+
return new GLTFInstance();
218+
}));
217219
cache.set(url, gltfInstanceLoads);
218220
}
219221

packages/model-viewer/src/three-components/gltf-instance/VariantMaterialLoaderPlugin.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
* https://github.com/takahirox/three-gltf-extensions/tree/main/loaders/KHR_materials_variants
2626
*/
2727

28-
import {Material as ThreeMaterial} from 'three';
28+
import {Material as ThreeMaterial, Mesh} from 'three';
2929
import {GLTF, GLTFLoaderPlugin, GLTFParser} from 'three/examples/jsm/loaders/GLTFLoader.js';
30+
import {GLTFReference} from "three/examples/jsm/loaders/GLTFLoader";
3031

3132

3233
export interface UserDataVariantMapping {
@@ -72,8 +73,7 @@ const ensureUniqueNames = (variantNames: string[]) => {
7273
* @param variantNames {Array<string>} Required to be unique names
7374
* @return {Map}
7475
*/
75-
const mappingsArrayToTable = (extensionDef:
76-
any): Map<number, UserDataVariantMapping> => {
76+
const mappingsArrayToTable = (extensionDef: any): Map<number, UserDataVariantMapping> => {
7777
const table = new Map<number, UserDataVariantMapping>();
7878
for (const mapping of extensionDef.mappings) {
7979
for (const variant of mapping.variants) {
@@ -114,38 +114,28 @@ export default class GLTFMaterialsVariantsExtension implements
114114
for (const scene of gltf.scenes) {
115115
// Save the variants data under associated mesh.userData
116116
scene.traverse(object => {
117-
// The following code can be simplified if parser.associations directly
118-
// supports meshes.
119-
const association = parser.associations.get(object);
117+
const mesh = object as Mesh;
120118

121-
if (association == null || association.meshes == null) {
119+
if (!mesh.isMesh) {
122120
return;
123121
}
124122

125-
const meshIndex = association.meshes;
123+
const association = parser.associations.get(mesh) as GLTFReference & {primitives: number};
126124

127-
// Two limitations:
128-
// 1. The nodeDef shouldn't have any objects (camera, light, or
129-
// nodeDef.extensions object)
130-
// other than nodeDef.mesh
131-
// 2. Other plugins shouldn't change any scene graph hierarchy
132-
// The following code can cause error if hitting the either or both
133-
// limitations If parser.associations will directly supports meshes
134-
// these limitations can be removed
125+
if (association == null || association.meshes == null || association.primitives == null) {
126+
return;
127+
}
135128

136-
const meshDef = json.meshes[meshIndex];
129+
const meshDef = json.meshes[association.meshes];
137130
const primitivesDef = meshDef.primitives;
138-
const meshes = 'isMesh' in object ? [object] : object.children;
139-
140-
for (let i = 0; i < primitivesDef.length; i++) {
141-
const primitiveDef = primitivesDef[i];
142-
const extensionsDef = primitiveDef.extensions;
143-
if (!extensionsDef || !extensionsDef[this.name]) {
144-
continue;
145-
}
146-
meshes[i].userData.variantMaterials =
147-
mappingsArrayToTable(extensionsDef[this.name]);
131+
const primitiveDef = primitivesDef[association.primitives];
132+
const extensionsDef = primitiveDef.extensions;
133+
134+
if (!extensionsDef || !extensionsDef[this.name]) {
135+
return;
148136
}
137+
138+
mesh.userData.variantMaterials = mappingsArrayToTable(extensionsDef[this.name]);
149139
});
150140
}
151141

Binary file not shown.

packages/space-opera/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"watch:test": "karma start",
1616
"watch:tsc": "tsc -w",
1717
"watch:rollup": "rollup -c -w",
18-
"serve": "./node_modules/.bin/http-server -a 127.0.0.1 -o /demo/ -c-1",
18+
"serve": "./node_modules/.bin/http-server -a 127.0.0.1 -o /editor/ -c-1",
1919
"pyserve": "echo This assumes 'python' is Python 2. && which python && echo Visit $(hostname):8000/index.html && python -m SimpleHTTPServer 8000",
2020
"dev": "npm run build -- --incremental && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput --incremental' 'watch:rollup' 'watch:test' 'serve -- -s'",
2121
"dev-py": "npm run build -- --incremental && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput --incremental' 'watch:rollup' 'watch:test' 'pyserve'"

0 commit comments

Comments
 (0)