[Bug]: glTF triangle strips and fans lose the node their primitives came from, merging unrelated nodes

Author: kevin9327Created Sep 10, 2026Updated Sep 10, 2026

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:

python
for i, (mesh_index, mesh_transform) in enumerate(mesh_list):

and the strip and fan branches reuse i for the triangle counter:

python
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:

python
group_idx = primitive.material if group_by_material else (i, primitive.material)
...
metadata["node_index"] = i

group_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.

python
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=1

Screenshots/Videos

Relevant log output

bash
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=2

Both 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