Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release: Loop Subdivision #1762

Merged
merged 36 commits into from
Dec 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
72f1c6f
Add loop subdivision function in remesh file
HyeonseoNam Nov 14, 2022
4841ecc
add function docstring
HyeonseoNam Nov 14, 2022
75c1464
edit function docstring
HyeonseoNam Nov 14, 2022
2e951a0
fix the slowest part
HyeonseoNam Nov 14, 2022
b220156
add loop subdivision into Trimesh class
HyeonseoNam Nov 14, 2022
be6c5b5
edit loop subdivision for multibody case
HyeonseoNam Nov 15, 2022
b656817
add test cases for loop subdivision
HyeonseoNam Nov 15, 2022
4adc252
add error message
HyeonseoNam Nov 16, 2022
6a886da
fix formatting
HyeonseoNam Nov 16, 2022
026fba0
Revert "fix formatting"
HyeonseoNam Nov 16, 2022
fd38dc1
fix format
HyeonseoNam Nov 16, 2022
2ed14ca
fix format
HyeonseoNam Nov 16, 2022
6e5474e
fix import zip_longest for py2
HyeonseoNam Nov 16, 2022
d7d064c
fix format
HyeonseoNam Nov 16, 2022
f689147
add urdf schema
mikedh Nov 23, 2022
9889c78
Merge pull request #1750 from HyeonseoNam/feature/loop
mikedh Nov 23, 2022
5e453af
change default args
mikedh Nov 23, 2022
870a526
add test for unique_name
mikedh Nov 24, 2022
1fb0c26
feature: add support for adding animations to gltf export as a postpr…
Krande Nov 27, 2022
3d47d6a
fix #1755
mikedh Nov 28, 2022
c0cc6bb
simplify face preprocessing
mikedh Nov 28, 2022
ba0f510
Merge pull request #1765 from Krande/gltf-animations
mikedh Nov 29, 2022
f403b80
remove split_object
mikedh Nov 29, 2022
a32e208
try automatic multibody logic
mikedh Nov 29, 2022
f78e12a
filter vertices for multibody subdivision
mikedh Nov 29, 2022
312724c
replace unicode chr
mikedh Nov 29, 2022
6eb00f3
change validation as a todo
mikedh Nov 29, 2022
eee850d
python2
mikedh Nov 29, 2022
37c5f30
only test dae on ros-industrial
mikedh Nov 29, 2022
965024a
fix stl name on python2
mikedh Nov 29, 2022
489b3ca
remove silly header logic in
mikedh Nov 29, 2022
3be0c62
change dae ignore_broken default
mikedh Nov 29, 2022
4967ab9
disable dae for now
mikedh Nov 29, 2022
96faea2
fix remesh on python 2
mikedh Nov 30, 2022
1915fe5
remove embed
mikedh Nov 30, 2022
c27cda2
tests for subdivide_loop
mikedh Dec 1, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def abspath(rel):
# The theme to use for HTML and HTML Help pages
html_theme = 'sphinx_rtd_theme'
# html_theme = 'insegel'
# html_theme = 'furo'

# options for rtd-theme
html_theme_options = {
Expand Down
3 changes: 2 additions & 1 deletion docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ pyopenssl==22.1.0
autodocsumm==0.2.9
jinja2==3.1.2
matplotlib==3.6.2
nbconvert==7.2.4
nbconvert==7.2.5

2 changes: 1 addition & 1 deletion examples/section.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
"source": [
"# we can plot the intersection (red) and our original geometry(black and green)\n",
"ax = plt.gca()\n",
"for h in hits:\n",
"for h in hits.geoms:\n",
" ax.plot(*h.xy, color='r')\n",
"slice_2D.show()"
]
Expand Down
Binary file added models/forearm.zae
Binary file not shown.
12 changes: 9 additions & 3 deletions tests/corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@
available.difference_update(
[k for k, v in
trimesh.exchange.load.mesh_loaders.items()
if v in (trimesh.exchange.misc.load_meshio,
trimesh.exchange.dae.load_collada)])
if v in (trimesh.exchange.misc.load_meshio,)])
# remove loaders we don't care about
available.difference_update({'json'})
available.difference_update({'json', 'dae', 'zae'})
available.update({'dxf', 'svg'})


