Skip to content

Commit

Permalink
Meldis reload considers glTF content (RobotLocomotion#21297)
Browse files Browse the repository at this point in the history
  • Loading branch information
SeanCurtis-TRI authored Apr 11, 2024
1 parent b32107d commit c336929
Show file tree
Hide file tree
Showing 2 changed files with 90 additions and 12 deletions.
38 changes: 34 additions & 4 deletions bindings/pydrake/visualization/_meldis.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import copy
import hashlib
import json
import logging
import numpy as np
from pathlib import Path
Expand Down Expand Up @@ -147,12 +148,23 @@ def on_viewer_geometry_data(self, message: lcmt_viewer_geometry_data):

def on_mesh(self, path: Path):
assert isinstance(path, Path)
# Hash the file contents, even if we don't know how to interpret it.
content = self._read_file(path)
if path.suffix.lower() == ".obj":
for mtl_names in re.findall(rb"^\s*mtllib\s+(.*?)\s*$", content,
re.MULTILINE):
for mtl_name in mtl_names.decode("utf-8").split():
self.on_mtl(path.parent / mtl_name)
self.on_obj(path, content)
elif path.suffix.lower() == ".gltf":
self.on_gltf(path, content)
else:
_logger.warn(f"Unsupported mesh file: '{path}'\n"
"Update Meldis's hasher to trigger reloads on this "
"kind of file.")

def on_obj(self, path: Path, content: bytes):
assert isinstance(path, Path)
for mtl_names in re.findall(rb"^\s*mtllib\s+(.*?)\s*$", content,
re.MULTILINE):
for mtl_name in mtl_names.decode("utf-8").split():
self.on_mtl(path.parent / mtl_name)

def on_mtl(self, path: Path):
assert isinstance(path, Path)
Expand All @@ -165,6 +177,24 @@ def on_texture(self, path: Path):
assert isinstance(path, Path)
self._read_file(path)

def on_gltf(self, path: Path, content: bytes):
assert isinstance(path, Path)
try:
document = json.loads(content.decode(encoding="utf-8"))
except json.JSONDecodeError:
_logger.warn(f"glTF file is not valid JSON: {path}")
return

# Handle the images
for image in document.get("images", []):
if not image.get("uri", "").startswith("data:"):
self.on_texture(path.parent / image["uri"])

# Handle the .bin files.
for buffer in document.get("buffers", []):
if not buffer.get("uri", "").startswith("data:"):
self._read_file(path.parent / buffer["uri"])


class _ViewerApplet:
"""Displays lcmt_viewer_load_robot and lcmt_viewer_draw into MeshCat."""
Expand Down
64 changes: 56 additions & 8 deletions bindings/pydrake/visualization/test/meldis_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

import functools
import hashlib
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest

Expand Down Expand Up @@ -302,24 +302,24 @@ def dut(data):
message.num_links = len(message.link)
self.assertEqual(dut(message), empty_hash)

# Switch to a valid mesh filename => non-empty hash.
# Switch to a valid .obj mesh filename => non-empty hash.
test_tmpdir = Path(os.environ["TEST_TMPDIR"])
mesh_filename = test_tmpdir / "mesh_checksum_test.obj"
with open(mesh_filename, "w") as f:
obj_filename = test_tmpdir / "mesh_checksum_test.obj"
with open(obj_filename, "w") as f:
f.write("foobar")
mesh.string_data = str(mesh_filename)
mesh.string_data = str(obj_filename)
mesh_hash_1 = dut(message)
self.assertNotEqual(mesh_hash_1, empty_hash)

# Changing the mesh content changes the checksum.
# Changing the .obj mesh content changes the checksum.
# Invalid mtl filenames are not an error.
with open(mesh_filename, "w") as f:
with open(obj_filename, "w") as f:
f.write("foo\n mtllib mesh_checksum_test.mtl \nbar\n")
mesh_hash_2 = dut(message)
self.assertNotEqual(mesh_hash_2, empty_hash)
self.assertNotEqual(mesh_hash_2, mesh_hash_1)

# The appearance of the mtl file changes the checksum.
# The appearance of the .obj's mtl file changes the checksum.
with open(test_tmpdir / "mesh_checksum_test.mtl", "w") as f:
f.write("quux")
mesh_hash_3 = dut(message)
Expand All @@ -340,6 +340,54 @@ def dut(data):
"mesh_checksum_test.mtl",
"mesh_checksum_test.png"})

# Message with .gltf mesh that can't be parsed => non-empty hash.
# (Invalid glTF content is not an error.)
gltf_filename = test_tmpdir / "mesh_checksum_test.gltf"
with open(gltf_filename, "w") as f:
f.write("I'm adversarially not json. {")
mesh.string_data = str(gltf_filename)
gltf_hash_1 = dut(message)
self.assertNotEqual(gltf_hash_1, empty_hash)

# Valid glTF file, but with no external files; the glTF's contents
# matter.
with open(gltf_filename, "w") as f:
f.write("{}")
gltf_hash_2 = dut(message)
self.assertNotEqual(gltf_hash_2, empty_hash)
self.assertNotEqual(gltf_hash_2, gltf_hash_1)

# Valid glTF file reference an external image.
with open(gltf_filename, "w") as f:
f.write(json.dumps({"images": [{"uri": str(png_filename)}]}))
gltf_hash_3 = dut(message)
self.assertNotEqual(gltf_hash_3, empty_hash)
self.assertNotEqual(gltf_hash_3, gltf_hash_2)

# Now finally, the glTF file has a .bin. This time, as a cross-check,
# inspect the filenames that were hashed instead of the hash itself.
bin_filename = test_tmpdir / "mesh_checksum_test.bin"
bin_filename.touch()
with open(gltf_filename, "w") as f:
f.write(json.dumps({
"images": [{"uri": str(png_filename)}],
"buffers": [{"uri": str(bin_filename)}]
}))
hasher = mut._meldis._GeometryFileHasher()
hasher.on_viewer_load_robot(message)
hashed_names = set([x.name for x in hasher._paths])
self.assertSetEqual(hashed_names, {"mesh_checksum_test.gltf",
"mesh_checksum_test.bin",
"mesh_checksum_test.png"})

# A message with an unsupported extension => non-empty hash.
unsupported_filename = test_tmpdir / "mesh_checksum_test.ply"
with open(unsupported_filename, "w") as f:
f.write("Non-empty content will not matter.")
mesh.string_data = str(unsupported_filename)
unsupported_hash = dut(message)
self.assertNotEqual(unsupported_hash, empty_hash)

def test_viewer_applet_alpha_slider(self):
# Create the device under test.
dut = mut.Meldis()
Expand Down

0 comments on commit c336929

Please sign in to comment.