diff --git a/geometry/BUILD.bazel b/geometry/BUILD.bazel index 04c30ae26104..77143061d18b 100644 --- a/geometry/BUILD.bazel +++ b/geometry/BUILD.bazel @@ -2,10 +2,15 @@ load( "@drake//tools/skylark:drake_cc.bzl", + "drake_cc_binary", "drake_cc_googletest", "drake_cc_library", "drake_cc_package_library", ) +load( + "@drake//tools/skylark:drake_py.bzl", + "drake_py_binary", +) load("//tools/lint:lint.bzl", "add_lint_tests") package(default_visibility = ["//visibility:public"]) @@ -30,6 +35,7 @@ drake_cc_package_library( ":geometry_version", ":internal_frame", ":internal_geometry", + ":meshcat", ":proximity_engine", ":proximity_properties", ":rgba", @@ -336,6 +342,42 @@ drake_cc_library( ], ) +drake_cc_library( + name = "meshcat", + srcs = ["meshcat.cc"], + hdrs = ["meshcat.h"], + data = [ + "//doc:favicon", + "@meshcat//:dist/index.html", + "@meshcat//:dist/main.min.js", + ], + deps = [ + "//common:essential", + "//common:find_resource", + "//common:unused", + "@msgpack", + "@uwebsockets", + ], +) + +drake_cc_binary( + name = "meshcat_demo", + srcs = ["meshcat_demo.cc"], + deps = ["meshcat"], +) + +drake_py_binary( + name = "meshcat_websocket_client", + srcs = ["test/meshcat_websocket_client.py"], +) + +drake_cc_googletest( + name = "meshcat_test", + data = ["meshcat_websocket_client"], + tags = ["requires-network"], + deps = ["meshcat"], +) + # ----------------------------------------------------- filegroup( diff --git a/geometry/dev/BUILD.bazel b/geometry/dev/BUILD.bazel deleted file mode 100644 index 317ac116b3cb..000000000000 --- a/geometry/dev/BUILD.bazel +++ /dev/null @@ -1,36 +0,0 @@ -# -*- python -*- - -load( - "@drake//tools/skylark:drake_cc.bzl", - "drake_cc_binary", - "drake_cc_library", -) -load("//tools/lint:lint.bzl", "add_lint_tests") - -package(default_visibility = ["//visibility:private"]) - -drake_cc_library( - name = "meshcat", - srcs = ["meshcat.cc"], - hdrs = ["meshcat.h"], - data = [ - "//doc:favicon", - "@meshcat//:dist/index.html", - "@meshcat//:dist/main.min.js", - ], - deps = [ - "//common:essential", - "//common:find_resource", - "//common:unused", - "@msgpack", - "@uwebsockets", - ], -) - -drake_cc_binary( - name = "meshcat_demo", - srcs = ["meshcat_demo.cc"], - deps = ["meshcat"], -) - -add_lint_tests() diff --git a/geometry/dev/meshcat.cc b/geometry/meshcat.cc similarity index 71% rename from geometry/dev/meshcat.cc rename to geometry/meshcat.cc index 8b1faf51273c..d58f04f9ec9f 100644 --- a/geometry/dev/meshcat.cc +++ b/geometry/meshcat.cc @@ -1,14 +1,16 @@ -#include "drake/geometry/dev/meshcat.h" +#include "drake/geometry/meshcat.h" #include #include +#include #include #include #include -#include +#include #include #include +#include #include #include "drake/common/find_resource.h" @@ -48,13 +50,19 @@ class Meshcat::WebSocketPublisher { WebSocketPublisher() : app_future(app_promise.get_future()) {} // Call this from websocket thread. - void SetAppPromise(uWS::App* app, uWS::Loop* loop) { - app_promise.set_value(std::make_pair(app, loop)); + void SetAppPromise(uWS::App* app, uWS::Loop* loop, int port, + us_listen_socket_t* listen_socket) { + app_promise.set_value(std::make_tuple(app, loop, port, listen_socket)); } // Call this from main thread. void GetAppFuture() { - std::tie(app_, loop_) = app_future.get(); + std::tie(app_, loop_, port_, listen_socket_) = app_future.get(); + } + + int port() const { + DRAKE_DEMAND(port_ > 0); + return port_; } template @@ -66,8 +74,8 @@ class Meshcat::WebSocketPublisher { std::stringstream message; msgpack::zone z; - msgpack::pack(message, std::unordered_map( - {{"type", msgpack::object("set_property", z)}, + msgpack::pack(message, std::map( + {{"type", msgpack::object("set_property", z)}, {"path", msgpack::object(path, z)}, {"property", msgpack::object(property, z)}, {"value", msgpack::object(value, z)}})); @@ -88,9 +96,20 @@ class Meshcat::WebSocketPublisher { } } + void Shutdown() { + loop_->defer([this]() { + us_listen_socket_close(0, listen_socket_); + }); + } + private: - std::promise> app_promise{}; - std::future> app_future{}; + std::promise> + app_promise{}; + std::future> + app_future{}; + + int port_{-1}; + us_listen_socket_t* listen_socket_; // Only loop_->defer() should be called from outside the websocket_thread. See // the documentation for uWebSockets for further details: @@ -99,7 +118,7 @@ class Meshcat::WebSocketPublisher { // The remaining variables should only be accessed from the websocket_thread. uWS::App* app_{nullptr}; - std::unordered_map set_properties_{}; + std::map set_properties_{}; }; Meshcat::Meshcat() { @@ -111,7 +130,22 @@ Meshcat::Meshcat() { publisher_->GetAppFuture(); } -Meshcat::~Meshcat() = default; +Meshcat::~Meshcat() { + publisher_->Shutdown(); + // Waiting for the thread to close can be extremely slow; see discussion at + // https://github.com/uNetworking/uWebSockets/discussions/809 . So we detach + // instead. The thread uses callbacks that have data passed by value; it + // should not depend on this class in any way. + websocket_thread_.detach(); +} + +std::string Meshcat::web_url() const { + return fmt::format("http://localhost:{}", publisher_->port()); +} + +std::string Meshcat::ws_url() const { + return fmt::format("ws://localhost:{}", publisher_->port()); +} void Meshcat::JoinWebSocketThread() { websocket_thread_.join(); } @@ -152,26 +186,23 @@ void Meshcat::WebsocketMain() { }) .ws("/*", std::move(behavior)); - bool listening = false; + us_listen_socket_t* listen_socket = nullptr; do { app.listen( port, LIBUS_LISTEN_EXCLUSIVE_PORT, - [port, &listening](us_listen_socket_t* listenSocket) { - if (listenSocket) { + [port, &listen_socket](us_listen_socket_t* socket) { + if (socket) { std::cout << "Meshcat listening for connections at http://127.0.0.1:" << port << std::endl; - listening = true; + listen_socket = socket; } }); - } while (!listening && port++ <= kMaxPort); + } while (listen_socket == nullptr && port++ <= kMaxPort); - publisher_->SetAppPromise(&app, uWS::Loop::get()); + publisher_->SetAppPromise(&app, uWS::Loop::get(), port, listen_socket); app.run(); - - // run() should not terminate. If it does, then we've failed. - throw std::runtime_error("Meshcat websocket thread failed"); } } // namespace geometry diff --git a/geometry/dev/meshcat.h b/geometry/meshcat.h similarity index 81% rename from geometry/dev/meshcat.h rename to geometry/meshcat.h index 3a3c8218e35e..3cb3e4b37b1b 100644 --- a/geometry/dev/meshcat.h +++ b/geometry/meshcat.h @@ -18,8 +18,8 @@ this server is running. Note: This code is currently a skeleton implementation; it only allows you to set (boolean) properties as a minimal demonstration of sending data from C++ to -the viewer. It is the result of the first PR in a train of PRs that will -establish the full Meshcat functionality. See #13038. +the viewer. It is the result of the second PR in a train of PRs that will +establish the full Meshcat functionality. See #13038. */ class Meshcat { public: @@ -31,6 +31,13 @@ class Meshcat { ~Meshcat(); + // Returns the hosted http URL. + std::string web_url() const; + + // (Advanced) Returns the ws:// URL for direct connection to the websocket + // interface. Most users should connect via a browser opened to web_url(). + std::string ws_url() const; + /** The thread run by this class will run for the lifetime of the Meshcat instance. We provide this method as a simple way to block the main thread to allow users to open their browser and work with the Meshcat scene. */ diff --git a/geometry/dev/meshcat_demo.cc b/geometry/meshcat_demo.cc similarity index 90% rename from geometry/dev/meshcat_demo.cc rename to geometry/meshcat_demo.cc index fb624d9be60d..eb1d8a206aa7 100644 --- a/geometry/dev/meshcat_demo.cc +++ b/geometry/meshcat_demo.cc @@ -1,10 +1,10 @@ -#include "drake/geometry/dev/meshcat.h" +#include "drake/geometry/meshcat.h" /** This binary provides a simple demonstration of using Meshcat. It serves as a stand-in for the proper test suite that will come in the next PR, since it requires it's own substantial changes to the build system. -To test, you must manually run `bazel run //geometry/dev:meshcat_demo`. It will +To test, you must manually run `bazel run //geometry:meshcat_demo`. It will print two URLs to console. Navigating your browser to the first, you should see that the normally blue meshcat background is not visible (the background will look white). In the second URL, you should see the default meshcat view, but diff --git a/geometry/test/meshcat_test.cc b/geometry/test/meshcat_test.cc new file mode 100644 index 000000000000..e8f17d27e7cd --- /dev/null +++ b/geometry/test/meshcat_test.cc @@ -0,0 +1,80 @@ +#include "drake/geometry/meshcat.h" + +#include +#include +#include + +namespace drake { +namespace geometry { +namespace { + +using ::testing::HasSubstr; + +GTEST_TEST(MeshcatTest, TestHttp) { + drake::geometry::Meshcat meshcat; + // Note: The server doesn't respect all requests; unfortunately we can't use + // curl --head and wget --spider nor curl --range to avoid downloading the + // full file. + EXPECT_EQ(system(fmt::format("curl -o /dev/null --silent {}/index.html", + meshcat.web_url()) + .c_str()), + 0); + EXPECT_EQ(system(fmt::format("curl -o /dev/null --silent {}/main.min.js", + meshcat.web_url()) + .c_str()), + 0); + EXPECT_EQ(system(fmt::format("curl -o /dev/null --silent {}/favicon.ico", + meshcat.web_url()) + .c_str()), + 0); +} + +GTEST_TEST(MeshcatTest, ConstructMultiple) { + drake::geometry::Meshcat meshcat; + drake::geometry::Meshcat meshcat2; + + EXPECT_THAT(meshcat.web_url(), HasSubstr("http://localhost:")); + EXPECT_THAT(meshcat.ws_url(), HasSubstr("ws://localhost:")); + EXPECT_THAT(meshcat2.web_url(), HasSubstr("http://localhost:")); + EXPECT_THAT(meshcat2.ws_url(), HasSubstr("ws://localhost:")); + EXPECT_NE(meshcat.web_url(), meshcat2.web_url()); +} + +void CheckCommand(const drake::geometry::Meshcat& meshcat, int message_num, + const std::string& desired_command_json) { + EXPECT_EQ( + system(fmt::format( + "python3 geometry/test/meshcat_websocket_client.py '{}' {} '{}'", + meshcat.ws_url(), message_num, desired_command_json).c_str()), + 0); +} + +GTEST_TEST(MeshcatTest, SetProperty) { + drake::geometry::Meshcat meshcat; + meshcat.SetProperty("/Background", "visible", false); + CheckCommand(meshcat, 1, "{" + "\"property\":\"visible\", " + "\"value\":false, " + "\"path\":\"/Background\", " + "\"type\":\"set_property\" " + "}"); + meshcat.SetProperty("/Grid", "visible", false); + // Note: The order of the messages is due to "/Background" < "/Grid" in the + // std::map, not due to the order that SetProperty was called. + CheckCommand(meshcat, 1, "{" + "\"property\":\"visible\", " + "\"value\":false, " + "\"path\":\"/Background\", " + "\"type\":\"set_property\" " + "}"); + CheckCommand(meshcat, 2, "{" + "\"property\":\"visible\", " + "\"value\":false, " + "\"path\":\"/Grid\", " + "\"type\":\"set_property\" " + "}"); +} + +} // namespace +} // namespace geometry +} // namespace drake diff --git a/geometry/test/meshcat_websocket_client.py b/geometry/test/meshcat_websocket_client.py new file mode 100644 index 000000000000..e6c38cfce839 --- /dev/null +++ b/geometry/test/meshcat_websocket_client.py @@ -0,0 +1,41 @@ +# Usage: +# python3 meshcat_websocket_client.py ws_url message_number desired_command_json # noqa +# +# This test script connects to a websocket on localhost at `port`, listens for +# `message_number` received messages, unpacks the last message and compares it +# to the dictionary passed via `desired_command_json`. + +import asyncio +import json +import sys +import umsgpack +import websockets + + +async def CheckCommand(ws_url, message_number, desired_command): + async with websockets.connect(ws_url) as websocket: + message = '' + for n in range(message_number): + message = await asyncio.wait_for(websocket.recv(), timeout=10) + command = umsgpack.unpackb(message) + if command != desired_command: + print("FAILED") + print(f"Expected: {desired_command}") + print(f"Received: {command}") + exit(1) + +if len(sys.argv) != 4: + print(sys.argv) + print("Usage: python3 meshcat_websocket_client.py ws_url " + + "message_number desired_command_json") + exit(1) + +ws_url = sys.argv[1] +message_number = int(sys.argv[2]) +desired_command_json = sys.argv[3] +desired_command = json.loads(desired_command_json) + +asyncio.get_event_loop().run_until_complete( + CheckCommand(ws_url, message_number, desired_command)) + +exit(0) diff --git a/setup/mac/source_distribution/requirements-test-only.txt b/setup/mac/source_distribution/requirements-test-only.txt index 24aeb80de266..1aae4684c687 100644 --- a/setup/mac/source_distribution/requirements-test-only.txt +++ b/setup/mac/source_distribution/requirements-test-only.txt @@ -1,3 +1,4 @@ jupyter pandas six +websockets diff --git a/setup/ubuntu/source_distribution/packages-bionic-test-only.txt b/setup/ubuntu/source_distribution/packages-bionic-test-only.txt index 7506d0ebe291..0cf95995865c 100644 --- a/setup/ubuntu/source_distribution/packages-bionic-test-only.txt +++ b/setup/ubuntu/source_distribution/packages-bionic-test-only.txt @@ -15,6 +15,7 @@ python3-scipy-dbg python3-six python3-tk-dbg python3-uritemplate +python3-websockets python3-yaml-dbg python3-zmq-dbg valgrind diff --git a/setup/ubuntu/source_distribution/packages-focal-test-only.txt b/setup/ubuntu/source_distribution/packages-focal-test-only.txt index dc467c8034df..49af59897dda 100644 --- a/setup/ubuntu/source_distribution/packages-focal-test-only.txt +++ b/setup/ubuntu/source_distribution/packages-focal-test-only.txt @@ -16,6 +16,7 @@ python3-scipy-dbg python3-six python3-tk-dbg python3-uritemplate +python3-websockets python3-yaml-dbg python3-zmq-dbg valgrind diff --git a/tools/workspace/meshcat/README.md b/tools/workspace/meshcat/README.md index 7c31b0b1b4c8..afab19d7fadf 100644 --- a/tools/workspace/meshcat/README.md +++ b/tools/workspace/meshcat/README.md @@ -8,6 +8,10 @@ is also required. The local testing consists largely of informal prodding: run an application which exercises meshcat and confirm that is reasonably well behaved. +Required: + - drake/geometry:meshcat_demo + - See the documentation at the top of that file for the expected behavior. + Possible options: - drake/manipulation/util/show_model.py