Expand Down Expand Up @@ -173,6 +172,13 @@ def equal(a, b):
report.update(on_repo(
repo='KhronosGroup/glTF-Sample-Models',
commit='8e9a5a6ad1a2790e2333e3eb48a1ee39f9e0e31b'))

# add back collada for this repo
available.update(['dae', 'zae'])
report.update(on_repo(
repo='ros-industrial/universal_robot',
commit='8f01aa1934079e5a2c859ccaa9dd6623d4cfa2fe'))

P.print()

# print a formatted report of what we loaded
Expand Down
80 changes: 80 additions & 0 deletions tests/test_obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,27 @@ def test_chair(self):
# assert g.np.allclose(
# 1.0, g.np.linalg.norm(mesh.vertex_normals, axis=1))

def test_multi_nodupe(self):
s = g.get_mesh("forearm.zae")
obj, mtl = g.trimesh.exchange.obj.export_obj(
s, include_color=True,
include_texture=True,
return_texture=True)
# should be using one material file
assert obj.count('mtllib') == 1
assert 'mtllib material.mtl' in obj
# should be specifying 5 materials
assert obj.count('usemtl') == 5

# this file has only the properties (no images)
assert len(mtl) == 1
mtl_names = [
L.strip().split()[-1].strip() for L in
mtl['material.mtl'].decode('utf-8').split('\n')
if 'newmtl' in L]
# there should be 5 unique material names
assert len(set(mtl_names)) == 5

def test_mtl_color_roundtrip(self):

# create a mesh with a simple material
Expand Down Expand Up @@ -327,6 +348,65 @@ def test_mtl_color_roundtrip(self):
assert g.np.isclose(m.visual.material.glossiness,
r.visual.material.glossiness)

def test_compound_scene_export(self):

# generate a mesh with multiple textures
a = g.get_mesh('BoxTextured.glb')
a = a.scaled(1.0 / a.extents.max())
a.apply_translation(-a.bounds[0])

b = g.get_mesh('fuze.obj').scene()
b = b.scaled(1.0 / b.extents.max())
b.apply_translation(-b.bounds[0] + [2, 0, 0])

d = next(iter(b.copy().geometry.values()))
d.apply_translation([-1, 0, 0])
assert hash(d.visual.material) == hash(
b.geometry['fuze.obj'].visual.material)

# should change the material hash
d.visual.material.glossiness = 0.1
assert hash(d.visual.material) != hash(
b.geometry['fuze.obj'].visual.material)

# generate a compound scene
c = a + b + d
for i in c.geometry.values():
# name all the materials the same thing
i.visual.material.name = 'material_0'

# export the compound scene
obj, mtl = c.export(file_type='obj', return_texture=True)
# there should be exactly one mtllib referenced
assert obj.count('mtllib') == 1
assert obj.count('usemtl') == 3

# should be one texture image for each of 3
# plus the `.mtl` file itself
# if we had image-hash-deduplication this should
# be changed to 3 as the image for `b` and `d` are the same
assert len(mtl) == 4

# get the material names specified

mtl_names = [
L.strip().split()[-1].strip() for L in
mtl['material.mtl'].decode('utf-8').split('\n')
if 'newmtl' in L]
# there should be 3 unique material names
assert len(set(mtl_names)) == 3

# now reload the compound scene
t = g.trimesh.load(
file_obj=g.trimesh.util.wrap_as_stream(obj),
file_type='obj',
resolver=g.trimesh.resolvers.ZipResolver(mtl),
group_material=False,
split_object=True)
# these names should match eventually
assert len(t.geometry.keys()) == len(c.geometry.keys())
assert g.np.isclose(t.area, c.area)


