diff --git a/doc/BUILD.bazel b/doc/BUILD.bazel index 0af371cfe324..07b43d6de436 100644 --- a/doc/BUILD.bazel +++ b/doc/BUILD.bazel @@ -54,6 +54,7 @@ filegroup( # https://developer.mozilla.org/en-US/docs/Web/Manifest "site.webmanifest", ], + visibility = ["//visibility:public"], ) filegroup( diff --git a/geometry/dev/BUILD.bazel b/geometry/dev/BUILD.bazel new file mode 100644 index 000000000000..317ac116b3cb --- /dev/null +++ b/geometry/dev/BUILD.bazel @@ -0,0 +1,36 @@ +# -*- 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/dev/meshcat.cc new file mode 100644 index 000000000000..8b1faf51273c --- /dev/null +++ b/geometry/dev/meshcat.cc @@ -0,0 +1,178 @@ +#include "drake/geometry/dev/meshcat.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "drake/common/find_resource.h" +#include "drake/common/never_destroyed.h" +#include "drake/common/unused.h" + +namespace { +std::string LoadFile(const std::string& filename) { + const std::string resource = drake::FindResourceOrThrow(filename); + std::ifstream file(resource.c_str(), std::ios::in); + if (!file.is_open()) + throw std::runtime_error("Error opening file: " + filename); + std::stringstream content; + content << file.rdbuf(); + file.close(); + return content.str(); +} +} // namespace + +namespace drake { +namespace geometry { + +namespace { + +constexpr static bool SSL = false; +struct PerSocketData { + // Intentionally left empty. +}; +using WebSocket = uWS::WebSocket; + +} // namespace + +class Meshcat::WebSocketPublisher { + public: + DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(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)); + } + + // Call this from main thread. + void GetAppFuture() { + std::tie(app_, loop_) = app_future.get(); + } + + template + void SetProperty(const std::string& path, const std::string& property, + const T& value) { + DRAKE_ASSERT(app_ != nullptr); + DRAKE_ASSERT(loop_ != nullptr); + + std::stringstream message; + + msgpack::zone z; + msgpack::pack(message, std::unordered_map( + {{"type", msgpack::object("set_property", z)}, + {"path", msgpack::object(path, z)}, + {"property", msgpack::object(property, z)}, + {"value", msgpack::object(value, z)}})); + + // Note: Must pass path and property by value because they will go out of + // scope. + loop_->defer([this, path, property, msg = message.str()]() { + app_->publish("all", msg, uWS::OpCode::BINARY, false); + set_properties_[path + "/" + property] = msg; + }); + } + + void SendTree(WebSocket* ws) { + // TODO(russt): Generalize this to publishing the entire scene tree. + for (const auto& [key, msg] : set_properties_) { + unused(key); + ws->send(msg); + } + } + + private: + std::promise> app_promise{}; + std::future> app_future{}; + + // Only loop_->defer() should be called from outside the websocket_thread. See + // the documentation for uWebSockets for further details: + // https://github.com/uNetworking/uWebSockets/blob/d94bf2cd43bed5e0de396a8412f156e15c141e98/misc/READMORE.md#threading + uWS::Loop* loop_{nullptr}; + + // The remaining variables should only be accessed from the websocket_thread. + uWS::App* app_{nullptr}; + std::unordered_map set_properties_{}; +}; + +Meshcat::Meshcat() { + // A std::promise is made in the WebSocketPublisher. + publisher_ = std::make_unique(); + websocket_thread_ = std::thread(&Meshcat::WebsocketMain, this); + // The std::promise is full-filled in WebsocketMain; we wait here to obtain + // that value. + publisher_->GetAppFuture(); +} + +Meshcat::~Meshcat() = default; + +void Meshcat::JoinWebSocketThread() { websocket_thread_.join(); } + +void Meshcat::SetProperty(const std::string& path, const std::string& property, + bool value) { + publisher_->SetProperty(path, property, value); +} + +void Meshcat::WebsocketMain() { + // Preload the three files we will serve (always). + static const drake::never_destroyed index_html( + LoadFile("drake/external/meshcat/dist/index.html")); + static const drake::never_destroyed main_min_js( + LoadFile("drake/external/meshcat/dist/main.min.js")); + static const drake::never_destroyed favicon_ico( + LoadFile("drake/doc/favicon.ico")); + int port = 7001; + const int kMaxPort = 7099; + + uWS::App::WebSocketBehavior behavior; + behavior.open = [this](WebSocket* ws) { + ws->subscribe("all"); + // Update this new connection with previously published data. + publisher_->SendTree(ws); + }; + + uWS::App app = + uWS::App() + .get("/*", + [&](uWS::HttpResponse* res, uWS::HttpRequest* req) { + if (req->getUrl() == "/main.min.js") { + res->end(main_min_js.access()); + } else if (req->getUrl() == "/favicon.ico") { + res->end(favicon_ico.access()); + } else { + res->end(index_html.access()); + } + }) + .ws("/*", std::move(behavior)); + + bool listening = false; + do { + app.listen( + port, LIBUS_LISTEN_EXCLUSIVE_PORT, + [port, &listening](us_listen_socket_t* listenSocket) { + if (listenSocket) { + std::cout + << "Meshcat listening for connections at http://127.0.0.1:" + << port << std::endl; + listening = true; + } + }); + } while (!listening && port++ <= kMaxPort); + + publisher_->SetAppPromise(&app, uWS::Loop::get()); + + app.run(); + + // run() should not terminate. If it does, then we've failed. + throw std::runtime_error("Meshcat websocket thread failed"); +} + +} // namespace geometry +} // namespace drake diff --git a/geometry/dev/meshcat.h b/geometry/dev/meshcat.h new file mode 100644 index 000000000000..3a3c8218e35e --- /dev/null +++ b/geometry/dev/meshcat.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include "drake/common/drake_copyable.h" + +namespace drake { +namespace geometry { + +/** Provides an interface to https://github.com/rdeits/meshcat. + +Each instance of this class spawns a thread which runs an http/websocket server. +Users can navigate their browser to the hosted URL to visualize the Meshcat +scene. Note that, unlike many visualizers, one cannot open the visualizer until +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. +*/ +class Meshcat { + public: + DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(Meshcat) + + /** Constructs the Meshcat instance. It will listen on the first available + port starting at 7001 (up to 7099). */ + Meshcat(); + + ~Meshcat(); + + /** 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. */ + void JoinWebSocketThread(); + + /** Forwards a set_property(...) message to the meshcat viewers. For example, + @verbatim + meshcat.SetProperty("/Background", "visible", false); + @endverbatim + will turn off the background. */ + void SetProperty(const std::string& path, const std::string& property, + bool value); + + private: + void WebsocketMain(); + std::thread websocket_thread_{}; + + // Provides PIMPL encapsulation of websocket types. + class WebSocketPublisher; + std::unique_ptr publisher_; +}; + +} // namespace geometry +} // namespace drake diff --git a/geometry/dev/meshcat_demo.cc b/geometry/dev/meshcat_demo.cc new file mode 100644 index 000000000000..fb624d9be60d --- /dev/null +++ b/geometry/dev/meshcat_demo.cc @@ -0,0 +1,32 @@ +#include "drake/geometry/dev/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 +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 +the grid that normally shows the ground plane is not visible. +*/ + +int main() { + drake::geometry::Meshcat meshcat; + + // Note: this will only send one message to any new server. + meshcat.SetProperty("/Background", "visible", false); + meshcat.SetProperty("/Background", "visible", true); + meshcat.SetProperty("/Background", "visible", false); + + // Demonstrate that we can construct multiple meshcats (and they will serve on + // different ports). + drake::geometry::Meshcat meshcat2; + meshcat2.SetProperty("/Grid", "visible", false); + + // Effectively sleep forever. + meshcat.JoinWebSocketThread(); + meshcat2.JoinWebSocketThread(); + + return 0; +} diff --git a/setup/mac/binary_distribution/Brewfile b/setup/mac/binary_distribution/Brewfile index 5906fb1f08f2..b696f9770f2e 100644 --- a/setup/mac/binary_distribution/Brewfile +++ b/setup/mac/binary_distribution/Brewfile @@ -21,6 +21,7 @@ brew 'libyaml' brew 'lz4' brew 'nlopt' brew 'numpy' +brew 'msgpack' brew 'openblas' brew 'pkg-config' brew 'python@3.9' diff --git a/setup/ubuntu/binary_distribution/packages-bionic.txt b/setup/ubuntu/binary_distribution/packages-bionic.txt index 04ff5c311b8f..e31201c00717 100644 --- a/setup/ubuntu/binary_distribution/packages-bionic.txt +++ b/setup/ubuntu/binary_distribution/packages-bionic.txt @@ -24,6 +24,7 @@ liblapack3 libldl2 liblz4-1 liblzma5 +libmsgpackc2 libmumps-seq-5.1.2 libnetcdf13 libnlopt0 diff --git a/setup/ubuntu/binary_distribution/packages-focal.txt b/setup/ubuntu/binary_distribution/packages-focal.txt index 62a714c58e11..d65d4671e917 100644 --- a/setup/ubuntu/binary_distribution/packages-focal.txt +++ b/setup/ubuntu/binary_distribution/packages-focal.txt @@ -23,6 +23,7 @@ liblapack3 libldl2 liblz4-1 liblzma5 +libmsgpackc2 libmumps-seq-5.2.1 libnetcdf15 libnlopt-cxx0 diff --git a/setup/ubuntu/source_distribution/packages-bionic.txt b/setup/ubuntu/source_distribution/packages-bionic.txt index d996669581cc..c911e7ca1283 100644 --- a/setup/ubuntu/source_distribution/packages-bionic.txt +++ b/setup/ubuntu/source_distribution/packages-bionic.txt @@ -21,6 +21,7 @@ libjsoncpp-dev liblapack-dev liblz4-dev liblzma-dev +libmsgpack-dev libmumps-seq-dev libnlopt-dev libpng-dev diff --git a/setup/ubuntu/source_distribution/packages-focal.txt b/setup/ubuntu/source_distribution/packages-focal.txt index 3923c81eb048..89d2f8a02050 100644 --- a/setup/ubuntu/source_distribution/packages-focal.txt +++ b/setup/ubuntu/source_distribution/packages-focal.txt @@ -22,6 +22,7 @@ libjsoncpp-dev liblapack-dev liblz4-dev liblzma-dev +libmsgpack-dev libmumps-seq-dev libnlopt-cxx-dev libopengl-dev diff --git a/tools/workspace/BUILD.bazel b/tools/workspace/BUILD.bazel index 3454c74e3f30..8254495342ab 100644 --- a/tools/workspace/BUILD.bazel +++ b/tools/workspace/BUILD.bazel @@ -59,6 +59,8 @@ _DRAKE_EXTERNAL_PACKAGE_INSTALLS = ["@%s//:install" % p for p in [ "sdformat", "spdlog", "tinyobjloader", + "usockets", + "uwebsockets", "vtk", ]] + ["//tools/workspace/%s:install" % p for p in [ "cds", diff --git a/tools/workspace/default.bzl b/tools/workspace/default.bzl index 12d4af60c58a..cf49fb979fd2 100644 --- a/tools/workspace/default.bzl +++ b/tools/workspace/default.bzl @@ -52,6 +52,7 @@ load("@drake//tools/workspace/meshcat:repository.bzl", "meshcat_repository") load("@drake//tools/workspace/meshcat_python:repository.bzl", "meshcat_python_repository") # noqa load("@drake//tools/workspace/models:repository.bzl", "models_repository") load("@drake//tools/workspace/mosek:repository.bzl", "mosek_repository") +load("@drake//tools/workspace/msgpack:repository.bzl", "msgpack_repository") load("@drake//tools/workspace/net_sf_jchart2d:repository.bzl", "net_sf_jchart2d_repository") # noqa load("@drake//tools/workspace/nlopt:repository.bzl", "nlopt_repository") load("@drake//tools/workspace/openblas:repository.bzl", "openblas_repository") @@ -79,6 +80,8 @@ load("@drake//tools/workspace/suitesparse:repository.bzl", "suitesparse_reposito load("@drake//tools/workspace/tinyobjloader:repository.bzl", "tinyobjloader_repository") # noqa load("@drake//tools/workspace/tinyxml2:repository.bzl", "tinyxml2_repository") load("@drake//tools/workspace/uritemplate_py:repository.bzl", "uritemplate_py_repository") # noqa +load("@drake//tools/workspace/usockets:repository.bzl", "usockets_repository") # noqa +load("@drake//tools/workspace/uwebsockets:repository.bzl", "uwebsockets_repository") # noqa load("@drake//tools/workspace/voxelized_geometry_tools:repository.bzl", "voxelized_geometry_tools_repository") # noqa load("@drake//tools/workspace/vtk:repository.bzl", "vtk_repository") load("@drake//tools/workspace/x11:repository.bzl", "x11_repository") @@ -198,6 +201,8 @@ def add_default_repositories(excludes = [], mirrors = DEFAULT_MIRRORS): models_repository(name = "models", mirrors = mirrors) if "mosek" not in excludes: mosek_repository(name = "mosek") + if "msgpack" not in excludes: + msgpack_repository(name = "msgpack") if "net_sf_jchart2d" not in excludes: net_sf_jchart2d_repository(name = "net_sf_jchart2d", mirrors = mirrors) if "nlopt" not in excludes: @@ -252,6 +257,10 @@ def add_default_repositories(excludes = [], mirrors = DEFAULT_MIRRORS): tinyxml2_repository(name = "tinyxml2") if "uritemplate_py" not in excludes: uritemplate_py_repository(name = "uritemplate_py", mirrors = mirrors) + if "usockets" not in excludes: + usockets_repository(name = "usockets", mirrors = mirrors) + if "uwebsockets" not in excludes: + uwebsockets_repository(name = "uwebsockets", mirrors = mirrors) if "voxelized_geometry_tools" not in excludes: voxelized_geometry_tools_repository(name = "voxelized_geometry_tools", mirrors = mirrors) # noqa if "vtk" not in excludes: diff --git a/tools/workspace/meshcat/package.BUILD.bazel b/tools/workspace/meshcat/package.BUILD.bazel index e720f39b5098..aa529fb2f8b0 100644 --- a/tools/workspace/meshcat/package.BUILD.bazel +++ b/tools/workspace/meshcat/package.BUILD.bazel @@ -11,7 +11,7 @@ VIEWER_FILES = [ exports_files( VIEWER_FILES, - visibility = ["@meshcat_python//:__pkg__"], + visibility = ["//visibility:public"], ) install_files( diff --git a/tools/workspace/msgpack/BUILD.bazel b/tools/workspace/msgpack/BUILD.bazel new file mode 100644 index 000000000000..2e5301dc6578 --- /dev/null +++ b/tools/workspace/msgpack/BUILD.bazel @@ -0,0 +1,8 @@ +# -*- python -*- + +# This file exists to make our directory into a Bazel package, so that our +# neighboring *.bzl file can be loaded elsewhere. + +load("//tools/lint:lint.bzl", "add_lint_tests") + +add_lint_tests() diff --git a/tools/workspace/msgpack/repository.bzl b/tools/workspace/msgpack/repository.bzl new file mode 100644 index 000000000000..0ceaafba25cf --- /dev/null +++ b/tools/workspace/msgpack/repository.bzl @@ -0,0 +1,20 @@ +# -*- mode: python -*- + +load( + "@drake//tools/workspace:pkg_config.bzl", + "pkg_config_repository", +) + +def msgpack_repository( + name, + licenses = ["notice"], # Boost-1.0 + modname = "msgpack", + pkg_config_paths = ["/usr/local/opt/msgpack/lib/pkgconfig"], + **kwargs): + pkg_config_repository( + name = name, + licenses = licenses, + modname = modname, + pkg_config_paths = pkg_config_paths, + **kwargs + ) diff --git a/tools/workspace/usockets/BUILD.bazel b/tools/workspace/usockets/BUILD.bazel new file mode 100644 index 000000000000..7198e3bb9b53 --- /dev/null +++ b/tools/workspace/usockets/BUILD.bazel @@ -0,0 +1,5 @@ +# -*- python -*- + +load("//tools/lint:lint.bzl", "add_lint_tests") + +add_lint_tests() diff --git a/tools/workspace/usockets/package.BUILD.bazel b/tools/workspace/usockets/package.BUILD.bazel new file mode 100644 index 000000000000..c81c23f01a05 --- /dev/null +++ b/tools/workspace/usockets/package.BUILD.bazel @@ -0,0 +1,30 @@ +# -*- python -*- + +load( + "@drake//tools/install:install.bzl", + "install", +) + +licenses(["notice"]) # Apache 2.0 + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "usockets", + hdrs = ["src/libusockets.h"], + srcs = glob([ + "src/*.c", + "src/eventing/*.c", + "src/crypto/*.c", + "src/**/*.h", + ]), + includes = ["src"], + copts = ["-DLIBUS_NO_SSL"], + linkstatic = 1, +) + +# Install the license file. +install( + name = "install", + docs = ["LICENSE"], +) diff --git a/tools/workspace/usockets/repository.bzl b/tools/workspace/usockets/repository.bzl new file mode 100644 index 000000000000..294d8bf7511b --- /dev/null +++ b/tools/workspace/usockets/repository.bzl @@ -0,0 +1,15 @@ +# -*- python -*- + +load("@drake//tools/workspace:github.bzl", "github_archive") + +def usockets_repository( + name, + mirrors = None): + github_archive( + name = name, + repository = "uNetworking/uSockets", + commit = "v0.7.1", + sha256 = "1fdc5376e5ef9acf4fb673fcd5fd191da9b8d59a319e9ec7922872070a3dd21c", # noqa + build_file = "@drake//tools/workspace/usockets:package.BUILD.bazel", + mirrors = mirrors, + ) diff --git a/tools/workspace/uwebsockets/BUILD.bazel b/tools/workspace/uwebsockets/BUILD.bazel new file mode 100644 index 000000000000..7198e3bb9b53 --- /dev/null +++ b/tools/workspace/uwebsockets/BUILD.bazel @@ -0,0 +1,5 @@ +# -*- python -*- + +load("//tools/lint:lint.bzl", "add_lint_tests") + +add_lint_tests() diff --git a/tools/workspace/uwebsockets/package.BUILD.bazel b/tools/workspace/uwebsockets/package.BUILD.bazel new file mode 100644 index 000000000000..a3d523c5ca43 --- /dev/null +++ b/tools/workspace/uwebsockets/package.BUILD.bazel @@ -0,0 +1,28 @@ +# -*- python -*- + +load( + "@drake//tools/install:install.bzl", + "install", +) + +licenses(["notice"]) # Apache 2.0 + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "uwebsockets", + hdrs = glob(["src/*.h"]), + includes = ["src"], + deps = [ + "@usockets", + "@zlib", + ], + linkopts = ["-pthread"], + linkstatic = 1, +) + +# Install the license file. +install( + name = "install", + docs = ["LICENSE"], +) diff --git a/tools/workspace/uwebsockets/repository.bzl b/tools/workspace/uwebsockets/repository.bzl new file mode 100644 index 000000000000..8aac12200bb5 --- /dev/null +++ b/tools/workspace/uwebsockets/repository.bzl @@ -0,0 +1,15 @@ +# -*- python -*- + +load("@drake//tools/workspace:github.bzl", "github_archive") + +def uwebsockets_repository( + name, + mirrors = None): + github_archive( + name = name, + repository = "uNetworking/uWebSockets", + commit = "v19.3.0", + sha256 = "6f709b4e5fe053a94a952da93c07c919b36bcb8c838c69067560ae85f97c5621", # noqa + build_file = "@drake//tools/workspace/uwebsockets:package.BUILD.bazel", + mirrors = mirrors, + )