def test_pluggable_load_cube(self): """ This won't work on Windows due to NamedTemporaryFile being reopened. Use the testpath package instead? """ ply_file = "\n".join(CUBE_PLY_LINES) io = IO() with NamedTemporaryFile(mode="w", suffix=".ply") as f: f.write(ply_file) f.flush() mesh = io.load_mesh(f.name) self.assertClose(mesh.verts_padded(), torch.FloatTensor(CUBE_VERTS)[None]) self.assertClose(mesh.faces_padded(), torch.LongTensor(CUBE_FACES)[None]) device = torch.device("cuda:0") with NamedTemporaryFile(mode="w", suffix=".ply") as f2: io.save_mesh(mesh, f2.name) f2.flush() mesh2 = io.load_mesh(f2.name, device=device) self.assertEqual(mesh2.verts_padded().device, device) self.assertClose(mesh2.verts_padded().cpu(), mesh.verts_padded()) self.assertClose(mesh2.faces_padded().cpu(), mesh.faces_padded()) with NamedTemporaryFile(mode="w") as f3: with self.assertRaisesRegex( ValueError, "No mesh interpreter found to write to"): io.save_mesh(mesh, f3.name) with self.assertRaisesRegex(ValueError, "No mesh interpreter found to read "): io.load_mesh(f3.name)
def _load_pointcloud(pcl_path: Union[str, Path], max_points: int = 0) -> Pointclouds: pcl = IO().load_pointcloud(pcl_path) if max_points > 0: pcl = pcl.subsample(max_points) return pcl
def test_load_pointcloud_bad_order(self): """ Ply file with a strange property order """ file = "\n".join([ "ply", "format ascii 1.0", "element vertex 1", "property uchar green", "property float x", "property float z", "property uchar red", "property float y", "property uchar blue", "end_header", "1 2 3 4 5 6", ]) io = IO() pointcloud_gpu = io.load_pointcloud(StringIO(file), device="cuda:0") self.assertEqual(pointcloud_gpu.device, torch.device("cuda:0")) pointcloud = pointcloud_gpu.to(torch.device("cpu")) expected_points = torch.tensor([[[2, 5, 3]]], dtype=torch.float32) expected_features = torch.tensor([[[4, 1, 6]]], dtype=torch.float32) self.assertClose(pointcloud.points_padded(), expected_points) self.assertClose(pointcloud.features_padded(), expected_features)
def test_load_lumpy(self): # Example off file whose faces have different numbers of vertices. off_file_lines = [ "8 3 12", " 1.0 0.0 1.4142", " 0.0 1.0 1.4142", "-1.0 0.0 1.4142", " 0.0 -1.0 1.4142", " 1.0 0.0 0.0", " 0.0 1.0 0.0", "-1.0 0.0 0.0", " 0.0 -1.0 0.0", "3 0 1 2 255 0 0 #red", "4 7 4 0 3 0 255 0 #green", "4 4 5 1 0 0 0 255 #blue", ] off_file = "\n".join(off_file_lines) io = IO() with NamedTemporaryFile(mode="w", suffix=".off") as f: f.write(off_file) f.flush() mesh = io.load_mesh(f.name) self.assertEqual(mesh.verts_padded().shape, (1, 8, 3)) verts_str = " ".join(off_file_lines[1:9]) verts_data = torch.tensor([float(i) for i in verts_str.split()]) self.assertClose(mesh.verts_padded().flatten(), verts_data) self.assertEqual(mesh.faces_padded().shape, (1, 5, 3)) faces_expected = [[0, 1, 2], [7, 4, 0], [7, 0, 3], [4, 5, 1], [4, 1, 0]] self.assertClose(mesh.faces_padded()[0], torch.tensor(faces_expected))
def test_save_load_with_normals(self): points = torch.tensor([[0, 0, 0], [0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=torch.float32) normals = torch.tensor([[0, 1, 0], [1, 0, 0], [1, 4, 1], [1, 0, 0]], dtype=torch.float32) features = torch.rand_like(points) for do_features, do_normals in itertools.product([True, False], [True, False]): cloud = Pointclouds( points=[points], features=[features] if do_features else None, normals=[normals] if do_normals else None, ) device = torch.device("cuda:0") io = IO() with NamedTemporaryFile(mode="w", suffix=".ply") as f: io.save_pointcloud(cloud.cuda(), f.name) f.flush() cloud2 = io.load_pointcloud(f.name, device=device) self.assertEqual(cloud2.device, device) cloud2 = cloud2.cpu() self.assertClose(cloud2.points_padded(), cloud.points_padded()) if do_normals: self.assertClose(cloud2.normals_padded(), cloud.normals_padded()) else: self.assertIsNone(cloud.normals_padded()) self.assertIsNone(cloud2.normals_padded()) if do_features: self.assertClose(cloud2.features_packed(), features) else: self.assertIsNone(cloud2.features_packed())
def test_load_vertex_colors(self): # Example with no faces and with integer vertex colors off_file_lines = [ "8 1 12", " 1.0 0.0 1.4142 0 1 0", " 0.0 1.0 1.4142 0 1 0", "-1.0 0.0 1.4142 0 1 0", " 0.0 -1.0 1.4142 0 1 0", " 1.0 0.0 0.0 0 1 0", " 0.0 1.0 0.0 0 1 0", "-1.0 0.0 0.0 0 1 0", " 0.0 -1.0 0.0 0 1 0", "3 0 1 2", ] off_file = "\n".join(off_file_lines) io = IO() with NamedTemporaryFile(mode="w", suffix=".off") as f: f.write(off_file) f.flush() mesh = io.load_mesh(f.name) self.assertEqual(mesh.verts_padded().shape, (1, 8, 3)) verts_lines = (line.split()[:3] for line in off_file_lines[1:9]) verts_data = [[[float(x) for x in line] for line in verts_lines]] self.assertClose(mesh.verts_padded(), torch.tensor(verts_data)) self.assertClose(mesh.faces_padded(), torch.tensor([[[0, 1, 2]]])) self.assertIsInstance(mesh.textures, TexturesVertex) colors = mesh.textures.verts_features_padded() self.assertEqual(colors.shape, (1, 8, 3)) self.assertClose(colors[0, :, [0, 2]], torch.zeros(8, 2)) self.assertClose(colors[0, :, 1], torch.full((8, ), 1.0 / 255))
def test_load_face_colors(self): # Example from wikipedia off_file_lines = [ "OFF", "# cube.off", "# A cube", " ", "8 6 12", " 1.0 0.0 1.4142", " 0.0 1.0 1.4142", "-1.0 0.0 1.4142", " 0.0 -1.0 1.4142", " 1.0 0.0 0.0", " 0.0 1.0 0.0", "-1.0 0.0 0.0", " 0.0 -1.0 0.0", "4 0 1 2 3 255 0 0 #red", "4 7 4 0 3 0 255 0 #green", "4 4 5 1 0 0 0 255 #blue", "4 5 6 2 1 0 255 0 ", "4 3 2 6 7 0 0 255", "4 6 5 4 7 255 0 0", ] off_file = "\n".join(off_file_lines) io = IO() with NamedTemporaryFile(mode="w", suffix=".off") as f: f.write(off_file) f.flush() mesh = io.load_mesh(f.name) self.assertEqual(mesh.verts_padded().shape, (1, 8, 3)) verts_str = " ".join(off_file_lines[5:13]) verts_data = torch.tensor([float(i) for i in verts_str.split()]) self.assertClose(mesh.verts_padded().flatten(), verts_data) self.assertClose(mesh.faces_padded(), torch.tensor(CUBE_FACES)[None]) faces_colors_full = mesh.textures.atlas_padded() self.assertEqual(faces_colors_full.shape, (1, 12, 1, 1, 3)) faces_colors = faces_colors_full[0, :, 0, 0] max_color = faces_colors.max() self.assertEqual(max_color, 1) # Every face has one color 1, the rest 0. total_color = faces_colors.sum(dim=1) self.assertEqual(total_color.max(), max_color) self.assertEqual(total_color.min(), max_color)
def test_save_load_meshes(self): verts = torch.tensor([[0, 0, 0], [0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=torch.float32) faces = torch.tensor([[0, 1, 2], [0, 2, 3]]) normals = torch.tensor([[0, 1, 0], [1, 0, 0], [1, 4, 1], [1, 0, 0]], dtype=torch.float32) vert_colors = torch.rand_like(verts) texture = TexturesVertex(verts_features=[vert_colors]) for do_textures, do_normals in itertools.product([True, False], [True, False]): mesh = Meshes( verts=[verts], faces=[faces], textures=texture if do_textures else None, verts_normals=[normals] if do_normals else None, ) device = torch.device("cuda:0") io = IO() with NamedTemporaryFile(mode="w", suffix=".ply") as f: io.save_mesh(mesh.cuda(), f.name) f.flush() mesh2 = io.load_mesh(f.name, device=device) self.assertEqual(mesh2.device, device) mesh2 = mesh2.cpu() self.assertClose(mesh2.verts_padded(), mesh.verts_padded()) self.assertClose(mesh2.faces_padded(), mesh.faces_padded()) if do_normals: self.assertTrue(mesh.has_verts_normals()) self.assertTrue(mesh2.has_verts_normals()) self.assertClose(mesh2.verts_normals_padded(), mesh.verts_normals_padded()) else: self.assertFalse(mesh.has_verts_normals()) self.assertFalse(mesh2.has_verts_normals()) self.assertFalse( torch.allclose(mesh2.verts_normals_padded(), normals)) if do_textures: self.assertIsInstance(mesh2.textures, TexturesVertex) self.assertClose(mesh2.textures.verts_features_list()[0], vert_colors) else: self.assertIsNone(mesh2.textures)
def test_save_too_many_colors(self): verts = torch.tensor([[0, 0, 0], [0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=torch.float32) faces = torch.tensor([[0, 1, 2], [0, 2, 3]]) vert_colors = torch.rand((4, 7)) texture_with_seven_colors = TexturesVertex( verts_features=[vert_colors]) mesh = Meshes( verts=[verts], faces=[faces], textures=texture_with_seven_colors, ) io = IO() msg = "Texture will not be saved as it has 7 colors, not 3." with NamedTemporaryFile(mode="w", suffix=".ply") as f: with self.assertWarnsRegex(UserWarning, msg): io.save_mesh(mesh.cuda(), f.name)
def test_load_no_usemtl(self): obj_filename = "missing_usemtl/cow.obj" # obj_filename has no "usemtl material_1" line filename = os.path.join(DATA_DIR, obj_filename) # TexturesUV type mesh = IO().load_mesh(filename) self.assertIsNotNone(mesh.textures) verts, faces, aux = load_obj(filename) self.assertTrue("material_1" in aux.material_colors) self.assertTrue("material_1" in aux.texture_images)
def test_load_open3d_mesh(self): # Header based on issue #1104 header = "\n".join([ "ply", "format binary_little_endian 1.0", "comment Created by Open3D", "element vertex 3", "property double x", "property double y", "property double z", "property double nx", "property double ny", "property double nz", "property uchar red", "property uchar green", "property uchar blue", "element face 1", "property list uchar uint vertex_indices", "end_header", "", ]).encode("ascii") vert_data = struct.pack("<" + "ddddddBBB" * 3, *range(9 * 3)) face_data = struct.pack("<" + "BIII", 3, 0, 1, 2) io = IO() with NamedTemporaryFile(mode="wb", suffix=".ply") as f: f.write(header) f.write(vert_data) f.write(face_data) f.flush() mesh = io.load_mesh(f.name) self.assertClose(mesh.faces_padded(), torch.arange(3)[None, None]) self.assertClose( mesh.verts_padded(), (torch.arange(3) + 9.0 * torch.arange(3)[:, None])[None], )
def test_load_cloudcompare_pointcloud(self): """ Test loading a pointcloud styled like some cloudcompare output. cloudcompare is an open source 3D point cloud processing software. """ header = "\n".join([ "ply", "format binary_little_endian 1.0", "obj_info Not a key-value pair!", "element vertex 8", "property double x", "property double y", "property double z", "property uchar red", "property uchar green", "property uchar blue", "property float my_Favorite", "end_header", "", ]).encode("ascii") data = struct.pack("<" + "dddBBBf" * 8, *range(56)) io = IO() with NamedTemporaryFile(mode="wb", suffix=".ply") as f: f.write(header) f.write(data) f.flush() pointcloud = io.load_pointcloud(f.name) self.assertClose( pointcloud.points_padded()[0], torch.FloatTensor([0, 1, 2]) + 7 * torch.arange(8)[:, None], ) self.assertClose( pointcloud.features_padded()[0], torch.FloatTensor([3, 4, 5]) + 7 * torch.arange(8)[:, None], )
def test_save_pointcloud(self): header = "\n".join([ "ply", "format binary_little_endian 1.0", "element vertex 8", "property float x", "property float y", "property float z", "property float red", "property float green", "property float blue", "end_header", "", ]).encode("ascii") data = struct.pack("<" + "f" * 48, *range(48)) points = torch.FloatTensor([0, 1, 2]) + 6 * torch.arange(8)[:, None] features = torch.FloatTensor([3, 4, 5]) + 6 * torch.arange(8)[:, None] pointcloud = Pointclouds(points=[points], features=[features]) io = IO() with NamedTemporaryFile(mode="rb", suffix=".ply") as f: io.save_pointcloud(data=pointcloud, path=f.name) f.flush() f.seek(0) actual_data = f.read() reloaded_pointcloud = io.load_pointcloud(f.name) self.assertEqual(header + data, actual_data) self.assertClose(reloaded_pointcloud.points_list()[0], points) self.assertClose(reloaded_pointcloud.features_list()[0], features) with NamedTemporaryFile(mode="r", suffix=".ply") as f: io.save_pointcloud(data=pointcloud, path=f.name, binary=False) reloaded_pointcloud2 = io.load_pointcloud(f.name) self.assertEqual(f.readline(), "ply\n") self.assertEqual(f.readline(), "format ascii 1.0\n") self.assertClose(reloaded_pointcloud2.points_list()[0], points) self.assertClose(reloaded_pointcloud2.features_list()[0], features)
def test_bad(self): # Test errors from various invalid OFF files. io = IO() def load(lines): off_file = "\n".join(lines) with NamedTemporaryFile(mode="w", suffix=".off") as f: f.write(off_file) f.flush() io.load_mesh(f.name) # First a good example lines = [ "4 2 12", " 1.0 0.0 1.4142", " 0.0 1.0 1.4142", " 1.0 0.0 0.4142", " 0.0 1.0 0.4142", "3 0 1 2 ", "3 1 3 0 ", ] # This example passes. load(lines) # OFF can occur on the first line separately load(["OFF"] + lines) # OFF line can be merged in to the first line lines2 = lines.copy() lines2[0] = "OFF " + lines[0] load(lines2) with self.assertRaisesRegex(ValueError, "Not enough face data."): load(lines[:-1]) lines2 = lines.copy() lines2[0] = "4 1 12" with self.assertRaisesRegex(ValueError, "Extra data at end of file:"): load(lines2) lines2 = lines.copy() lines2[-1] = "2 1 3" with self.assertRaisesRegex(ValueError, "Faces must have at least 3 vertices."): load(lines2) lines2 = lines.copy() lines2[-1] = "4 1 3 0" with self.assertRaisesRegex( ValueError, "A line of face data did not have the specified length."): load(lines2) lines2 = lines.copy() lines2[0] = "6 2 0" with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 5"): load(lines2) lines2[0] = "5 1 0" with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 5"): load(lines2) lines2[0] = "16 2 0" with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 5"): load(lines2) lines2[0] = "3 3 0" # This is a bit of a special case because the last vertex could be a face with self.assertRaisesRegex(ValueError, "Faces must have at least 3 vertices."): load(lines2) lines2[4] = "7.3 4.2 8.3" with self.assertRaisesRegex( ValueError, "A line of face data did not have the specified length."): load(lines2) # Now try bad number of colors lines2 = lines.copy() lines2[2] = "7.3 4.2 8.3 932" with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 2"): load(lines2) lines2[1] = "7.3 4.2 8.3 932" lines2[3] = "7.3 4.2 8.3 932" lines2[4] = "7.3 4.2 8.3 932" with self.assertRaisesRegex(ValueError, "Bad vertex data."): load(lines2) lines2 = lines.copy() lines2[5] = "3 0 1 2 0.9" lines2[6] = "3 0 3 0 0.9" with self.assertRaisesRegex(ValueError, "Unexpected number of colors."): load(lines2) lines2 = lines.copy() for i in range(1, 7): lines2[i] = lines2[i] + " 4 4 4 4" msg = "Faces colors ignored because vertex colors provided too." with self.assertWarnsRegex(UserWarning, msg): load(lines2)
def test_save_load_icosphere(self): # Test that saving a mesh as an off file and loading it results in the # same data on the correct device, for all permitted types of textures. # Standard test is for random colors, but also check totally white, # because there's a different in OFF semantics between "1.0" color (=full) # and "1" (= 1/255 color) sphere = ico_sphere(0) io = IO() device = torch.device("cuda:0") atlas_padded = torch.rand(1, sphere.faces_list()[0].shape[0], 1, 1, 3) atlas = TexturesAtlas(atlas_padded) atlas_padded_white = torch.ones(1, sphere.faces_list()[0].shape[0], 1, 1, 3) atlas_white = TexturesAtlas(atlas_padded_white) verts_colors_padded = torch.rand(1, sphere.verts_list()[0].shape[0], 3) vertex_texture = TexturesVertex(verts_colors_padded) verts_colors_padded_white = torch.ones(1, sphere.verts_list()[0].shape[0], 3) vertex_texture_white = TexturesVertex(verts_colors_padded_white) # No colors case with NamedTemporaryFile(mode="w", suffix=".off") as f: io.save_mesh(sphere, f.name) f.flush() mesh1 = io.load_mesh(f.name, device=device) self.assertEqual(mesh1.device, device) mesh1 = mesh1.cpu() self.assertClose(mesh1.verts_padded(), sphere.verts_padded()) self.assertClose(mesh1.faces_padded(), sphere.faces_padded()) self.assertIsNone(mesh1.textures) # Atlas case sphere.textures = atlas with NamedTemporaryFile(mode="w", suffix=".off") as f: io.save_mesh(sphere, f.name) f.flush() mesh2 = io.load_mesh(f.name, device=device) self.assertEqual(mesh2.device, device) mesh2 = mesh2.cpu() self.assertClose(mesh2.verts_padded(), sphere.verts_padded()) self.assertClose(mesh2.faces_padded(), sphere.faces_padded()) self.assertClose(mesh2.textures.atlas_padded(), atlas_padded, atol=1e-4) # White atlas case sphere.textures = atlas_white with NamedTemporaryFile(mode="w", suffix=".off") as f: io.save_mesh(sphere, f.name) f.flush() mesh3 = io.load_mesh(f.name) self.assertClose(mesh3.textures.atlas_padded(), atlas_padded_white, atol=1e-4) # TexturesVertex case sphere.textures = vertex_texture with NamedTemporaryFile(mode="w", suffix=".off") as f: io.save_mesh(sphere, f.name) f.flush() mesh4 = io.load_mesh(f.name, device=device) self.assertEqual(mesh4.device, device) mesh4 = mesh4.cpu() self.assertClose(mesh4.verts_padded(), sphere.verts_padded()) self.assertClose(mesh4.faces_padded(), sphere.faces_padded()) self.assertClose(mesh4.textures.verts_features_padded(), verts_colors_padded, atol=1e-4) # white TexturesVertex case sphere.textures = vertex_texture_white with NamedTemporaryFile(mode="w", suffix=".off") as f: io.save_mesh(sphere, f.name) f.flush() mesh5 = io.load_mesh(f.name) self.assertClose(mesh5.textures.verts_features_padded(), verts_colors_padded_white, atol=1e-4)
def test_save_pointcloud(self): header = "\n".join([ "ply", "format binary_little_endian 1.0", "element vertex 8", "property float x", "property float y", "property float z", "property float red", "property float green", "property float blue", "end_header", "", ]).encode("ascii") data = struct.pack("<" + "f" * 48, *range(48)) points = torch.FloatTensor([0, 1, 2]) + 6 * torch.arange(8)[:, None] features_large = torch.FloatTensor([3, 4, 5 ]) + 6 * torch.arange(8)[:, None] features = features_large / 255.0 pointcloud_largefeatures = Pointclouds(points=[points], features=[features_large]) pointcloud = Pointclouds(points=[points], features=[features]) io = IO() with NamedTemporaryFile(mode="rb", suffix=".ply") as f: io.save_pointcloud(data=pointcloud_largefeatures, path=f.name) f.flush() f.seek(0) actual_data = f.read() reloaded_pointcloud = io.load_pointcloud(f.name) self.assertEqual(header + data, actual_data) self.assertClose(reloaded_pointcloud.points_list()[0], points) self.assertClose(reloaded_pointcloud.features_list()[0], features_large) # Test the load-save cycle leaves file completely unchanged with NamedTemporaryFile(mode="rb", suffix=".ply") as f: io.save_pointcloud( data=reloaded_pointcloud, path=f.name, ) f.flush() f.seek(0) data2 = f.read() self.assertEqual(data2, actual_data) with NamedTemporaryFile(mode="r", suffix=".ply") as f: io.save_pointcloud(data=pointcloud, path=f.name, binary=False, decimal_places=9) reloaded_pointcloud2 = io.load_pointcloud(f.name) self.assertEqual(f.readline(), "ply\n") self.assertEqual(f.readline(), "format ascii 1.0\n") self.assertClose(reloaded_pointcloud2.points_list()[0], points) self.assertClose(reloaded_pointcloud2.features_list()[0], features) for binary in [True, False]: with NamedTemporaryFile(mode="rb", suffix=".ply") as f: io.save_pointcloud(data=pointcloud, path=f.name, colors_as_uint8=True, binary=binary) f.flush() f.seek(0) actual_data = f.read() reloaded_pointcloud3 = io.load_pointcloud(f.name) self.assertClose(reloaded_pointcloud3.features_list()[0], features) self.assertIn(b"property uchar green", actual_data) # Test the load-save cycle leaves file completely unchanged with NamedTemporaryFile(mode="rb", suffix=".ply") as f: io.save_pointcloud( data=reloaded_pointcloud3, path=f.name, binary=binary, colors_as_uint8=True, ) f.flush() f.seek(0) data2 = f.read() self.assertEqual(data2, actual_data)
def test_load_obj_complex_pluggable(self): """ This won't work on Windows due to the behavior of NamedTemporaryFile """ obj_file = "\n".join([ "# this is a comment", # Comments should be ignored. "v 0.1 0.2 0.3", "v 0.2 0.3 0.4", "v 0.3 0.4 0.5", "v 0.4 0.5 0.6", "vn 0.000000 0.000000 -1.000000", "vn -1.000000 -0.000000 -0.000000", "vn -0.000000 -0.000000 1.000000", # Normals should not be ignored. "v 0.5 0.6 0.7", "vt 0.749279 0.501284 0.0", # Some files add 0.0 - ignore this. "vt 0.999110 0.501077", "vt 0.999455 0.750380", "f 1 2 3", "f 1 2 4 3 5", # Polygons should be split into triangles "f 2/1/2 3/1/2 4/2/2", # Texture/normals are loaded correctly. "f -1 -2 1", # Negative indexing counts from the end. ]) io = IO() with NamedTemporaryFile(mode="w", suffix=".obj") as f: f.write(obj_file) f.flush() mesh = io.load_mesh(f.name) mesh_from_path = io.load_mesh(Path(f.name)) with NamedTemporaryFile(mode="w", suffix=".ply") as f: f.write(obj_file) f.flush() with self.assertRaisesRegex(ValueError, "Invalid file header."): io.load_mesh(f.name) expected_verts = torch.tensor( [ [0.1, 0.2, 0.3], [0.2, 0.3, 0.4], [0.3, 0.4, 0.5], [0.4, 0.5, 0.6], [0.5, 0.6, 0.7], ], dtype=torch.float32, ) expected_faces = torch.tensor( [ [0, 1, 2], # First face [0, 1, 3], # Second face (polygon) [0, 3, 2], # Second face (polygon) [0, 2, 4], # Second face (polygon) [1, 2, 3], # Third face (normals / texture) [4, 3, 0], # Fourth face (negative indices) ], dtype=torch.int64, ) self.assertClose(mesh.verts_padded(), expected_verts[None]) self.assertClose(mesh.faces_padded(), expected_faces[None]) self.assertClose(mesh_from_path.verts_padded(), expected_verts[None]) self.assertClose(mesh_from_path.faces_padded(), expected_faces[None]) self.assertIsNone(mesh.textures)
def _load(path, **kwargs) -> Meshes: io = IO() io.register_meshes_format(MeshGlbFormat()) return io.load_mesh(path, **kwargs)