def simple_load(text):
# we're going to load faces in a basic text way
Expand Down
81 changes: 79 additions & 2 deletions tests/test_remesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ def test_subdivide(self):
sub, idx = m.subdivide_to_size(
max_edge=max_edge, return_index=True)
assert g.np.allclose(m.area, sub.area)
edge_len = (g.np.diff(sub.vertices[sub.edges_unique],
axis=1).reshape((-1, 3))**2).sum(axis=1)**.5
edge_len = (g.np.diff(
sub.vertices[sub.edges_unique],
axis=1).reshape((-1, 3))**2).sum(axis=1)**.5
assert (edge_len < max_edge).all()

# should be the same order of magnitude size
assert g.np.allclose(m.extents, sub.extents, rtol=2)
# should be one index per new face
assert len(idx) == len(sub.faces)
# every face should be subdivided
Expand Down Expand Up @@ -106,6 +109,80 @@ def test_sub(self):
# volume should be the same
assert g.np.isclose(m.volume, s.volume)

def test_loop(self):
meshes = [
g.get_mesh('soup.stl'), # a soup of random triangles
g.get_mesh('featuretype.STL')] # a mesh with a single body

for m in meshes:
sub = m.subdivide_loop(iterations=1)
# number of faces should increase
assert len(sub.faces) > len(m.faces)
# subdivided faces are smaller
assert sub.area_faces.mean() < m.area_faces.mean()

def test_loop_multibody(self):
# a mesh with multiple bodies
mesh = g.get_mesh('cycloidal.ply')
sub = mesh.subdivide_loop(iterations=2)

# number of faces should increase
assert len(sub.faces) > len(mesh.faces)
# subdivided faces are smaller
assert sub.area_faces.mean() < mesh.area_faces.mean()
# should be the same order of magnitude area
# rtol=2 means it can be up to twice/half
assert g.np.isclose(sub.area, mesh.area, rtol=2)
# should have the same number of bodies
assert len(mesh.split()) == len(sub.split())

def test_loop_multi_simple(self, count=10):
meshes = []
for i in range(count):
current = g.trimesh.creation.icosahedron()
current.apply_translation([i * 1.5, 0, 0])
meshes.append(current)
# concatenate into a single multibody mesh
m = g.trimesh.util.concatenate(meshes)
# run subdivision on that
a = m.subdivide_loop(iterations=4)
# make sure it splits and is watertight
split = a.split()
assert len(split) == count
assert all(i.is_watertight for i in split)

def test_loop_correct(self):
box = g.trimesh.creation.box()
big_sphere = g.trimesh.creation.icosphere(radius=0.5)
small_sphere = g.trimesh.creation.icosphere(radius=0.4)
sub = box.subdivide_loop(iterations=2)
# smaller than 0.5 sphere
assert big_sphere.contains(sub.vertices).all()
# bigger than 0.4 sphere
assert (~small_sphere.contains(sub.vertices)).all()

def test_loop_bound(self):
def _get_boundary_vertices(mesh):
boundary_groups = g.trimesh.grouping.group_rows(
mesh.edges_sorted, require_count=1)
return mesh.vertices[g.np.unique(mesh.edges_sorted[boundary_groups])]

box = g.trimesh.creation.box()
bottom_mask = g.np.zeros(len(box.faces), dtype=bool)
bottom_faces = [1, 5]
bottom_mask[bottom_faces] = True
# eliminate bottom of the box
box.update_faces(~bottom_mask)
bottom_vrts = _get_boundary_vertices(box)
# subdivide box
sub = box.subdivide_loop(iterations=2)
sub_bottom_vrts = _get_boundary_vertices(sub)

# y value of bottom boundary vertices should not be changed
assert g.np.isclose(bottom_vrts[:, 1].mean(),
sub_bottom_vrts[:, 1].mean(),
atol=1e-5)

def test_uv(self):
# get a mesh with texture
m = g.get_mesh('fuze.obj')
Expand Down
1 change: 1 addition & 0 deletions tests/test_stl.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def test_attrib(self):
def test_ascii_multibody(self):
s = g.get_mesh('multibody.stl')
assert len(s.geometry) == 2
assert set(s.geometry.keys()) == {'bodya', 'bodyb'}

def test_empty(self):
# demo files to check
Expand Down
17 changes: 17 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,23 @@ def test_unique_id(self):
# make sure id's can be reproduced
assert s == unique_ids_0[i]

