[Bug]: glTF triangle strips and fans lose the node their primitives came from, merging unrelated nodes
Bug Description
When a .glb declares a primitive with mode TRIANGLE_STRIP (5) or TRIANGLE_FAN (6), the importer loses track of
which node each primitive came from. Two separate nodes get merged into a single mesh, so a scene that should hold two
independent entities holds one whose geometry spans both, and the second node's name and material are dropped.
In genesis/utils/gltf.py::parse_mesh_glb the outer loop walks the node list:
for i, (mesh_index, mesh_transform) in enumerate(mesh_list):and the strip and fan branches reuse i for the triangle counter:
elif mode == 5: # TRIANGLE_STRIP
triangles = []
for i in range(len(indices) - 2):so once either branch runs, i holds the last triangle index rather than the node index. Both consumers read i
afterwards:
group_idx = primitive.material if group_by_material else (i, primitive.material)
...
metadata["node_index"] = igroup_idx decides which primitives share a mesh, and node_index decides which submeshes are merged into one
collision geom (RigidEntityDescription, the groups_by_node block). A strip or fan whose triangle counter lands on
another node's index therefore fuses two unrelated nodes, and a node whose primitives end on different counters is
split apart.
The geometry decoding itself is fine, so nothing warns and the file loads.
Steps to Reproduce
Self-contained, no external assets. Two nodes, one triangle each, no material. The only thing that changes between the three runs is which node declares its triangle as a strip or a fan.
import tempfile
from pathlib import Path
import numpy as np
import pygltflib
import genesis as gs
from genesis.utils.gltf import parse_mesh_glb
TRI_A = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float32)
TRI_B = np.array([[4.0, 0.0, 0.0], [5.0, 0.0, 0.0], [4.0, 1.0, 0.0]], dtype=np.float32)
INDICES = np.array([0, 1, 2], dtype=np.uint32)
TRIANGLES, TRIANGLE_STRIP, TRIANGLE_FAN = 4, 5, 6
def write_glb(path, modes):
blob = b""
buffer_views = []
for data in (TRI_A.tobytes(), TRI_B.tobytes(), INDICES.tobytes()):
blob += b"\x00" * ((4 - len(blob) % 4) % 4)
buffer_views.append(pygltflib.BufferView(buffer=0, byteOffset=len(blob), byteLength=len(data)))
blob += data
gltf = pygltflib.GLTF2(
scene=0,
scenes=[pygltflib.Scene(nodes=[0, 1])],
nodes=[pygltflib.Node(mesh=0), pygltflib.Node(mesh=1)],
meshes=[
pygltflib.Mesh(
name=f"mesh_{i}",
primitives=[pygltflib.Primitive(attributes=pygltflib.Attributes(POSITION=i), indices=2, mode=mode)],
)
for i, mode in enumerate(modes)
],
accessors=[
pygltflib.Accessor(
bufferView=i,
componentType=pygltflib.FLOAT,
count=3,
type="VEC3",
min=tri.min(axis=0).tolist(),
max=tri.max(axis=0).tolist(),
)
for i, tri in enumerate((TRI_A, TRI_B))
]
+ [pygltflib.Accessor(bufferView=2, componentType=pygltflib.UNSIGNED_INT, count=3, type="SCALAR")],
bufferViews=buffer_views,
buffers=[pygltflib.Buffer(byteLength=len(blob))],
)
gltf.set_binary_blob(blob)
gltf.save_binary(str(path))
gs.init(backend=gs.cpu, logging_level="error")
with tempfile.TemporaryDirectory() as directory:
for label, modes in (
("both nodes TRIANGLES", (TRIANGLES, TRIANGLES)),
("second node TRIANGLE_STRIP", (TRIANGLES, TRIANGLE_STRIP)),
("first node TRIANGLE_FAN", (TRIANGLE_FAN, TRIANGLES)),
):
path = Path(directory) / "two_nodes.glb"
write_glb(path, modes)
meshes = parse_mesh_glb(
str(path), group_by_material=False, scale=None, is_mesh_zup=True, surface=gs.surfaces.Default()
)
print(f"{label}: 2 nodes -> {len(meshes)} mesh(es)")
for mesh in meshes:
print(
f" name={mesh.metadata['name']!r} node_index={mesh.metadata['node_index']} "
f"n_verts={len(mesh.trimesh.vertices)} n_faces={len(mesh.trimesh.faces)}"
)
print()Expected Behavior
Each node keeps its own mesh, its own name and its own node index, whatever primitive mode it uses:
both nodes TRIANGLES: 2 nodes -> 2 mesh(es)
name='mesh_0' node_index=0 n_verts=3 n_faces=1
name='mesh_1' node_index=1 n_verts=3 n_faces=1
second node TRIANGLE_STRIP: 2 nodes -> 2 mesh(es)
name='mesh_0' node_index=0 n_verts=3 n_faces=1
name='mesh_1' node_index=1 n_verts=3 n_faces=1
first node TRIANGLE_FAN: 2 nodes -> 2 mesh(es)
name='mesh_0' node_index=0 n_verts=3 n_faces=1
name='mesh_1' node_index=1 n_verts=3 n_faces=1Screenshots/Videos
Relevant log output
both nodes TRIANGLES: 2 nodes -> 2 mesh(es)
name='mesh_0' node_index=0 n_verts=3 n_faces=1
name='mesh_1' node_index=1 n_verts=3 n_faces=1
second node TRIANGLE_STRIP: 2 nodes -> 1 mesh(es)
name='mesh_0' node_index=0 n_verts=6 n_faces=2
first node TRIANGLE_FAN: 2 nodes -> 1 mesh(es)
name='mesh_0' node_index=1 n_verts=6 n_faces=2Both nodes' vertices end up in one mesh, and mesh_1 is gone.
Environment
- OS: Windows 11 24H2
- GPU/CPU: N/A - CPU backend, the defect is in the parser and is backend independent
- GPU-driver version: N/A
- CUDA / CUDA-toolkit version: N/A
Release version or Commit ID
1.4.0, commit b3c6c73a7a671fc486df5d69c9f481a91b1d57b6
Additional Context
Happy to open a PR giving the triangle loops their own counter and adding a regression case to
tests/parsers/test_mesh.py.
Source: Genesis-Embodied-AI/genesis-world