@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
400 lines (324 loc) • 14.8 kB
text/typescript
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Mesh, MeshStandardMaterial} from 'three';
import {$currentGLTF} from '../../features/scene-graph.js';
import {$primitivesList} from '../../features/scene-graph/model.js';
import {PrimitiveNode} from '../../features/scene-graph/nodes/primitive-node.js';
import {$scene} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
import {ModelScene} from '../../three-components/ModelScene.js';
import {waitForEvent} from '../../utilities.js';
import {assetPath, rafPasses} from '../helpers.js';
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
const CUBES_GLB_PATH = assetPath('models/cubes.gltf'); // has variants
const MESH_PRIMITIVES_GLB_PATH =
assetPath('models/MeshPrimitivesVariants.glb'); // has variants
const CUBE_GLB_PATH = assetPath('models/cube.gltf'); // has UV coords
const RIGGEDFIGURE_GLB_PATH = assetPath(
'models/glTF-Sample-Assets/Models/RiggedFigure/glTF-Binary/RiggedFigure.glb');
function getGLTFRoot(scene: ModelScene, hasBeenExportedOnce = false) {
// TODO: export is putting in an extra node layer, because the loader
// gives us a Group, but if the exporter doesn't get a Scene, then it
// wraps everything in an "AuxScene" node. Feels like a three.js bug.
return hasBeenExportedOnce ? scene.model!.children[0] : scene.model!;
}
suite('SceneGraph', () => {
let element: ModelViewerElement;
setup(() => {
element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
});
teardown(() => {
document.body.removeChild(element);
});
suite('scene export', () => {
suite('transformations', () => {
test(
'setting scale before model loads has expected dimensions',
async () => {
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const dim = element.getDimensions();
expect(dim.x).to.be.eq(1, 'x');
expect(dim.y).to.be.eq(2, 'y');
expect(dim.z).to.be.eq(3, 'z');
});
test('orientation is applied after scale', async () => {
element.orientation = '90deg 90deg 90deg';
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const dim = element.getDimensions();
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
});
test('exports and re-imports the rescaled model', async () => {
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.scale = '1 1 1';
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
const dim = element.getDimensions();
expect(dim.x).to.be.eq(1, 'x');
expect(dim.y).to.be.eq(2, 'y');
expect(dim.z).to.be.eq(3, 'z');
});
test('exports and re-imports the transformed model', async () => {
element.orientation = '90deg 90deg 90deg';
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.orientation = '0deg 0deg 0deg';
element.scale = '1 1 1';
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
const dim = element.getDimensions();
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
});
});
suite('with a loaded model', () => {
setup(async () => {
element.src = CUBES_GLB_PATH;
await waitForEvent(element, 'load');
await rafPasses();
});
test('exports the loaded model to GLTF', async () => {
const exported = await element.exportScene({binary: false});
expect(exported).to.be.not.undefined;
expect(exported.size).to.be.greaterThan(500);
});
test('exports the loaded model to GLB', async () => {
const exported = await element.exportScene({binary: true});
expect(exported).to.be.not.undefined;
expect(exported.size).to.be.greaterThan(500);
});
test('has variants', () => {
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(3);
const gltfRoot = getGLTFRoot(element[$scene]);
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
});
test('allows the scene graph to be manipulated', async () => {
element.variantName = 'Yellow Red';
await waitForEvent(element, 'variant-applied');
const material =
(element[$scene].model!.children[1] as Mesh).material as
MeshStandardMaterial;
const mat = element.model!.getMaterialByName('red')!;
expect(mat.isActive).to.be.true;
mat.pbrMetallicRoughness.setBaseColorFactor([0.5, 0.5, 0.5, 1]);
const color = mat.pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
console.log(material.name, ': actual material ', material.uuid);
expect(material.color).to.include({r: 0.5, g: 0.5, b: 0.5});
});
test(
`Setting variantName to null results in primitive
reverting to default/initial material`,
async () => {
let primitiveNode: PrimitiveNode|null = null
// Finds the first primitive with material 0 assigned.
for (const primitive of element.model![$primitivesList]) {
if (primitive.variantInfo != null &&
primitive.initialMaterialIdx == 0) {
primitiveNode = primitive;
return;
}
}
expect(primitiveNode).to.not.be.null;
// Switches to a new variant.
element.variantName = 'Yellow Red';
await waitForEvent(element, 'variant-applied');
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('red');
// Switches to null variant.
element.variantName = null;
await waitForEvent(element, 'variant-applied');
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('purple');
});
test('exports and re-imports the model with variants', async () => {
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(3);
const gltfRoot = getGLTFRoot(element[$scene], true);
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
});
});
suite(
'with a loaded model containing a mesh with multiple primitives',
() => {
setup(async () => {
element.src = MESH_PRIMITIVES_GLB_PATH;
await waitForEvent(element, 'load');
await rafPasses();
});
test('has variants', () => {
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(2);
const gltfRoot = getGLTFRoot(element[$scene]);
expect(
gltfRoot.children[0].children[0].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[1].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[2].userData.variantMaterials.size)
.to.be.eq(2);
});
test(
`Setting variantName to null results in primitive
reverting to default/initial material`,
async () => {
let primitiveNode: PrimitiveNode|null = null
// Finds the first primitive with material 0 assigned.
for (const primitive of element.model![$primitivesList]) {
if (primitive.variantInfo != null &&
primitive.initialMaterialIdx == 0) {
primitiveNode = primitive;
return;
}
}
expect(primitiveNode).to.not.be.null;
// Switches to a new variant.
element.variantName = 'Inverse';
await waitForEvent(element, 'variant-applied');
expect(
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('STEEL RED X');
// Switches to null variant.
element.variantName = null;
await waitForEvent(element, 'variant-applied');
expect(
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('STEEL METALLIC');
});
test('exports and re-imports the model with variants', async () => {
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(2);
const gltfRoot = getGLTFRoot(element[$scene], true);
expect(
gltfRoot.children[0].children[0].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[1].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[2].userData.variantMaterials.size)
.to.be.eq(2);
});
});
test.skip(
'When loading a new JPEG texture from an ObjectURL, the GLB does not export PNG',
async () => {
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
await rafPasses();
const url = assetPath(
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF/Default_albedo.jpg');
const blob = await fetch(url).then(r => r.blob());
const objectUrl = URL.createObjectURL(blob);
const texture = await element.createTexture(objectUrl, 'image/jpeg');
element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
const exported = await element.exportScene({binary: true});
expect(exported).to.be.not.undefined;
// The JPEG is ~1 Mb and the equivalent PNG is about ~6 Mb, so this
// just checks we saved an image and it wasn't too big.
expect(exported.size).to.be.greaterThan(0.5e6);
expect(exported.size).to.be.lessThan(1.5e6);
});
});
suite('with a loaded scene graph', () => {
let material: MeshStandardMaterial;
setup(async () => {
element.src = ASTRONAUT_GLB_PATH;
await waitForEvent(element, 'load');
material =
(element[$scene].model!.children[0].children[0] as Mesh).material as
MeshStandardMaterial;
});
test('allows the scene graph to be manipulated', async () => {
await element.model!.materials[0].pbrMetallicRoughness.setBaseColorFactor(
[1, 0, 0, 1]);
expect(material.color).to.include({r: 1, g: 0, b: 0});
const color =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([1, 0, 0, 1]);
});
suite('when the model changes', () => {
test('updates when the model changes', async () => {
const color =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
const nextColor =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(nextColor).to.be.eql([1, 1, 1, 1]);
});
test('allows the scene graph to be manipulated', async () => {
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
await element.model!.materials[0]
.pbrMetallicRoughness.setBaseColorFactor([1, 0, 0, 1]);
const color =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([1, 0, 0, 1]);
const newMaterial =
(element[$scene].model!.children[0] as Mesh).material as
MeshStandardMaterial;
expect(newMaterial.color).to.include({r: 1, g: 0, b: 0});
});
});
suite('Scene-graph gltf-to-three mappings', () => {
test('has a mapping for each primitive mesh', async () => {
element.src = RIGGEDFIGURE_GLB_PATH;
await waitForEvent(element, 'load');
const gltf = (element as any)[$currentGLTF] as ModelViewerGLTFInstance;
for (const primitive of element.model![$primitivesList]) {
expect(gltf.correlatedSceneGraph.threeObjectMap.get(primitive.mesh))
.to.be.ok;
}
});
});
});
});