def test_unique_name(self):
from trimesh.util import unique_name

assert len(unique_name(None, {})) > 0
assert len(unique_name('', {})) > 0

count = 10
names = set()
for i in range(count):
names.add(unique_name('hi', names))
assert len(names) == count

names = set()
for i in range(count):
names.add(unique_name('', names))
assert len(names) == count


class ContainsTest(unittest.TestCase):

Expand Down
30 changes: 29 additions & 1 deletion trimesh/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1505,7 +1505,8 @@ def vertex_neighbors(self):
[1, 2, 3, 4]
"""
return graph.neighbors(
edges=self.edges_unique, max_index=len(self.vertices))
edges=self.edges_unique,
max_index=len(self.vertices))

@caching.cache_decorator
def is_winding_consistent(self):
Expand Down Expand Up @@ -1997,6 +1998,33 @@ def subdivide_to_size(self, max_edge, max_iter=10, return_index=False):

return result

def subdivide_loop(self, iterations=None):
"""
Subdivide a mesh by dividing each triangle into four
triangles and approximating their smoothed surface
using loop subdivision. Loop subdivision often looks
better on triangular meshes than catmul-clark, which
operates primarily on quads.

Parameters
------------
iterations : int
Number of iterations to run subdivision.
multibody : bool
If True will try to subdivide for each submesh
"""
# perform subdivision for one mesh
new_vertices, new_faces = remesh.subdivide_loop(
vertices=self.vertices,
faces=self.faces,
iterations=iterations)
# create new mesh
result = Trimesh(
vertices=new_vertices,
faces=new_faces,
process=False)
return result

@log_time
def smoothed(self, **kwargs):
"""
Expand Down
9 changes: 1 addition & 8 deletions trimesh/constants.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import numpy as np

from .util import log, PY3

if PY3:
# will be the highest granularity clock available
from time import perf_counter as now
else:
# perf_counter not available on python 2
from time import time as now
from .util import log, now


class ToleranceMesh(object):
Expand Down
15 changes: 11 additions & 4 deletions trimesh/exchange/dae.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

def load_collada(file_obj,
resolver=None,
ignore_broken=False,
ignore_broken=True,
**kwargs):
"""
Load a COLLADA (.dae) file into a list of trimesh kwargs.
Expand All @@ -37,12 +37,19 @@ def load_collada(file_obj,
"""
import collada

if ignore_broken:
ignores = [collada.common.DaeError,
collada.common.DaeIncompleteError,
collada.common.DaeMalformedError,
collada.common.DaeBrokenRefError,
collada.common.DaeIncompleteError]
else:
ignores = None

# load scene using pycollada
c = collada.Collada(
file_obj,
ignore=[collada.common.DaeUnsupportedError,
collada.common.DaeBrokenRefError]
if ignore_broken else None)
ignore=ignores)

# Create material map from Material ID to trimesh material
material_map = {}
Expand Down
8 changes: 4 additions & 4 deletions trimesh/exchange/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,20 +239,20 @@ def export_scene(scene,
elif file_type == 'glb':
data = export_glb(scene, **kwargs)
elif file_type == 'dict':
data = scene_to_dict(scene)
data = scene_to_dict(scene, *kwargs)
elif file_type == 'obj':
if resolver is None and util.is_string(file_obj):
resolver = resolvers.FilePathResolver(file_obj)
data = export_obj(scene, resolver=resolver)
data = export_obj(scene, resolver=resolver, **kwargs)
elif file_type == 'dict64':
data = scene_to_dict(scene, use_base64=True)
elif file_type == 'svg':
from trimesh.path.exchange import svg_io
data = svg_io.export_svg(scene, **kwargs)
elif file_type == 'ply':
data = export_ply(scene.dump(concatenate=True))
data = export_ply(scene.dump(concatenate=True), **kwargs)
elif file_type == 'stl':
data = export_stl(scene.dump(concatenate=True))
data = export_stl(scene.dump(concatenate=True), **kwargs)
elif file_type == '3mf':
data = export_3MF(scene, **kwargs)
else:
Expand Down
Loading