diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..df2db85 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,3 @@ +ignore: + - "src/meshcat/transformations.py" + - "setup.py" diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..3c6c970 --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-cov + git submodule update --init --recursive + pip install -e . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest --cov=./ + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 3b667ec..5c7e3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,11 @@ __pycache__ *.pyc venv *.egg-info +*.eggs/ .ipynb_checkpoints build dist +venv* +.pytest_cache +.vscode diff --git a/.gitmodules b/.gitmodules index d3ca1a3..a17a66b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "meshcat/viewer"] path = src/meshcat/viewer - url = git@github.com:rdeits/meshcat.git + url = https://github.com/rdeits/meshcat.git diff --git a/MANIFEST.in b/MANIFEST.in index 636d91d..2e9f6b0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ graft src/meshcat/viewer +prune src/meshcat/viewer/node_modules graft src/meshcat/examples +graft src/meshcat/tests/data diff --git a/Readme.rst b/Readme.rst index 4dc36b7..1e75c46 100644 --- a/Readme.rst +++ b/Readme.rst @@ -1,9 +1,226 @@ -Introduction -============ +meshcat-python: Python Bindings to the MeshCat WebGL viewer +=========================================================== -MeshCat_ is a remotely-controllable 3D viewer, built on top of three.js_. The MeshCat viewer runs in a browser and listens for geometry commands over WebSockets. This makes it easy to create a tree of objects and transformations by sending the appropriate commands over the websocket. +.. image:: https://github.com/meshcat-dev/meshcat-python/workflows/CI/badge.svg?branch=master + :target: https://github.com/meshcat-dev/meshcat-python/actions?query=workflow%3ACI +.. image:: https://codecov.io/gh/meshcat-dev/meshcat-python/branch/master/graph/badge.svg + :target: https://codecov.io/gh/meshcat-dev/meshcat-python -.. _MeshCat: https://github.com/rdeits/meshcat + +MeshCat_ is a remotely-controllable 3D viewer, built on top of three.js_. The viewer contains a tree of objects and transformations (i.e. a scene graph) and allows those objects and transformations to be added and manipulated with simple commands. This makes it easy to create 3D visualizations of geometries, mechanisms, and robots. + +The MeshCat architecture is based on the model used by Jupyter_: + +- The viewer itself runs entirely in the browser, with no external dependencies +- The MeshCat server communicates with the viewer via WebSockets +- Your code can use the meshcat python libraries or communicate directly with the server through its ZeroMQ_ socket. + +.. _ZeroMQ: http://zguide.zeromq.org/ +.. _Jupyter: http://jupyter.org/ +.. _MeshCat: https://github.com/meshcat-dev/meshcat .. _three.js: https://threejs.org/ -This package, meshcat-python, allows you to create objects and move them in space from Python. For some examples of usage, see `demo.ipynb`. +Installation +------------ + +The latest version of MeshCat requires Python 3.6 or above. + +Using pip: + +:: + + pip install meshcat + +From source: + +:: + + git clone https://github.com/meshcat-dev/meshcat-python + git submodule update --init --recursive + cd meshcat-python + python setup.py install + +You will need the ZeroMQ libraries installed on your system: + +Ubuntu/Debian: + +:: + + apt install libzmq3-dev + +Homebrew: + +:: + + brew install zmq + +Windows: + +Download the official installer from zeromq.org_. + +.. _zeromq.org: https://zeromq.org/download/ + +Usage +===== + +For examples of interactive usage, see demo.ipynb_ + +.. _demo.ipynb: examples/demo.ipynb + +Under the Hood +============== + +Starting a Server +----------------- + +If you want to run your own meshcat server (for example, to communicate with the viewer over ZeroMQ from another language), all you need to do is run: + +:: + + meshcat-server + +The server will choose an available ZeroMQ URL and print that URL over stdout. If you want to specify a URL, just do: + +:: + + meshcat-server --zmq-url= + +You can also instruct the server to open a browser window with: + +:: + + meshcat-server --open + +Protocol +-------- + +All communication with the meshcat server happens over the ZMQ socket. Some commands consist of multiple ZMQ frames. + +:ZMQ frames: + ``["url"]`` +:Action: + Request URL +:Response: + The web URL for the server. Open this URL in your browser to see the 3D scene. + +| + +:ZMQ frames: + ``["wait"]`` +:Action: + Wait for a browser to connect +:Response: + "ok" when a brower has connected to the server. This is useful in scripts to block execution until geometry can actually be displayed. + +| + +:ZMQ frames: + ``["set_object", "/slash/separated/path", data]`` +:Action: + Set the object at the given path. ``data`` is a ``MsgPack``-encoded dictionary, described below. +:Response: + "ok" + +| + +:ZMQ frames: + ``["set_transform", "/slash/separated/path", data]`` +:Action: + Set the transform of the object at the given path. There does not need to be any geometry at that path yet, so ``set_transform`` and ``set_object`` can happen in any order. ``data`` is a ``MsgPack``-encoded dictionary, described below. +:Response: + "ok" + +| + +:ZMQ frames: + ``["delete", "/slash/separated/path", data]`` +:Action: + Delete the object at the given path. ``data`` is a ``MsgPack``-encoded dictionary, described below. +:Response: + "ok" + +| + +``set_object`` data format +^^^^^^^^^^^^^^^^^^^^^^^^^^ +:: + + { + "type": "set_object", + "path": "/slash/separated/path", // the path of the object + "object": + } + +The format of the ``object`` field is exactly the built-in JSON serialization format from three.js (note that we use the JSON structure, but actually use msgpack for the encoding due to its much better performance). For examples of the JSON structure, see the three.js wiki_ . + +Note on redundancy + The ``type`` and ``path`` fields are duplicated: they are sent once in the first two ZeroMQ frames and once inside the MsgPack-encoded data. This is intentional and makes it easier for the server to handle messages without unpacking them fully. + +.. _wiki: https://github.com/mrdoob/three.js/wiki/JSON-Geometry-format-4 +.. _msgpack: https://msgpack.org/index.html + +``set_transform`` data format +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:: + + { + "type": "set_transform", + "path": "/slash/separated/path", + "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + } + +The format of the ``matrix`` in a ``set_transform`` command is a column-major homogeneous transformation matrix. + +``delete`` data format +^^^^^^^^^^^^^^^^^^^^^^ +:: + + { + "type": "delete", + "path", "/slash/separated/path" + } + +Examples +-------- + +Creating a box at path ``/meshcat/box`` + +:: + + { + "type": "set_object", + "path": "/meshcat/box", + "object": { + "metadata": {"type": "Object", "version": 4.5}, + "geometries": [{"depth": 0.5, + "height": 0.5, + "type": "BoxGeometry", + "uuid": "fbafc3d6-18f8-11e8-b16e-f8b156fe4628", + "width": 0.5}], + "materials": [{"color": 16777215, + "reflectivity": 0.5, + "type": "MeshPhongMaterial", + "uuid": "e3c21698-18f8-11e8-b16e-f8b156fe4628"}], + "object": {"geometry": "fbafc3d6-18f8-11e8-b16e-f8b156fe4628", + "material": "e3c21698-18f8-11e8-b16e-f8b156fe4628", + "matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0], + "type": "Mesh", + "uuid": "fbafc3d7-18f8-11e8-b16e-f8b156fe4628"}}, + } + +Translating that box by the vector ``[2, 3, 4]``: + +:: + + { + "type": "set_transform", + "path": "/meshcat/box", + "matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 2.0, 3.0, 4.0, 1.0] + } + +Packing Arrays +-------------- + +Msgpack's default behavior is not ideal for packing large contiguous arrays (it inserts a type code before every element). For faster transfer of large pointclouds and meshes, msgpack ``Ext`` codes are available for several types of arrays. For the full list, see https://github.com/kawanet/msgpack-lite#extension-types . The ``meshcat`` Python bindings will automatically use these ``Ext`` types for ``numpy`` array inputs. + + diff --git a/demo.ipynb b/demo.ipynb deleted file mode 100644 index 9b18251..0000000 --- a/demo.ipynb +++ /dev/null @@ -1,202 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import os\n", - "import time" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import meshcat\n", - "import meshcat.visualizer as v\n", - "import meshcat.geometry as g\n", - "import meshcat.transformations as tf" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis = v.Visualizer()\n", - "\n", - "vis.jupyter_cell()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"boxes/box1\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"box\"].set_object(g.Box([0.2, 0.1, 0.1]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for theta in np.linspace(0, 2 * np.pi, 200):\n", - " vis[\"box\"].set_transform(tf.rotation_matrix(theta, [0, 0, 1]))\n", - " time.sleep(0.005)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"box\"].delete()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"sphere\"].set_object(g.Mesh(g.Sphere(1.0), g.MeshLambertMaterial(color=0xff22dd)))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"sphere\"].set_transform(tf.translation_matrix([1, 0, 0]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"sphere\"].delete()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vis[\"robots/valkyrie/head\"].set_object(g.Mesh(\n", - " g.ObjMeshGeometry.from_file(os.path.join(meshcat.viewer_assets_path(), \"data/head_multisense.obj\")),\n", - " g.MeshLambertMaterial(\n", - " map=g.ImageTexture(\n", - " image=g.PngImage.from_file(os.path.join(meshcat.viewer_assets_path(), \"data/HeadTextureMultisense.png\"))\n", - " )\n", - " )\n", - "))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "verts = np.random.rand(3, 100000) + np.array([[0.5, 0.5, 0.5]]).T\n", - "vis[\"perception/pointclouds/random\"].set_object(g.PointCloud(verts, verts))\n", - "vis[\"perception/pointclouds/random\"].set_transform(tf.translation_matrix([0, 3, 1]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cart_pole = vis[\"cart_pole\"]\n", - "cart_pole.delete()\n", - "cart = cart_pole[\"cart\"]\n", - "pivot = cart[\"pivot\"]\n", - "pole = pivot[\"pole\"]\n", - "cart.set_object(g.Box([0.5, 0.3, 0.2]))\n", - "pole.set_object(g.Box([1, 0.05, 0.05]))\n", - "pole.set_transform(tf.translation_matrix([0.5, 0, 0]))\n", - "pivot.set_transform(tf.rotation_matrix(-np.pi/2, [0, 1, 0]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cart.set_transform(tf.translation_matrix([0.5, 0, 0]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pivot.set_transform(tf.rotation_matrix(-np.pi/4, [0, 1, 0]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.5.2" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/examples/animation_demo.ipynb b/examples/animation_demo.ipynb new file mode 100644 index 0000000..186ebfb --- /dev/null +++ b/examples/animation_demo.ipynb @@ -0,0 +1,268 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# MeshCat Animations\n", + "\n", + "MeshCat.jl also provides an animation interface, built on top of the [three.js animation system](https://threejs.org/docs/#manual/introduction/Animation-system). While it is possible to construct animation clips and tracks manually, just as you would in Three.js, it's generally easier to use the MeshCat `Animation` type.\n", + "\n", + "Let's show off building a simple animation. We first have to create our scene: " + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import meshcat\n", + "from meshcat.geometry import Box" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "You can open the visualizer by visiting the following URL:\n", + "http://127.0.0.1:7000/static/\n" + ] + } + ], + "source": [ + "vis = meshcat.Visualizer()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "## To open the visualizer in a new browser tab, do: \n", + "# vis.open()\n", + "\n", + "## To open the visualizer inside this jupyter notebook, do: \n", + "# vis.jupyter_cell()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"box1\"].set_object(Box([0.1, 0.2, 0.3]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Building an Animation\n", + "\n", + "We construct an animation by first creating a blank `Animation()` object. We can then use the `at_frame` method to set properties or transforms of the animation at specific frames of the animation. Three.js will automatically interpolate between whatever values we provide. \n", + "\n", + "For example, let's animate moving the box from [0, 0, 0] to [0, 1, 0]: " + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from meshcat.animation import Animation\n", + "import meshcat.transformations as tf" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "anim = Animation()\n", + "\n", + "with anim.at_frame(vis, 0) as frame:\n", + " # `frame` behaves like a Visualizer, in that we can\n", + " # call `set_transform` and `set_property` on it, but\n", + " # it just stores information inside the animation\n", + " # rather than changing the current visualization\n", + " frame[\"box1\"].set_transform(tf.translation_matrix([0, 0, 0]))\n", + "with anim.at_frame(vis, 30) as frame:\n", + " frame[\"box1\"].set_transform(tf.translation_matrix([0, 1, 0]))\n", + " \n", + "# `set_animation` actually sends the animation to the\n", + "# viewer. By default, the viewer will play the animation\n", + "# right away. To avoid that, you can also pass `play=false`. \n", + "vis.set_animation(anim)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You should see the box slide 1 meter to the right in the viewer. If you missed the animation, you can run it again from the viewer. Click \"Open Controls\", find the \"Animations\" section, and click \"play\". " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Animating the Camera\n", + "\n", + "The camera is just another object in the MeshCat scene. To set its transform, we just need to index into the visualizer with the right path (note the leading `/`):" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"/Cameras/default\"].set_transform(tf.translation_matrix([0, 0, 1]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To animate the camera, we just have to do that same kind of `settransform!` to individual frames in an animation: " + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "anim = Animation()\n", + "\n", + "with anim.at_frame(vis, 0) as frame:\n", + " frame[\"/Cameras/default\"].set_transform(tf.translation_matrix([0, 0, 0]))\n", + "with anim.at_frame(vis, 30) as frame:\n", + " frame[\"/Cameras/default\"].set_transform(tf.translation_matrix([0, 0, 1]))\n", + "\n", + "# we can repeat the animation playback with the \n", + "# repetitions argument:\n", + "vis.set_animation(anim, repetitions=2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also animate object properties. For example, let's animate the camera's `zoom` property to smoothly zoom out and then back in. Note that to do this, we have to access a deeper path in the visualizer to get to the actual camera object. For more information, see: https://github.com/rdeits/meshcat#camera-control" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "anim = Animation()\n", + "\n", + "camera_path = \"/Cameras/default/rotated/\"\n", + "\n", + "with anim.at_frame(vis, 0) as frame:\n", + " frame[camera_path].set_property(\"zoom\", \"number\", 1)\n", + "with anim.at_frame(vis, 30) as frame:\n", + " frame[camera_path].set_property(\"zoom\", \"number\", 0.5)\n", + "with anim.at_frame(vis, 60) as frame:\n", + " frame[camera_path].set_property(\"zoom\", \"number\", 1)\n", + " \n", + "# While we're animating the camera zoom, we can also animate any other\n", + "# properties we want. Let's simultaneously translate the box during \n", + "# the same animation:\n", + "with anim.at_frame(vis, 0) as frame:\n", + " frame[\"box1\"].set_transform(tf.translation_matrix([0, -1, 0]))\n", + "with anim.at_frame(vis, 60) as frame:\n", + " frame[\"box1\"].set_transform(tf.translation_matrix([0, 1, 0]))\n", + "\n", + "vis.set_animation(anim)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Recording an Animation\n", + "\n", + "To record an animation at a smooth, fixed frame rate, click on \"Open Controls\" in the viewer, and then go to \"Animations\" -> \"default\" -> \"Recording\" -> \"record\". This will play the entire animation, recording every frame and then let you download the resulting frames to your computer. \n", + "\n", + "To record activity in the MeshCat window that isn't a MeshCat animation, we suggest using a screen-capture tool like Quicktime for macOS or RecordMyDesktop for Linux. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Converting the Animation into a Video\n", + "\n", + "Currently, meshcat can only save an animation as a `.tar` file consisting of a list of `.png` images, one for each frame. To convert that into a video, you will need to install the `ffmpeg` program, and then you can run: " + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from meshcat.animation import convert_frames_to_video" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saved output as /home/rdeits/locomotion/explorations/meshcat-distro/meshcat-python/output.mp4\n" + ] + } + ], + "source": [ + "convert_frames_to_video(\"/home/rdeits/Downloads/meshcat_1528401494656.tar\", overwrite=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "meshcat-python", + "language": "python", + "name": "meshcat-python" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.5.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/meshcat/examples/box.py b/examples/box.py similarity index 89% rename from src/meshcat/examples/box.py rename to examples/box.py index 921cdf9..da5118e 100644 --- a/src/meshcat/examples/box.py +++ b/examples/box.py @@ -12,6 +12,8 @@ draw_times = [] +vis["/Background"].set_property("top_color", [1, 0, 0]) + for i in range(200): theta = (i + 1) / 100 * 2 * math.pi now = time.time() diff --git a/examples/demo.ipynb b/examples/demo.ipynb new file mode 100644 index 0000000..17b7469 --- /dev/null +++ b/examples/demo.ipynb @@ -0,0 +1,528 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# MeshCat Python" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import os\n", + "import time\n", + "\n", + "import meshcat\n", + "import meshcat.geometry as g\n", + "import meshcat.transformations as tf" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a new visualizer\n", + "vis = meshcat.Visualizer()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, creating the `Visualizer` will start up a meshcat server for you in the background. The easiest way to open the visualizer is with its ``open`` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.open()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If ``vis.open()`` does not work for you, you can also point your browser to the server's URL:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.url()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To create a 3D object, we use the `set_object` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.set_object(g.Box([0.2, 0.2, 0.2]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And to move that object around, we use `set_transform`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for theta in np.linspace(0, 2 * np.pi, 200):\n", + " vis.set_transform(tf.rotation_matrix(theta, [0, 0, 1]))\n", + " time.sleep(0.005)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "MeshCat also supports embedding a 3D view inside a Jupyter notebook cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.jupyter_cell()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how the 3D scene displayed in the Jupyter cell matches the one in the external window. The meshcat server process remembers the objects and transforms you've sent, so opening a new browser pointing to the same URL should give you the same scene. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Calling `set_object` again will replace the existing Box:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.set_object(g.Box([0.1, 0.1, 0.2]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also delete the box:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.delete()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "MeshCat supports simple 2d texts rendering. For example, to write 2d texts onto a geometry:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.set_object(g.Box([1, 1, 2]),g.MeshPhongMaterial(map=g.TextTexture('Hello, world!')))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It is also possible to simple write 'floating' texts onto a scene without attaching it to an object (e.g., for scene description):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.delete()\n", + "vis.set_object(g.SceneText('Hello, world!',font_size=100))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "and just like the usual geometry/object, the scene texts can be rotated:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Rz = tf.rotation_matrix(np.pi/2, [0, 0, 1])\n", + "Ry = tf.rotation_matrix(np.pi/2, [0, 1, 0])\n", + "vis.set_transform(Ry.dot(Rz))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Under the hood, the `SceneTexts` are written onto a `Plane` geometry, and the plane size can be specified by width and height. These two parameters affect the texts size when the font_size itself is set too large; they would force a font downsizing when rendering so as to fit all the texts within the specified plane." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for i in np.linspace(8,2,10):\n", + " vis.set_object(g.SceneText('Hello, world!',width=2*i,height=2*i,font_size=300))\n", + " time.sleep(0.05)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The Scene Tree\n", + "\n", + "Obviously, we will often want to draw more than one object. So how do we do that? The fundamental idea of MeshCat is that it gives direct access to the *scene graph*. You can think of the scene as a tree of objects, and we name each object in the tree by its *path* from the root of the tree. Children in the tree inherit the transformations applied to their parents. So, for example, we might have a `robot` at the path `/robot`, and that robot might have a child called `head` at the path `/robot/head`. Each path in the tree can have a different geometry associated.\n", + "\n", + "First, let's create the robot. We access paths in the tree by indexing into the Visualizer:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"robot\"].set_object(g.Box([0.15, 0.35, 0.4]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's give the robot a head:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"robot\"][\"head\"].set_object(g.Box([0.2, 0.2, 0.2]))\n", + "vis[\"robot\"][\"head\"].set_transform(tf.translation_matrix([0, 0, 0.32]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can move the entire robot by setting the transform of the `/robot` path:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for x in np.linspace(0, np.pi, 100):\n", + " vis[\"robot\"].set_transform(tf.translation_matrix([np.sin(x), 0, 0]))\n", + " time.sleep(0.01)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And we can move just the head by setting the transform of `/robot/head`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for x in np.linspace(0, 2 * np.pi, 100):\n", + " # vis[\"robot/head\"] is a shorthand for vis[\"robot\"][\"head\"]\n", + " vis[\"robot/head\"].set_transform(\n", + " tf.translation_matrix([0, 0, 0.32]).dot(\n", + " tf.rotation_matrix(x, [0, 0, 1])))\n", + " time.sleep(0.01)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can delete the head..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"robot/head\"].delete()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "...or the entire robot:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"robot\"].delete()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Other Geometries\n", + "\n", + "MeshCat supports several geometric primitives as well as meshes (represented by `.obj`, `.dae`, or `.stl` files). You can also specify a material to describe the object's color, reflectivity, or texture:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"sphere\"].set_object(g.Sphere(0.1), \n", + " g.MeshLambertMaterial(\n", + " color=0xff22dd,\n", + " reflectivity=0.8))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"sphere\"].delete()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "MeshCat can load `.obj`, `.dae`, and `.stl` meshes via the `ObjMeshGeometry`, `DaeMeshGeometry`, and `StlMeshGeometry` types respectively:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"robots/valkyrie/head\"].set_object(\n", + " g.ObjMeshGeometry.from_file(\n", + " os.path.join(meshcat.viewer_assets_path(), \"data/head_multisense.obj\")),\n", + " g.MeshLambertMaterial(\n", + " map=g.ImageTexture(\n", + " image=g.PngImage.from_file(\n", + " os.path.join(meshcat.viewer_assets_path(), \"data/HeadTextureMultisense.png\"))\n", + " )\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `PointCloud()` function is a helper to create a `Points` object with a `PointsGeometry` and `PointsMaterial`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "verts = np.random.rand(3, 100000)\n", + "vis[\"perception/pointclouds/random\"].set_object(\n", + " g.PointCloud(position=verts, color=verts))\n", + "vis[\"perception/pointclouds/random\"].set_transform(\n", + " tf.translation_matrix([0, 1, 0]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis[\"robots\"].delete()\n", + "vis[\"perception\"].delete()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## Cart-Pole\n", + "\n", + "Here's a simple example of visualizing a mechanism:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cart_pole = vis[\"cart_pole\"]\n", + "cart_pole.delete()\n", + "cart = cart_pole[\"cart\"]\n", + "pivot = cart[\"pivot\"]\n", + "pole = pivot[\"pole\"]\n", + "cart.set_object(g.Box([0.5, 0.3, 0.2]))\n", + "pole.set_object(g.Box([1, 0.05, 0.05]))\n", + "pole.set_transform(tf.translation_matrix([0.5, 0, 0]))\n", + "pivot.set_transform(tf.rotation_matrix(-np.pi/2, [0, 1, 0]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for x in np.linspace(-np.pi, np.pi, 200):\n", + " cart.set_transform(tf.translation_matrix([np.sin(x), 0, 0]))\n", + " pivot.set_transform(tf.rotation_matrix(x / 4 - np.pi / 2, [0, 1, 0]))\n", + " time.sleep(0.01)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's set the camera position to above left of the cartpole:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.jupyter_cell()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.set_cam_pos([0.6, -1.0, 1.0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's change the focus to the leftmost 'l' character:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vis.set_cam_target([0.0, -1.0, 0.0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/lines.ipynb b/examples/lines.ipynb new file mode 100644 index 0000000..14b8e7e --- /dev/null +++ b/examples/lines.ipynb @@ -0,0 +1,177 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "You can open the visualizer by visiting the following URL:\n", + "http://127.0.0.1:7004/static/\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + "
\n", + "\n", + "
\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import meshcat\n", + "import meshcat.geometry as g\n", + "import numpy as np\n", + "import time\n", + "\n", + "vis = meshcat.Visualizer()\n", + "vis.jupyter_cell()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create some random vertices and render them as individual, nonconnected, [LineSegments](https://threejs.org/docs/index.html#api/en/objects/LineSegments)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "vis.delete()\n", + "vertices = np.random.random((3, 10)).astype(np.float32)\n", + "vis['lines_segments'].set_object(g.LineSegments(g.PointsGeometry(vertices)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Render as a single connected [Line](https://threejs.org/docs/index.html#api/en/objects/Line)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "line_vertices = np.array(vertices)\n", + "line_vertices[1, :] += 1\n", + "vis['line'].set_object(g.Line(g.PointsGeometry(line_vertices)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Render as a [LineLoop](https://threejs.org/docs/index.html#api/en/objects/LineLoop)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "line_loop = np.array(vertices)\n", + "line_loop[1, :] += 2\n", + "vis['line_loop'].set_object(g.LineLoop(g.PointsGeometry(line_loop)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Line can have mesh materials." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "vis.delete()\n", + "vertices = np.random.random((3, 10)).astype(np.float32)\n", + "vis['basic'].set_object(g.Line(g.PointsGeometry(vertices), g.MeshBasicMaterial(color=0xff0000)))" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "vphong = np.array(vertices)\n", + "vphong[1, :] += 1\n", + "vis['phong'].set_object(g.Line(g.PointsGeometry(vphong), g.MeshPhongMaterial(color=0xff0000)))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "vlamb = np.array(vertices)\n", + "vlamb[1, :] += 2\n", + "vis['lambert'].set_object(g.Line(g.PointsGeometry(vlamb), g.MeshLambertMaterial(color=0xff0000)))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "vtoon = np.array(vertices)\n", + "vtoon[1, :] += 3\n", + "vis['toon'].set_object(g.Line(g.PointsGeometry(vtoon), g.MeshToonMaterial(color=0xff0000)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Threejs also exposes attributes such as `color`, `linewidth`, `linecap` and `linejoin` using [LineBasicMaterial]( https://threejs.org/docs/#api/en/materials/LineBasicMaterial) and [LineDashMaterial]( https://threejs.org/docs/#api/en/materials/LineDashMaterial) materials. These have not been added." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/meshcat/examples/points.py b/examples/points.py similarity index 79% rename from src/meshcat/examples/points.py rename to examples/points.py index a2552c7..3d419ab 100644 --- a/src/meshcat/examples/points.py +++ b/examples/points.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function + import time import numpy as np diff --git a/publishing_notes.md b/publishing_notes.md new file mode 100644 index 0000000..1f9e21c --- /dev/null +++ b/publishing_notes.md @@ -0,0 +1,14 @@ +1. Update version and URL in setup.py +2. Create a new tag: + + git tag -a "v1.2.3" + git push + git push --tags + +3. Upload + + python3 -m pip install --upgrade setuptools wheel + rm dist/* + python3 setup.py sdist bdist_wheel + python3 -m pip install --upgrade twine + twine upload dist/* diff --git a/setup.py b/setup.py index f023d81..fd3fedf 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,30 @@ +import sys from setuptools import setup, find_packages setup(name="meshcat", - version="0.0.4", + version="0.3.2", description="WebGL-based visualizer for 3D geometries and scenes", url="https://github.com/rdeits/meshcat-python", - download_url="https://github.com/rdeits/meshcat-python/archive/v0.0.4.tar.gz", + download_url="https://github.com/rdeits/meshcat-python/archive/v0.3.2.tar.gz", author="Robin Deits", author_email="mail@robindeits.com", license="MIT", packages=find_packages("src"), package_dir={"": "src"}, test_suite="meshcat", + entry_points={ + "console_scripts": [ + "meshcat-server=meshcat.servers.zmqserver:main" + ] + }, install_requires=[ + "ipython >= 5", "u-msgpack-python >= 2.4.1", "numpy >= 1.14.0", - "tornado >= 4.0.0" + "tornado >= 4.0.0", + "pyzmq >= 17.0.0", + "pyngrok >= 4.1.6", + "pillow >= 7.0.0" ], zip_safe=False, include_package_data=True diff --git a/src/meshcat/__init__.py b/src/meshcat/__init__.py index fe8b50c..04d0c46 100644 --- a/src/meshcat/__init__.py +++ b/src/meshcat/__init__.py @@ -5,6 +5,7 @@ from . import visualizer from . import servers from . import transformations +from . import animation from .visualizer import Visualizer @@ -12,7 +13,6 @@ def viewer_assets_path(): return os.path.abspath( os.path.join( os.path.dirname(__file__), - "viewer", - "static" + "viewer" ) ) diff --git a/src/meshcat/animation.py b/src/meshcat/animation.py new file mode 100644 index 0000000..c09959d --- /dev/null +++ b/src/meshcat/animation.py @@ -0,0 +1,183 @@ +import tempfile +import tarfile +import os.path +import subprocess + +import bisect +from . import transformations as tf + + +class AnimationTrack(object): + __slots__ = ["name", "jstype", "frames", "values"] + + def __init__(self, name, jstype, frames=None, values=None): + self.name = name + self.jstype = jstype + if frames is None: + self.frames = [] + else: + self.frames = frames + if values is None: + self.values = [] + else: + self.values = values + + def set_property(self, frame, value): + i = bisect.bisect(self.frames, frame) + self.frames.insert(i, frame) + self.values.insert(i, value) + + def lower(self): + return { + u"name": str("." + self.name), + u"type": str(self.jstype), + u"keys": [{ + u"time": self.frames[i], + u"value": self.values[i] + } for i in range(len(self.frames))] + } + + +class AnimationClip(object): + __slots__ = ["tracks", "fps", "name"] + + def __init__(self, tracks=None, fps=30, name=u"default"): + if tracks is None: + self.tracks = {} + else: + self.tracks = tracks + self.fps = fps + self.name = name + + def set_property(self, frame, property, jstype, value): + if property not in self.tracks: + self.tracks[property] = AnimationTrack(property, jstype) + track = self.tracks[property] + track.set_property(frame, value) + + def lower(self): + return { + u"fps": self.fps, + u"name": str(self.name), + u"tracks": [t.lower() for t in self.tracks.values()] + } + + +class Animation(object): + __slots__ = ["clips", "default_framerate"] + + def __init__(self, clips=None, default_framerate=30): + if clips is None: + self.clips = {} + else: + self.clips = clips + self.default_framerate = default_framerate + + def lower(self): + return [{ + u"path": path.lower(), + u"clip": clip.lower() + } for (path, clip) in self.clips.items()] + + def at_frame(self, visualizer, frame): + return AnimationFrameVisualizer(self, visualizer.path, frame) + + +def js_position(matrix): + return list(matrix[:3, 3]) + + +def js_quaternion(matrix): + quat = tf.quaternion_from_matrix(matrix) + return [quat[1], quat[2], quat[3], quat[0]] + + +class AnimationFrameVisualizer(object): + __slots__ = ["animation", "path", "current_frame"] + + def __init__(self, animation, path, current_frame): + self.animation = animation + self.path = path + self.current_frame = current_frame + + def get_clip(self): + if self.path not in self.animation.clips: + self.animation.clips[self.path] = AnimationClip(fps=self.animation.default_framerate) + return self.animation.clips[self.path] + + def set_transform(self, matrix): + clip = self.get_clip() + clip.set_property(self.current_frame, u"position", u"vector3", js_position(matrix)) + clip.set_property(self.current_frame, u"quaternion", u"quaternion", js_quaternion(matrix)) + + def set_property(self, prop, jstype, value): + clip = self.get_clip() + clip.set_property(self.current_frame, prop, jstype, value) + + def __getitem__(self, path): + return AnimationFrameVisualizer(self.animation, self.path.append(path), self.current_frame) + + def __enter__(self): + return self + + def __exit__(self, *arg): + pass + + +def convert_frames_to_video(tar_file_path, output_path="output.mp4", framerate=60, overwrite=False): + """ + Try to convert a tar file containing a sequence of frames saved by the + meshcat viewer into a single video file. + + This relies on having `ffmpeg` installed on your system. + """ + output_path = os.path.abspath(output_path) + if os.path.isfile(output_path) and not overwrite: + raise ValueError("The output path {:s} already exists. To overwrite that file, you can pass overwrite=True to this function.".format(output_path)) + with tempfile.TemporaryDirectory() as tmp_dir: + with tarfile.open(tar_file_path) as tar: + def is_within_directory(directory, target): + + abs_directory = os.path.abspath(directory) + abs_target = os.path.abspath(target) + + prefix = os.path.commonprefix([abs_directory, abs_target]) + + return prefix == abs_directory + + def safe_extract(tar, path=".", members=None, *, numeric_owner=False): + + for member in tar.getmembers(): + member_path = os.path.join(path, member.name) + if not is_within_directory(path, member_path): + raise Exception("Attempted Path Traversal in Tar File") + + tar.extractall(path, members, numeric_owner=numeric_owner) + + + safe_extract(tar, tmp_dir) + args = ["ffmpeg", + "-r", str(framerate), + "-i", r"%07d.png", + "-vcodec", "libx264", + "-preset", "slow", + "-pix_fmt", "yuv420p", + "-crf", "18"] + if overwrite: + args.append("-y") + args.append(output_path) + try: + subprocess.check_call(args, cwd=tmp_dir) + except subprocess.CalledProcessError as e: + print(""" +Could not call `ffmpeg` to convert your frames into a video. +If you want to convert the frames manually, you can extract the +.tar archive into a directory, cd to that directory, and run: +ffmpeg -r 60 -i %07d.png -vcodec libx264 -preset slow -pix_fmt yuv420p -crf 18 output.mp4 + """) + raise + print("Saved output as {:s}".format(output_path)) + return output_path + + + diff --git a/src/meshcat/commands.py b/src/meshcat/commands.py index 9cca856..a8ddcf6 100644 --- a/src/meshcat/commands.py +++ b/src/meshcat/commands.py @@ -1,36 +1,81 @@ -from .geometry import Geometry, Mesh +from .geometry import Geometry, Object, Mesh, MeshPhongMaterial, OrthographicCamera, PerspectiveCamera, PointsMaterial, Points, TextTexture +from .path import Path + class SetObject: __slots__ = ["object", "path"] - def __init__(self, object, path=[]): - if isinstance(object, Geometry): - self.object = Mesh(object) + def __init__(self, geometry_or_object, material=None, path=None): + if isinstance(geometry_or_object, Object): + if material is not None: + raise(ValueError("Please supply either an Object OR a Geometry and a Material")) + self.object = geometry_or_object + elif isinstance(geometry_or_object, (OrthographicCamera, PerspectiveCamera)): + self.object = geometry_or_object else: - self.object = object - self.path = path + if material is None: + material = MeshPhongMaterial() + if isinstance(material, PointsMaterial): + self.object = Points(geometry_or_object, material) + else: + self.object = Mesh(geometry_or_object, material) + if path is not None: + self.path = path + else: + self.path = Path() def lower(self): return { - "type": "set_object", - "object": self.object.lower(), - "path": self.path + u"type": u"set_object", + u"object": self.object.lower(), + u"path": self.path.lower() } class SetTransform: __slots__ = ["matrix", "path"] - def __init__(self, matrix, path=[]): + def __init__(self, matrix, path): self.matrix = matrix self.path = path def lower(self): return { - "type": "set_transform", - "path": self.path, - "matrix": list(self.matrix.T.flatten()) + u"type": u"set_transform", + u"path": self.path.lower(), + u"matrix": list(self.matrix.T.flatten()) } +class SetCamTarget: + """Set the camera target point.""" + __slots__ = ["value"] + def __init__(self, pos): + self.value = pos + + def lower(self): + return { + u"type": "set_target", + u"path": "", + u"value": list(self.value) + } + + +class CaptureImage: + + def __init__(self, xres=None, yres=None): + self.xres = xres + self.yres = yres + + def lower(self): + data = { + u"type": u"capture_image" + } + if self.xres: + data[u"xres"] = self.xres + if self.yres: + data[u"yres"] = self.yres + return data + + class Delete: __slots__ = ["path"] def __init__(self, path): @@ -38,16 +83,40 @@ def __init__(self, path): def lower(self): return { - "type": "delete", - "path": self.path + u"type": u"delete", + u"path": self.path.lower() + } + +class SetProperty: + __slots__ = ["path", "key", "value"] + def __init__(self, key, value, path): + self.key = key + self.value = value + self.path = path + + def lower(self): + return { + u"type": u"set_property", + u"path": self.path.lower(), + u"property": self.key.lower(), + u"value": self.value } +class SetAnimation: + __slots__ = ["animation", "play", "repetitions"] -class ViewerMessage: - def __init__(self, commands): - self.commands = commands + def __init__(self, animation, play=True, repetitions=1): + self.animation = animation + self.play = play + self.repetitions = repetitions def lower(self): return { - "commands": [c.lower() for c in self.commands] + u"type": u"set_animation", + u"animations": self.animation.lower(), + u"options": { + u"play": self.play, + u"repetitions": self.repetitions + }, + u"path": "" } diff --git a/src/meshcat/geometry.py b/src/meshcat/geometry.py index 88925c8..ed9109d 100644 --- a/src/meshcat/geometry.py +++ b/src/meshcat/geometry.py @@ -1,11 +1,13 @@ import base64 import uuid - +from io import StringIO, BytesIO import umsgpack import numpy as np +from . import transformations as tf + -class SceneElement: +class SceneElement(object): def __init__(self): self.uuid = str(uuid.uuid1()) @@ -19,6 +21,9 @@ def lower_in_object(self, object_data): class Geometry(ReferenceSceneElement): field = "geometries" + def intrinsic_transform(self): + return tf.identity_matrix() + class Material(ReferenceSceneElement): field = "materials" @@ -34,75 +39,168 @@ class Image(ReferenceSceneElement): class Box(Geometry): def __init__(self, lengths): - super().__init__() + super(Box, self).__init__() self.lengths = lengths def lower(self, object_data): return { - "uuid": self.uuid, - "type": "BoxGeometry", - "width": self.lengths[0], - "height": self.lengths[1], - "depth": self.lengths[2] + u"uuid": self.uuid, + u"type": u"BoxGeometry", + u"width": self.lengths[0], + u"height": self.lengths[1], + u"depth": self.lengths[2] } class Sphere(Geometry): def __init__(self, radius): - super().__init__() + super(Sphere, self).__init__() self.radius = radius def lower(self, object_data): return { - "uuid": self.uuid, - "type": "SphereGeometry", - "radius": self.radius, - "widthSegments" : 20, - "heightSegments" : 20 + u"uuid": self.uuid, + u"type": u"SphereGeometry", + u"radius": self.radius, + u"widthSegments" : 20, + u"heightSegments" : 20 } +class Ellipsoid(Sphere): + """ + An Ellipsoid is treated as a Sphere of unit radius, with an affine + transformation applied to distort it into the ellipsoidal shape + """ + def __init__(self, radii): + super(Ellipsoid, self).__init__(1.0) + self.radii = radii + + def intrinsic_transform(self): + return np.diag(np.hstack((self.radii, 1.0))) + + +class Plane(Geometry): + + def __init__(self, width=1, height=1, widthSegments=1, heightSegments=1): + super(Plane, self).__init__() + self.width = width + self.height = height + self.widthSegments = widthSegments + self.heightSegments = heightSegments + + def lower(self, object_data): + return { + u"uuid": self.uuid, + u"type": u"PlaneGeometry", + u"width": self.width, + u"height": self.height, + u"widthSegments": self.widthSegments, + u"heightSegments": self.heightSegments, + } + -class MeshMaterial(Material): - def __init__(self, color=0xffffff, reflectivity=0.5, map=None, **kwargs): - super().__init__() +""" +A cylinder of the given height and radius. By Three.js convention, the axis of +rotational symmetry is aligned with the y-axis. +""" +class Cylinder(Geometry): + def __init__(self, height, radius=1.0, radiusTop=None, radiusBottom=None): + super(Cylinder, self).__init__() + if radiusTop is not None and radiusBottom is not None: + self.radiusTop = radiusTop + self.radiusBottom = radiusBottom + else: + self.radiusTop = radius + self.radiusBottom = radius + self.height = height + self.radialSegments = 50 + + def lower(self, object_data): + return { + u"uuid": self.uuid, + u"type": u"CylinderGeometry", + u"radiusTop": self.radiusTop, + u"radiusBottom": self.radiusBottom, + u"height": self.height, + u"radialSegments": self.radialSegments + } + + +class GenericMaterial(Material): + def __init__(self, color=0xffffff, reflectivity=0.5, map=None, + side = 2, transparent = None, opacity = 1.0, + linewidth = 1.0, + wireframe = False, + wireframeLinewidth = 1.0, + vertexColors=False, + **kwargs): + super(GenericMaterial, self).__init__() self.color = color self.reflectivity = reflectivity self.map = map + self.side = side + self.transparent = transparent + self.opacity = opacity + self.linewidth = linewidth + self.wireframe = wireframe + self.wireframeLinewidth = wireframeLinewidth + self.vertexColors = vertexColors self.properties = kwargs def lower(self, object_data): + # Three.js allows a material to have an opacity which is != 1, + # but to still be non-transparent, in which case the opacity only + # serves to desaturate the material's color. That's a pretty odd + # combination of things to want, so by default we juse use the + # opacity value to decide whether to set transparent to True or + # False. + if self.transparent is None: + transparent = bool(self.opacity != 1) + else: + transparent = self.transparent data = { - "uuid": self.uuid, - "type": self._type, - "color": self.color, - "reflectivity": self.reflectivity, + u"uuid": self.uuid, + u"type": self._type, + u"color": self.color, + u"reflectivity": self.reflectivity, + u"side": self.side, + u"transparent": transparent, + u"opacity": self.opacity, + u"linewidth": self.linewidth, + u"wireframe": bool(self.wireframe), + u"wireframeLinewidth": self.wireframeLinewidth, + u"vertexColors": (2 if self.vertexColors else 0), # three.js wants an enum } data.update(self.properties) if self.map is not None: - data["map"] = self.map.lower_in_object(object_data) + data[u"map"] = self.map.lower_in_object(object_data) return data -class MeshBasicMaterial(MeshMaterial): - _type="MeshBasicMaterial" +class MeshBasicMaterial(GenericMaterial): + _type=u"MeshBasicMaterial" + + +class MeshPhongMaterial(GenericMaterial): + _type=u"MeshPhongMaterial" -class MeshPhongMaterial(MeshMaterial): - _type="MeshPhongMaterial" +class MeshLambertMaterial(GenericMaterial): + _type=u"MeshLambertMaterial" -class MeshLambertMaterial(MeshMaterial): - _type="MeshLambertMaterial" +class MeshToonMaterial(GenericMaterial): + _type=u"MeshToonMaterial" -class MeshToonMaterial(MeshMaterial): - _type="MeshToonMaterial" +class LineBasicMaterial(GenericMaterial): + _type=u"LineBasicMaterial" class PngImage(Image): def __init__(self, data): - super().__init__() + super(PngImage, self).__init__() self.data = data @staticmethod @@ -112,28 +210,47 @@ def from_file(fname): def lower(self, object_data): return { - "uuid": self.uuid, - "url": "data:image/png;base64," + base64.b64encode(self.data).decode('ascii') + u"uuid": self.uuid, + u"url": str("data:image/png;base64," + base64.b64encode(self.data).decode('ascii')) + } + + +class TextTexture(Texture): + def __init__(self, text, font_size=100, font_face='sans-serif'): + super(TextTexture, self).__init__() + self.text = text + # font_size will be passed to the JS side as is; however if the + # text width exceeds canvas width, font_size will be reduced. + self.font_size = font_size + self.font_face = font_face + + def lower(self, object_data): + return { + u"uuid": self.uuid, + u"type": u"_text", + u"text": self.text, + u"font_size": self.font_size, + u"font_face": self.font_face, } class GenericTexture(Texture): def __init__(self, properties): - super().__init__() + super(GenericTexture, self).__init__() self.properties = properties def lower(self, object_data): - data = {"uuid": self.uuid} + data = {u"uuid": self.uuid} data.update(self.properties) - if "image" in data: - image = data["image"] - data["image"] = image.lower_in_object(object_data) + if u"image" in data: + image = data[u"image"] + data[u"image"] = image.lower_in_object(object_data) return data class ImageTexture(Texture): def __init__(self, image, wrap=[1001, 1001], repeat=[1, 1], **kwargs): - super().__init__() + super(ImageTexture, self).__init__() self.image = image self.wrap = wrap self.repeat = repeat @@ -141,48 +258,35 @@ def __init__(self, image, wrap=[1001, 1001], repeat=[1, 1], **kwargs): def lower(self, object_data): data = { - "uuid": self.uuid, - "wrap": self.wrap, - "repeat": self.repeat, - "image": self.image.lower_in_object(object_data) + u"uuid": self.uuid, + u"wrap": self.wrap, + u"repeat": self.repeat, + u"image": self.image.lower_in_object(object_data) } data.update(self.properties) return data -class GenericMaterial(Material): - def __init__(self, properties): - self.properties = properties - self.uuid = str(uuid.uuid1()) - - def lower(self, object_data): - data = {"uuid": self.uuid} - data.update(self.properties) - if "map" in data: - texture = data["map"] - data["map"] = texture.lower_in_object(object_data) - return data - - class Object(SceneElement): def __init__(self, geometry, material=MeshPhongMaterial()): - super().__init__() + super(Object, self).__init__() self.geometry = geometry self.material = material def lower(self): data = { - "metadata": { - "version": 4.5, - "type": "Object", + u"metadata": { + u"version": 4.5, + u"type": u"Object", }, - "geometries": [], - "materials": [], - "object": { - "uuid": self.uuid, - "type": self._type, - "geometry": self.geometry.uuid, - "material": self.material.uuid + u"geometries": [], + u"materials": [], + u"object": { + u"uuid": self.uuid, + u"type": self._type, + u"geometry": self.geometry.uuid, + u"material": self.material.uuid, + u"matrix": list(self.geometry.intrinsic_transform().flatten()) } } self.geometry.lower_in_object(data) @@ -191,8 +295,85 @@ def lower(self): class Mesh(Object): - _type = "Mesh" + _type = u"Mesh" + + +class OrthographicCamera(SceneElement): + def __init__(self, left, right, top, bottom, near, far, zoom=1): + super(OrthographicCamera, self).__init__() + self.left = left + self.right = right + self.top = top + self.bottom = bottom + self.near = near + self.far = far + self.zoom = zoom + + def lower(self): + data = { + u"object": { + u"uuid": self.uuid, + u"type": u"OrthographicCamera", + u"left": self.left, + u"right": self.right, + u"top": self.top, + u"bottom": self.bottom, + u"near": self.near, + u"far": self.far, + u"zoom": self.zoom, + } + } + return data + +class PerspectiveCamera(SceneElement): + """ + The PerspectiveCamera is the default camera used by the meshcat viewer. See + https://threejs.org/docs/#api/en/cameras/PerspectiveCamera for more + information. + """ + def __init__(self, fov = 50, aspect = 1, near = 0.1, far = 2000, + zoom = 1, filmGauge=35, filmOffset = 0, focus = 10): + """ + fov : Camera frustum vertical field of view, from bottom to top of view, in degrees. Default is 50. + aspect: Camera frustum aspect ratio, usually the canvas width / canvas height. Default is 1 (square canvas). + near : Camera frustum near plane. Default is 0.1. The valid range is greater than 0 and less than the current + value of the far plane. Note that, unlike for the OrthographicCamera, 0 is not a valid value for a + PerspectiveCamera's near plane. + far : Camera frustum far plane. Default is 2000. + zoom : Gets or sets the zoom factor of the camera. Default is 1. + filmGauge: Film size used for the larger axis. Default is 35 (millimeters). This parameter does not influence + the projection matrix unless .filmOffset is set to a nonzero value. + filmOffset: Horizontal off-center offset in the same unit as .filmGauge. Default is 0. + focus: Object distance used for stereoscopy and depth-of-field effects. This parameter does not influence + the projection matrix unless a StereoCamera is being used. Default is 10. + """ + #super(PerspectiveCamera, self).__init__() + SceneElement.__init__(self) + self.fov = fov + self.aspect = aspect + self.far = far + self.near = near + self.zoom = zoom + self.filmGauge = filmGauge + self.filmOffset = filmOffset + self.focus = focus + def lower(self): + data = { + u"object": { + u"uuid": self.uuid, + u"type": u"PerspectiveCamera", + u"aspect": self.aspect, + u"far": self.far, + u"filmGauge": self.filmGauge, + u"filmOffset": self.filmOffset, + u"focus": self.focus, + u"fov": self.fov, + u"near": self.near, + u"zoom": self.zoom, + } + } + return data def item_size(array): if array.ndim == 1: @@ -205,13 +386,13 @@ def item_size(array): def threejs_type(dtype): if dtype == np.uint8: - return "Uint8Array", 0x12 + return u"Uint8Array", 0x12 elif dtype == np.int32: - return "Int32Array", 0x15 + return u"Int32Array", 0x15 elif dtype == np.uint32: - return "Uint32Array", 0x16 + return u"Uint32Array", 0x16 elif dtype == np.float32: - return "Float32Array", 0x17 + return u"Float32Array", 0x17 else: raise ValueError("Unsupported datatype: " + str(dtype)) @@ -221,69 +402,185 @@ def pack_numpy_array(x): x = x.astype(np.float32) typename, extcode = threejs_type(x.dtype) return { - "itemSize": item_size(x), - "type": typename, - "array": umsgpack.Ext(extcode, x.tobytes()), - "normalized": False + u"itemSize": item_size(x), + u"type": typename, + u"array": umsgpack.Ext(extcode, x.tobytes('F')), + u"normalized": False } -class ObjMeshGeometry(Geometry): - def __init__(self, contents): - super().__init__() +def data_from_stream(stream): + if isinstance(stream, BytesIO): + data = stream.read().decode(encoding='utf-8') + elif isinstance(stream, StringIO): + data = stream.read() + else: + raise ValueError('Stream must be instance of StringIO or BytesIO, not {}'.format(type(stream))) + return data + + +class MeshGeometry(Geometry): + def __init__(self, contents, mesh_format): + super(MeshGeometry, self).__init__() self.contents = contents + self.mesh_format = mesh_format def lower(self, object_data): return { - "type": "_meshfile", - "uuid": self.uuid, - "format": "obj", - "data": self.contents + u"type": u"_meshfile_geometry", + u"uuid": self.uuid, + u"format": self.mesh_format, + u"data": self.contents } + +class ObjMeshGeometry(MeshGeometry): + def __init__(self, contents): + super(ObjMeshGeometry, self, contents, u"obj").__init__() + @staticmethod def from_file(fname): with open(fname, "r") as f: - return ObjMeshGeometry(f.read()) + return MeshGeometry(f.read(), u"obj") + + @staticmethod + def from_stream(f): + return MeshGeometry(data_from_stream(f), u"obj") + + +class DaeMeshGeometry(MeshGeometry): + def __init__(self, contents): + super(DaeMeshGeometry, self, contents, u"dae").__init__() + + @staticmethod + def from_file(fname): + with open(fname, "r") as f: + return MeshGeometry(f.read(), u"dae") + + @staticmethod + def from_stream(f): + return MeshGeometry(data_from_stream(f), u"dae") + + +class StlMeshGeometry(MeshGeometry): + def __init__(self, contents): + super(StlMeshGeometry, self, contents, u"stl").__init__() + + @staticmethod + def from_file(fname): + with open(fname, "rb") as f: + arr = np.frombuffer(f.read(), dtype=np.uint8) + _, extcode = threejs_type(np.uint8) + encoded = umsgpack.Ext(extcode, arr.tobytes()) + return MeshGeometry(encoded, u"stl") + + @staticmethod + def from_stream(f): + if isinstance(f, BytesIO): + arr = np.frombuffer(f.read(), dtype=np.uint8) + elif isinstance(f, StringIO): + arr = np.frombuffer(bytes(f.read(), "utf-8"), dtype=np.uint8) + else: + raise ValueError('Stream must be instance of StringIO or BytesIO, not {}'.format(type(f))) + _, extcode = threejs_type(np.uint8) + encoded = umsgpack.Ext(extcode, arr.tobytes()) + return MeshGeometry(encoded, u"stl") + + +class TriangularMeshGeometry(Geometry): + """ + A mesh consisting of an arbitrary collection of triangular faces. To + construct one, you need to pass in a collection of vertices as an Nx3 array + and a collection of faces as an Mx3 array. Each element of `faces` should + be a collection of 3 indices into the `vertices` array. + + For example, to create a square made out of two adjacent triangles, we + could do: + + vertices = np.array([ + [0, 0, 0], # the first vertex is at [0, 0, 0] + [1, 0, 0], + [1, 0, 1], + [0, 0, 1] + ]) + faces = np.array([ + [0, 1, 2], # The first face consists of vertices 0, 1, and 2 + [3, 0, 2] + ]) + + mesh = TriangularMeshGeometry(vertices, faces) + + To set the color of the mesh by vertex, pass an Nx3 array containing the + RGB values (in range [0,1]) of the vertices to the optional `color` + argument, and set `vertexColors=True` in the Material. + """ + __slots__ = ["vertices", "faces"] + + def __init__(self, vertices, faces, color=None): + super(TriangularMeshGeometry, self).__init__() + + vertices = np.asarray(vertices, dtype=np.float32) + faces = np.asarray(faces, dtype=np.uint32) + assert vertices.shape[1] == 3, "`vertices` must be an Nx3 array" + assert faces.shape[1] == 3, "`faces` must be an Mx3 array" + self.vertices = vertices + self.faces = faces + if color is not None: + color = np.asarray(color, dtype=np.float32) + assert np.array_equal(vertices.shape, color.shape), "`color` must be the same shape as vertices" + self.color = color + + def lower(self, object_data): + attrs = {u"position": pack_numpy_array(self.vertices.T)} + if self.color is not None: + attrs[u"color"] = pack_numpy_array(self.color.T) + return { + u"uuid": self.uuid, + u"type": u"BufferGeometry", + u"data": { + u"attributes": attrs, + u"index": pack_numpy_array(self.faces.T) + } + } class PointsGeometry(Geometry): def __init__(self, position, color=None): - super().__init__() + super(PointsGeometry, self).__init__() self.position = position self.color = color def lower(self, object_data): - attrs = {"position": pack_numpy_array(self.position)} + attrs = {u"position": pack_numpy_array(self.position)} if self.color is not None: - attrs["color"] = pack_numpy_array(self.color) + attrs[u"color"] = pack_numpy_array(self.color) return { - "uuid": self.uuid, - "type": "BufferGeometry", - "data": { - "attributes": attrs + u"uuid": self.uuid, + u"type": u"BufferGeometry", + u"data": { + u"attributes": attrs } } class PointsMaterial(Material): def __init__(self, size=0.001, color=0xffffff): - super().__init__() + super(PointsMaterial, self).__init__() self.size = size self.color = color def lower(self, object_data): return { - "uuid": self.uuid, - "type": "PointsMaterial", - "color": self.color, - "size": self.size, - "vertexColors": 2 + u"uuid": self.uuid, + u"type": u"PointsMaterial", + u"color": self.color, + u"size": self.size, + u"vertexColors": 2 } class Points(Object): - _type = "Points" + _type = u"Points" def PointCloud(position, color, **kwargs): @@ -293,4 +590,41 @@ def PointCloud(position, color, **kwargs): ) +def SceneText(text, width=10, height=10, **kwargs): + return Mesh( + Plane(width=width,height=height), + MeshPhongMaterial(map=TextTexture(text,**kwargs),transparent=True, + needsUpdate=True) + ) + +class Line(Object): + _type = u"Line" + + +class LineSegments(Object): + _type = u"LineSegments" + + +class LineLoop(Object): + _type = u"LineLoop" + + +def triad(scale=1.0): + """ + A visual representation of the origin of a coordinate system, drawn as three + lines in red, green, and blue along the x, y, and z axes. The `scale` parameter + controls the length of the three lines. + Returns an `Object` which can be passed to `set_object()` + """ + return LineSegments( + PointsGeometry(position=np.array([ + [0, 0, 0], [scale, 0, 0], + [0, 0, 0], [0, scale, 0], + [0, 0, 0], [0, 0, scale]]).astype(np.float32).T, + color=np.array([ + [1, 0, 0], [1, 0.6, 0], + [0, 1, 0], [0.6, 1, 0], + [0, 0, 1], [0, 0.6, 1]]).astype(np.float32).T + ), + LineBasicMaterial(vertexColors=True)) diff --git a/src/meshcat/path.py b/src/meshcat/path.py new file mode 100644 index 0000000..074e43a --- /dev/null +++ b/src/meshcat/path.py @@ -0,0 +1,23 @@ +class Path(object): + __slots__ = ["entries"] + + def __init__(self, entries=tuple()): + self.entries = entries + + def append(self, other): + new_path = self.entries + for element in other.split('/'): + if len(element) == 0: + new_path = tuple() + else: + new_path = new_path + (element,) + return Path(new_path) + + def lower(self): + return "/" + "/".join(self.entries) + + def __hash__(self): + return hash(self.entries) + + def __eq__(self, other): + return self.entries == other.entries diff --git a/src/meshcat/servers/tree.py b/src/meshcat/servers/tree.py new file mode 100644 index 0000000..f21d41d --- /dev/null +++ b/src/meshcat/servers/tree.py @@ -0,0 +1,27 @@ +from __future__ import absolute_import, division, print_function + +from collections import defaultdict + +class TreeNode(defaultdict): + __slots__ = ["object", "transform", "properties", "animation"] + + def __init__(self, *args, **kwargs): + super(TreeNode, self).__init__(*args, **kwargs) + self.object = None + self.properties = [] + self.transform = None + self.animation = None + +SceneTree = lambda: TreeNode(SceneTree) + +def walk(tree): + yield tree + for v in tree.values(): + for t in walk(v): # could use `yield from` if we didn't need python2 + yield t + +def find_node(tree, path): + if len(path) == 0: + return tree + else: + return find_node(tree[path[0]], path[1:]) diff --git a/src/meshcat/servers/zmqserver.py b/src/meshcat/servers/zmqserver.py index cbde214..3b7e39a 100644 --- a/src/meshcat/servers/zmqserver.py +++ b/src/meshcat/servers/zmqserver.py @@ -1,9 +1,13 @@ from __future__ import absolute_import, division, print_function +import atexit +import base64 import os +import re import sys +import subprocess import multiprocessing -from collections import deque +import json import tornado.web import tornado.ioloop @@ -11,60 +15,161 @@ import tornado.gen import zmq -from zmq.eventloop import ioloop +import zmq.eventloop.ioloop from zmq.eventloop.zmqstream import ZMQStream -# Install ZMQ ioloop instead of a tornado ioloop -# http://zeromq.github.com/pyzmq/eventloop.html -ioloop.install() +from .tree import SceneTree, walk, find_node -VIEWER_ROOT = os.path.join(os.path.dirname(__file__), "..", "viewer", "static") -VIEWER_HTML = "meshcat.html" +def capture(pattern, s): + match = re.match(pattern, s) + if not match: + raise ValueError("Could not match {:s} with pattern {:s}".format(s, pattern)) + else: + return match.groups()[0] + +def match_zmq_url(line): + return capture(r"^zmq_url=(.*)$", line) + +def match_web_url(line): + return capture(r"^web_url=(.*)$", line) + +def start_zmq_server_as_subprocess(zmq_url=None, server_args=[]): + """ + Starts the ZMQ server as a subprocess, passing *args through popen. + Optional Keyword Arguments: + zmq_url + """ + # Need -u for unbuffered output: https://stackoverflow.com/a/25572491 + args = [sys.executable, "-u", "-m", "meshcat.servers.zmqserver"] + if zmq_url is not None: + args.append("--zmq-url") + args.append(zmq_url) + if server_args: + args.append(*server_args) + # Note: Pass PYTHONPATH to be robust to workflows like Google Colab, + # where meshcat might have been added directly via sys.path.append. + # Copy existing environmental variables as some of them might be needed + # e.g. on Windows SYSTEMROOT and PATH + env = dict(os.environ) + env["PYTHONPATH"] = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + # Use start_new_session if it's available. Without it, in jupyter the server + # goes down when we cancel execution of any cell in the notebook. + server_proc = subprocess.Popen(args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=True) + line = "" + while "zmq_url" not in line: + line = server_proc.stdout.readline().strip().decode("utf-8") + if server_proc.poll() is not None: + outs, errs = server_proc.communicate() + print(outs.decode("utf-8")) + print(errs.decode("utf-8")) + raise RuntimeError("the meshcat server process exited prematurely with exit code " + str(server_proc.poll())) + zmq_url = match_zmq_url(line) + web_url = match_web_url(server_proc.stdout.readline().strip().decode("utf-8")) + + def cleanup(server_proc): + server_proc.kill() + server_proc.wait() + + atexit.register(cleanup, server_proc) + return server_proc, zmq_url, web_url + + +def _zmq_install_ioloop(): + # For pyzmq<17, install ioloop instead of a tornado ioloop + # http://zeromq.github.com/pyzmq/eventloop.html + try: + pyzmq_major = int(zmq.__version__.split(".")[0]) + except ValueError: + # Development version? + return + if pyzmq_major < 17: + zmq.eventloop.ioloop.install() + + +_zmq_install_ioloop() + +VIEWER_ROOT = os.path.join(os.path.dirname(__file__), "..", "viewer", "dist") +VIEWER_HTML = "index.html" DEFAULT_FILESERVER_PORT = 7000 MAX_ATTEMPTS = 1000 DEFAULT_ZMQ_METHOD = "tcp" DEFAULT_ZMQ_PORT = 6000 +MESHCAT_COMMANDS = ["set_transform", "set_object", "delete", "set_property", "set_animation"] + -def find_available_port(func, default_port, max_attempts=MAX_ATTEMPTS): +def find_available_port(func, default_port, max_attempts=MAX_ATTEMPTS, **kwargs): for i in range(max_attempts): port = default_port + i try: - return func(port), port + return func(port, **kwargs), port except (OSError, zmq.error.ZMQError): print("Port: {:d} in use, trying another...".format(port), file=sys.stderr) - pass + except Exception as e: + print(type(e)) + raise else: raise(Exception("Could not find an available port in the range: [{:d}, {:d})".format(default_port, max_attempts + default_port))) class WebSocketHandler(tornado.websocket.WebSocketHandler): - def __init__(self, *args, bridge=None, **kwargs): + def __init__(self, *args, **kwargs): + self.bridge = kwargs.pop("bridge") super(WebSocketHandler, self).__init__(*args, **kwargs) - self.bridge = bridge def open(self): self.bridge.websocket_pool.add(self) - self.bridge.process_pending_messages() print("opened:", self, file=sys.stderr) + self.bridge.send_scene(self) def on_message(self, message): - pass + try: + message = json.loads(message) + self.bridge.send_image(message['data']) + return + except Exception as err: + print(err) + raise def on_close(self): self.bridge.websocket_pool.remove(self) print("closed:", self, file=sys.stderr) +def create_command(data): + """Encode the drawing command into a Javascript fetch() command for display.""" + return """ +fetch("data:application/octet-binary;base64,{}") + .then(res => res.arrayBuffer()) + .then(buffer => viewer.handle_command_bytearray(new Uint8Array(buffer))); + """.format(base64.b64encode(data).decode("utf-8")) + + +class StaticFileHandlerNoCache(tornado.web.StaticFileHandler): + """Ensures static files do not get cached. + + Taken from: https://stackoverflow.com/a/18879658/7829525 + """ + def set_extra_headers(self, path): + # Disable cache + self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + + class ZMQWebSocketBridge(object): context = zmq.Context() - def __init__(self, zmq_url=None, host="127.0.0.1", port=None): + def __init__(self, zmq_url=None, host="127.0.0.1", port=None, + certfile=None, keyfile=None, ngrok_http_tunnel=False): self.host = host self.websocket_pool = set() self.app = self.make_app() + self.ioloop = tornado.ioloop.IOLoop.current() if zmq_url is None: def f(port): @@ -73,36 +178,181 @@ def f(port): else: self.zmq_socket, self.zmq_stream, self.zmq_url = self.setup_zmq(zmq_url) + protocol = "http:" + listen_kwargs = {} + if certfile is not None or keyfile is not None: + if certfile is None: + raise(Exception("You must supply a certfile if you supply a keyfile")) + if keyfile is None: + raise(Exception("You must supply a keyfile if you supply a certfile")) + + listen_kwargs["ssl_options"] = { "certfile": certfile, + "keyfile": keyfile } + protocol = "https:" + if port is None: - _, self.fileserver_port = find_available_port(self.app.listen, DEFAULT_FILESERVER_PORT) + _, self.fileserver_port = find_available_port(self.app.listen, DEFAULT_FILESERVER_PORT, **listen_kwargs) else: - self.app.listen(port) + self.app.listen(port, **listen_kwargs) self.fileserver_port = port - self.web_url = "http://{host}:{port}/static/".format(host=self.host, port=self.fileserver_port) - self.pending_messages = deque() + self.web_url = "{protocol}//{host}:{port}/static/".format( + protocol=protocol, host=self.host, port=self.fileserver_port) + + # Note: The (significant) advantage of putting this in here is not only + # so that the workflow is convenient, but also so that the server + # administers the public web_url when clients ask for it. + if ngrok_http_tunnel: + if protocol == "https:": + # TODO(russt): Consider plumbing ngrok auth through here for + # someone who has paid for ngrok and wants to use https. + raise(Exception('The free version of ngrok does not support https')) + + # Conditionally import pyngrok + try: + import pyngrok.conf + import pyngrok.ngrok + + # Use start_new_session if it's available. Without it, in + # jupyter the server goes down when we cancel execution of any + # cell in the notebook. + config = pyngrok.conf.PyngrokConfig(start_new_session=True) + self.web_url = pyngrok.ngrok.connect(self.fileserver_port, "http", pyngrok_config=config) + + # pyngrok >= 5.0.0 returns an NgrokTunnel object instead of the string. + if not isinstance(self.web_url, str): + self.web_url = self.web_url.public_url + self.web_url += "/static/" + + print("\n") # ensure any pyngrok output is properly terminated. + def cleanup(): + pyngrok.ngrok.kill() + + atexit.register(cleanup) + + except ImportError as e: + if "pyngrok" in e.__class__.__name__: + raise(Exception("You must install pyngrok (e.g. via `pip install pyngrok`).")) + + self.tree = SceneTree() def make_app(self): return tornado.web.Application([ - (r"/static/(.*)", tornado.web.StaticFileHandler, {"path": VIEWER_ROOT, "default_filename": VIEWER_HTML}), + (r"/static/(.*)", StaticFileHandlerNoCache, {"path": VIEWER_ROOT, "default_filename": VIEWER_HTML}), (r"/", WebSocketHandler, {"bridge": self}) ]) - def handle_zmq(self, msg): - if len(msg) == 1 and len(msg[0]) == 0: - self.zmq_socket.send(self.web_url.encode("utf-8")) + def wait_for_websockets(self): + if len(self.websocket_pool) > 0: + self.zmq_socket.send(b"ok") else: - self.send_to_websockets(msg) + self.ioloop.call_later(0.1, self.wait_for_websockets) - def send_to_websockets(self, msg): - self.pending_messages.append(msg) - self.process_pending_messages() + def send_image(self, data): + import base64 + mime, img_code = data.split(",", 1) + img_bytes = base64.b64decode(img_code) + self.zmq_stream.send(img_bytes) - def process_pending_messages(self): - while len(self.pending_messages) > 0 and len(self.websocket_pool) > 0: - msg = self.pending_messages.popleft() - for websocket in self.websocket_pool: - websocket.write_message(msg[0], binary=True) + def handle_zmq(self, frames): + cmd = frames[0].decode("utf-8") + if cmd == "url": + self.zmq_socket.send(self.web_url.encode("utf-8")) + elif cmd == "wait": + self.ioloop.add_callback(self.wait_for_websockets) + elif cmd == "set_target": + self.forward_to_websockets(frames) + self.zmq_socket.send(b"ok") + elif cmd == "capture_image": + if len(self.websocket_pool) > 0: + self.forward_to_websockets(frames) # on_message callback should handle the pb + else: + self.ioloop.call_later(0.3, lambda: self.handle_zmq(frames)) + elif cmd in MESHCAT_COMMANDS: + if len(frames) != 3: + self.zmq_socket.send(b"error: expected 3 frames") + return + path = list(filter(lambda x: len(x) > 0, frames[1].decode("utf-8").split("/"))) + data = frames[2] + # Support caching of objects (note: even UUIDs have to match). + cache_hit = (cmd == "set_object" and + find_node(self.tree, path).object and + find_node(self.tree, path).object == data) + if not cache_hit: + self.forward_to_websockets(frames) + if cmd == "set_transform": + find_node(self.tree, path).transform = data + elif cmd == "set_object": + find_node(self.tree, path).object = data + find_node(self.tree, path).properties = [] + elif cmd == "set_property": + find_node(self.tree, path).properties.append(data) + elif cmd == "set_animation": + find_node(self.tree, path).animation = data + elif cmd == "delete": + if len(path) > 0: + parent = find_node(self.tree, path[:-1]) + child = path[-1] + if child in parent: + del parent[child] + else: + self.tree = SceneTree() self.zmq_socket.send(b"ok") + elif cmd == "get_scene": + # when the server gets this command, return the tree + # as a series of msgpack-backed binary blobs + drawing_commands = "" + for node in walk(self.tree): + if node.object is not None: + drawing_commands += create_command(node.object) + for p in node.properties: + drawing_commands += create_command(p) + if node.transform is not None: + drawing_commands += create_command(node.transform) + if node.animation is not None: + drawing_commands += create_command(node.animation) + + # now that we have the drawing commands, generate the full + # HTML that we want to generate, including the javascript assets + mainminjs_path = os.path.join(VIEWER_ROOT, "main.min.js") + mainminjs_src = "" + with open(mainminjs_path, "r") as f: + mainminjs_src = f.readlines() + mainminjs_src = "".join(mainminjs_src) + + html = """ + + + MeshCat + +
+
+ + + + + + + """.format(mainminjs=mainminjs_src, commands=drawing_commands) + self.zmq_socket.send(html.encode('utf-8')) + else: + self.zmq_socket.send(b"error: unrecognized comand") + + def forward_to_websockets(self, frames): + cmd, path, data = frames + for websocket in self.websocket_pool: + websocket.write_message(data, binary=True) def setup_zmq(self, url): zmq_socket = self.context.socket(zmq.REP) @@ -111,36 +361,55 @@ def setup_zmq(self, url): zmq_stream.on_recv(self.handle_zmq) return zmq_socket, zmq_stream, url - def run(self): - tornado.ioloop.IOLoop.current().start() - - -# def _run_server(queue, **kwargs): -# queue.put((bridge.zmq_url, bridge.web_url)) + def send_scene(self, websocket): + for node in walk(self.tree): + if node.object is not None: + websocket.write_message(node.object, binary=True) + for p in node.properties: + websocket.write_message(p, binary=True) + if node.transform is not None: + websocket.write_message(node.transform, binary=True) + if node.animation is not None: + websocket.write_message(node.animation, binary=True) -# def create_server(*args, **kwargs): -# queue = multiprocessing.Queue() -# proc = multiprocessing.Process(target=_run_server, args=(queue,), kwargs=kwargs) -# proc.daemon = True -# proc.start() -# return proc, queue.get() + def run(self): + self.ioloop.start() -if __name__ == '__main__': +def main(): import argparse import sys + import webbrowser + import platform + import asyncio + + # Fix asyncio configuration on Windows for Python 3.8 and above. + # Workaround for https://github.com/tornadoweb/tornado/issues/2608 + if sys.version_info >= (3, 8) and platform.system() == 'Windows': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) parser = argparse.ArgumentParser(description="Serve the MeshCat HTML files and listen for ZeroMQ commands") - parser.add_argument('--zmq_url', '-z', type=str, nargs="?", default=None) + parser.add_argument('--zmq-url', '-z', type=str, nargs="?", default=None) parser.add_argument('--open', '-o', action="store_true") - # parser.add_argument('-m', type=str, nargs="?", default=None, help="") # Handle invocation via python -m packagename - parser.parse_args() + parser.add_argument('--certfile', type=str, default=None) + parser.add_argument('--keyfile', type=str, default=None) + parser.add_argument('--ngrok_http_tunnel', action="store_true", help=""" +ngrok is a service for creating a public URL from your local machine, which +is very useful if you would like to make your meshcat server public.""") + results = parser.parse_args() + bridge = ZMQWebSocketBridge(zmq_url=results.zmq_url, + certfile=results.certfile, + keyfile=results.keyfile, + ngrok_http_tunnel=results.ngrok_http_tunnel) + print("zmq_url={:s}".format(bridge.zmq_url)) + print("web_url={:s}".format(bridge.web_url)) + if results.open: + webbrowser.open(bridge.web_url, new=2) + + try: + bridge.run() + except KeyboardInterrupt: + pass - if len(sys.argv) > 1: - zmq_url = sys.argv[1] - else: - zmq_url = None - bridge = ZMQWebSocketBridge(zmq_url=zmq_url) - print(bridge.zmq_url) - print(bridge.web_url) - bridge.run() +if __name__ == '__main__': + main() diff --git a/src/meshcat/tests/data/mesh_0_convex_piece_0.dae b/src/meshcat/tests/data/mesh_0_convex_piece_0.dae new file mode 100644 index 0000000..161ceb9 --- /dev/null +++ b/src/meshcat/tests/data/mesh_0_convex_piece_0.dae @@ -0,0 +1,74 @@ + + + + + VCGLab + VCGLib | MeshLab + + Y_UP + Sun Feb 24 17:28:46 2019 + Sun Feb 24 17:28:46 2019 + + + + + + + + + -0.064709 0 0.118959 -0.064709 0 -0.118959 0.047419 0.04865 0.114999 0.047419 0.04865 -0.114999 -0.04312 0.053894 -0.116528 -0.04312 0.053894 0.116528 0.066425 0.015734 -0.117647 0.066425 0.015734 0.117647 0.004811 0.067837 -0.112214 0.004811 0.067837 0.112214 0.043045 0.053921 -0.116694 0.043045 0.053921 0.116694 -0.037308 0.056009 -0.114244 -0.04582 0.051421 0.116936 -0.04582 0.051421 -0.116936 -0.066397 0.015781 -0.117357 -0.066397 0.015781 0.117357 0.066882 0.002727 -0.118456 0.066882 0.002727 0.118456 0.064573 0 0.118959 0.064573 0 -0.118959 -0.067313 0.005169 0.118977 -0.067313 0.005169 -0.118977 -0.004941 0.06779 -0.112053 -0.004941 0.06779 0.112053 + + + + + + + + + + 0 -1 0 -0.675427 0.737427 0 0.342019 0.939693 0 0.00149996 0.0433893 0.999057 -0.893075 -0.449907 0 0 -1 0 0.999383 0.035113 0 0.0795751 0.073529 -0.994113 0.13702 0.0662866 0.988348 0.763174 -0.646193 0 0.0795751 0.073529 0.994113 0.00180951 0.04269 0.999087 0 -0.00348239 0.999994 -0.866023 0.500004 0 -0.00481929 0.999988 0 -0.34203 0.939689 0 -0.271789 0.168112 0.94756 -0.996295 0.085998 0 -0.866023 0.500004 0 0.342019 0.939693 0 -0.00481929 0.999988 0 0.747748 0.469526 0.469488 0.769548 0.638589 0 0.866003 0.500038 0 -0.00193139 0.301631 0.953423 -0.00195191 0.164856 0.986316 -0.675427 0.737427 0 -0.0171509 0.348873 0.937013 -0.34202 0.939693 -1.60643e-06 0.747748 0.469525 -0.469488 0.769548 0.638589 0 0.866003 0.500038 0 -0.893075 -0.449907 0 0 -0.00348239 -0.999994 0.00180951 0.04269 -0.999087 -0.996295 0.085998 0 0.00149996 0.0433893 -0.999057 -0.271789 0.168112 -0.94756 0.763174 -0.646193 0 0.999383 0.035113 0 0.13702 0.0662866 -0.988348 -0.342044 0.939684 0.000230279 -0.00195191 0.164856 -0.986316 -0.00193139 0.301631 -0.953423 -0.0171509 0.348873 -0.937013 -0.341964 0.939713 0 + + + + + + + + + + 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 0.752941 + + + + + + + + + + + + + + + +

0 0 0 20 20 0 19 19 0 14 14 1 5 5 1 4 4 1 10 10 2 9 9 2 11 11 2 13 13 3 21 21 3 11 11 3 21 21 4 1 1 4 0 0 4 1 1 5 20 20 5 0 0 5 7 7 6 18 18 6 6 6 6 20 20 7 10 10 7 6 6 7 18 18 8 7 7 8 19 19 8 20 20 9 18 18 9 19 19 9 7 7 10 11 11 10 19 19 10 11 11 11 21 21 11 19 19 11 21 21 12 0 0 12 19 19 12 15 15 13 13 13 13 14 14 13 9 9 14 23 23 14 24 24 14 23 23 15 12 12 15 24 24 15 21 21 16 13 13 16 16 16 16 15 15 17 21 21 17 16 16 17 13 13 18 15 15 18 16 16 18 9 9 19 10 10 19 8 8 19 23 23 20 9 9 20 8 8 20 11 11 21 7 7 21 2 2 21 10 10 22 11 11 22 2 2 22 7 7 23 6 6 23 2 2 23 11 11 24 9 9 24 5 5 24 13 13 25 11 11 25 5 5 25 14 14 26 13 13 26 5 5 26 9 9 27 24 24 27 5 5 27 24 24 28 12 12 28 5 5 28 6 6 29 10 10 29 3 3 29 10 10 30 2 2 30 3 3 30 2 2 31 6 6 31 3 3 31 1 1 32 21 21 32 22 22 32 20 20 33 1 1 33 22 22 33 10 10 34 20 20 34 22 22 34 21 21 35 15 15 35 22 22 35 14 14 36 10 10 36 22 22 36 15 15 37 14 14 37 22 22 37 18 18 38 20 20 38 17 17 38 6 6 39 18 18 39 17 17 39 20 20 40 6 6 40 17 17 40 12 12 41 23 23 41 4 4 41 10 10 42 14 14 42 4 4 42 8 8 43 10 10 43 4 4 43 23 23 44 8 8 44 4 4 44 5 5 45 12 12 45 4 4 45

+
+
+
+
+ + + + + + + + + + + + + + +
diff --git a/src/meshcat/tests/data/mesh_0_convex_piece_0.obj b/src/meshcat/tests/data/mesh_0_convex_piece_0.obj new file mode 100644 index 0000000..462d346 --- /dev/null +++ b/src/meshcat/tests/data/mesh_0_convex_piece_0.obj @@ -0,0 +1,71 @@ +v -0.06470900 0.00000000 0.11895900 +v -0.06470900 0.00000000 -0.11895900 +v 0.04741900 0.04865000 0.11499900 +v 0.04741900 0.04865000 -0.11499900 +v -0.04312000 0.05389400 -0.11652800 +v -0.04312000 0.05389400 0.11652800 +v 0.06642500 0.01573400 -0.11764700 +v 0.06642500 0.01573400 0.11764700 +v 0.00481100 0.06783700 -0.11221400 +v 0.00481100 0.06783700 0.11221400 +v 0.04304500 0.05392100 -0.11669400 +v 0.04304500 0.05392100 0.11669400 +v -0.03730800 0.05600900 -0.11424400 +v -0.04582000 0.05142100 0.11693600 +v -0.04582000 0.05142100 -0.11693600 +v -0.06639700 0.01578100 -0.11735700 +v -0.06639700 0.01578100 0.11735700 +v 0.06688200 0.00272700 -0.11845600 +v 0.06688200 0.00272700 0.11845600 +v 0.06457300 0.00000000 0.11895900 +v 0.06457300 0.00000000 -0.11895900 +v -0.06731300 0.00516900 0.11897700 +v -0.06731300 0.00516900 -0.11897700 +v -0.00494100 0.06779000 -0.11205300 +v -0.00494100 0.06779000 0.11205300 +f 1 21 20 +f 15 6 5 +f 11 10 12 +f 14 22 12 +f 22 2 1 +f 2 21 1 +f 8 19 7 +f 21 11 7 +f 19 8 20 +f 21 19 20 +f 8 12 20 +f 12 22 20 +f 22 1 20 +f 16 14 15 +f 10 24 25 +f 24 13 25 +f 22 14 17 +f 16 22 17 +f 14 16 17 +f 10 11 9 +f 24 10 9 +f 12 8 3 +f 11 12 3 +f 8 7 3 +f 12 10 6 +f 14 12 6 +f 15 14 6 +f 10 25 6 +f 25 13 6 +f 7 11 4 +f 11 3 4 +f 3 7 4 +f 2 22 23 +f 21 2 23 +f 11 21 23 +f 22 16 23 +f 15 11 23 +f 16 15 23 +f 19 21 18 +f 7 19 18 +f 21 7 18 +f 13 24 5 +f 11 15 5 +f 9 11 5 +f 24 9 5 +f 6 13 5 \ No newline at end of file diff --git a/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_ascii b/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_ascii new file mode 100644 index 0000000..cdd6181 --- /dev/null +++ b/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_ascii @@ -0,0 +1,324 @@ +solid vcg + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex -6.470900e-02 0.000000e+00 1.189590e-01 + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex 6.457300e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal -6.754271e-01 7.374267e-01 0.000000e+00 + outer loop + vertex -4.582000e-02 5.142100e-02 -1.169360e-01 + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + vertex -4.312000e-02 5.389400e-02 -1.165280e-01 + endloop + endfacet + facet normal 3.420193e-01 9.396929e-01 -0.000000e+00 + outer loop + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 4.811000e-03 6.783700e-02 1.122140e-01 + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + endloop + endfacet + facet normal 1.499956e-03 4.338930e-02 9.990572e-01 + outer loop + vertex -4.582000e-02 5.142100e-02 1.169360e-01 + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + endloop + endfacet + facet normal -8.930755e-01 -4.499069e-01 0.000000e+00 + outer loop + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex -6.470900e-02 0.000000e+00 -1.189590e-01 + vertex -6.470900e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex -6.470900e-02 0.000000e+00 -1.189590e-01 + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex -6.470900e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal 9.993834e-01 3.511298e-02 0.000000e+00 + outer loop + vertex 6.642500e-02 1.573400e-02 1.176470e-01 + vertex 6.688200e-02 2.727000e-03 1.184560e-01 + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + endloop + endfacet + facet normal 7.957511e-02 7.352903e-02 -9.941133e-01 + outer loop + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + endloop + endfacet + facet normal 1.370198e-01 6.628661e-02 9.883479e-01 + outer loop + vertex 6.688200e-02 2.727000e-03 1.184560e-01 + vertex 6.642500e-02 1.573400e-02 1.176470e-01 + vertex 6.457300e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal 7.631737e-01 -6.461934e-01 0.000000e+00 + outer loop + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex 6.688200e-02 2.727000e-03 1.184560e-01 + vertex 6.457300e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal 7.957510e-02 7.352903e-02 9.941133e-01 + outer loop + vertex 6.642500e-02 1.573400e-02 1.176470e-01 + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + vertex 6.457300e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal 1.809506e-03 4.268996e-02 9.990867e-01 + outer loop + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex 6.457300e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal 0.000000e+00 -3.482394e-03 9.999939e-01 + outer loop + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex -6.470900e-02 0.000000e+00 1.189590e-01 + vertex 6.457300e-02 0.000000e+00 1.189590e-01 + endloop + endfacet + facet normal -8.660231e-01 5.000042e-01 0.000000e+00 + outer loop + vertex -6.639700e-02 1.578100e-02 -1.173570e-01 + vertex -4.582000e-02 5.142100e-02 1.169360e-01 + vertex -4.582000e-02 5.142100e-02 -1.169360e-01 + endloop + endfacet + facet normal -4.819290e-03 9.999884e-01 0.000000e+00 + outer loop + vertex 4.811000e-03 6.783700e-02 1.122140e-01 + vertex -4.941000e-03 6.779000e-02 -1.120530e-01 + vertex -4.941000e-03 6.779000e-02 1.120530e-01 + endloop + endfacet + facet normal -3.420299e-01 9.396891e-01 0.000000e+00 + outer loop + vertex -4.941000e-03 6.779000e-02 -1.120530e-01 + vertex -3.730800e-02 5.600900e-02 -1.142440e-01 + vertex -4.941000e-03 6.779000e-02 1.120530e-01 + endloop + endfacet + facet normal -2.717889e-01 1.681123e-01 9.475595e-01 + outer loop + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex -4.582000e-02 5.142100e-02 1.169360e-01 + vertex -6.639700e-02 1.578100e-02 1.173570e-01 + endloop + endfacet + facet normal -9.962953e-01 8.599798e-02 0.000000e+00 + outer loop + vertex -6.639700e-02 1.578100e-02 -1.173570e-01 + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex -6.639700e-02 1.578100e-02 1.173570e-01 + endloop + endfacet + facet normal -8.660229e-01 5.000042e-01 0.000000e+00 + outer loop + vertex -4.582000e-02 5.142100e-02 1.169360e-01 + vertex -6.639700e-02 1.578100e-02 -1.173570e-01 + vertex -6.639700e-02 1.578100e-02 1.173570e-01 + endloop + endfacet + facet normal 3.420193e-01 9.396929e-01 0.000000e+00 + outer loop + vertex 4.811000e-03 6.783700e-02 1.122140e-01 + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 4.811000e-03 6.783700e-02 -1.122140e-01 + endloop + endfacet + facet normal -4.819291e-03 9.999884e-01 0.000000e+00 + outer loop + vertex -4.941000e-03 6.779000e-02 -1.120530e-01 + vertex 4.811000e-03 6.783700e-02 1.122140e-01 + vertex 4.811000e-03 6.783700e-02 -1.122140e-01 + endloop + endfacet + facet normal 7.477479e-01 4.695255e-01 4.694878e-01 + outer loop + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + vertex 6.642500e-02 1.573400e-02 1.176470e-01 + vertex 4.741900e-02 4.865000e-02 1.149990e-01 + endloop + endfacet + facet normal 7.695478e-01 6.385892e-01 -0.000000e+00 + outer loop + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + vertex 4.741900e-02 4.865000e-02 1.149990e-01 + endloop + endfacet + facet normal 8.660033e-01 5.000383e-01 0.000000e+00 + outer loop + vertex 6.642500e-02 1.573400e-02 1.176470e-01 + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + vertex 4.741900e-02 4.865000e-02 1.149990e-01 + endloop + endfacet + facet normal -1.931394e-03 3.016308e-01 9.534228e-01 + outer loop + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + vertex 4.811000e-03 6.783700e-02 1.122140e-01 + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + endloop + endfacet + facet normal -1.951906e-03 1.648559e-01 9.863157e-01 + outer loop + vertex -4.582000e-02 5.142100e-02 1.169360e-01 + vertex 4.304500e-02 5.392100e-02 1.166940e-01 + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + endloop + endfacet + facet normal -6.754271e-01 7.374268e-01 0.000000e+00 + outer loop + vertex -4.582000e-02 5.142100e-02 -1.169360e-01 + vertex -4.582000e-02 5.142100e-02 1.169360e-01 + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + endloop + endfacet + facet normal -1.715086e-02 3.488726e-01 9.370132e-01 + outer loop + vertex 4.811000e-03 6.783700e-02 1.122140e-01 + vertex -4.941000e-03 6.779000e-02 1.120530e-01 + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + endloop + endfacet + facet normal -3.420200e-01 9.396927e-01 -1.606431e-06 + outer loop + vertex -4.941000e-03 6.779000e-02 1.120530e-01 + vertex -3.730800e-02 5.600900e-02 -1.142440e-01 + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + endloop + endfacet + facet normal 7.477477e-01 4.695254e-01 -4.694884e-01 + outer loop + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 4.741900e-02 4.865000e-02 -1.149990e-01 + endloop + endfacet + facet normal 7.695478e-01 6.385892e-01 0.000000e+00 + outer loop + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 4.741900e-02 4.865000e-02 1.149990e-01 + vertex 4.741900e-02 4.865000e-02 -1.149990e-01 + endloop + endfacet + facet normal 8.660033e-01 5.000383e-01 0.000000e+00 + outer loop + vertex 4.741900e-02 4.865000e-02 1.149990e-01 + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + vertex 4.741900e-02 4.865000e-02 -1.149990e-01 + endloop + endfacet + facet normal -8.930755e-01 -4.499069e-01 0.000000e+00 + outer loop + vertex -6.470900e-02 0.000000e+00 -1.189590e-01 + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex -6.731300e-02 5.169000e-03 -1.189770e-01 + endloop + endfacet + facet normal -0.000000e+00 -3.482394e-03 -9.999939e-01 + outer loop + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex -6.470900e-02 0.000000e+00 -1.189590e-01 + vertex -6.731300e-02 5.169000e-03 -1.189770e-01 + endloop + endfacet + facet normal 1.809506e-03 4.268996e-02 -9.990867e-01 + outer loop + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex -6.731300e-02 5.169000e-03 -1.189770e-01 + endloop + endfacet + facet normal -9.962953e-01 8.599799e-02 0.000000e+00 + outer loop + vertex -6.731300e-02 5.169000e-03 1.189770e-01 + vertex -6.639700e-02 1.578100e-02 -1.173570e-01 + vertex -6.731300e-02 5.169000e-03 -1.189770e-01 + endloop + endfacet + facet normal 1.499956e-03 4.338930e-02 -9.990572e-01 + outer loop + vertex -4.582000e-02 5.142100e-02 -1.169360e-01 + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex -6.731300e-02 5.169000e-03 -1.189770e-01 + endloop + endfacet + facet normal -2.717889e-01 1.681123e-01 -9.475595e-01 + outer loop + vertex -6.639700e-02 1.578100e-02 -1.173570e-01 + vertex -4.582000e-02 5.142100e-02 -1.169360e-01 + vertex -6.731300e-02 5.169000e-03 -1.189770e-01 + endloop + endfacet + facet normal 7.631736e-01 -6.461934e-01 0.000000e+00 + outer loop + vertex 6.688200e-02 2.727000e-03 1.184560e-01 + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex 6.688200e-02 2.727000e-03 -1.184560e-01 + endloop + endfacet + facet normal 9.993834e-01 3.511297e-02 0.000000e+00 + outer loop + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + vertex 6.688200e-02 2.727000e-03 1.184560e-01 + vertex 6.688200e-02 2.727000e-03 -1.184560e-01 + endloop + endfacet + facet normal 1.370198e-01 6.628661e-02 -9.883479e-01 + outer loop + vertex 6.457300e-02 0.000000e+00 -1.189590e-01 + vertex 6.642500e-02 1.573400e-02 -1.176470e-01 + vertex 6.688200e-02 2.727000e-03 -1.184560e-01 + endloop + endfacet + facet normal -3.420436e-01 9.396840e-01 2.302793e-04 + outer loop + vertex -3.730800e-02 5.600900e-02 -1.142440e-01 + vertex -4.941000e-03 6.779000e-02 -1.120530e-01 + vertex -4.312000e-02 5.389400e-02 -1.165280e-01 + endloop + endfacet + facet normal -1.951906e-03 1.648560e-01 -9.863157e-01 + outer loop + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex -4.582000e-02 5.142100e-02 -1.169360e-01 + vertex -4.312000e-02 5.389400e-02 -1.165280e-01 + endloop + endfacet + facet normal -1.931394e-03 3.016307e-01 -9.534229e-01 + outer loop + vertex 4.811000e-03 6.783700e-02 -1.122140e-01 + vertex 4.304500e-02 5.392100e-02 -1.166940e-01 + vertex -4.312000e-02 5.389400e-02 -1.165280e-01 + endloop + endfacet + facet normal -1.715086e-02 3.488726e-01 -9.370132e-01 + outer loop + vertex -4.941000e-03 6.779000e-02 -1.120530e-01 + vertex 4.811000e-03 6.783700e-02 -1.122140e-01 + vertex -4.312000e-02 5.389400e-02 -1.165280e-01 + endloop + endfacet + facet normal -3.419637e-01 9.397132e-01 0.000000e+00 + outer loop + vertex -4.312000e-02 5.389400e-02 1.165280e-01 + vertex -3.730800e-02 5.600900e-02 -1.142440e-01 + vertex -4.312000e-02 5.389400e-02 -1.165280e-01 + endloop + endfacet +endsolid vcg diff --git a/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_binary b/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_binary new file mode 100644 index 0000000..9cb0fee Binary files /dev/null and b/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_binary differ diff --git a/src/meshcat/tests/dummy_websocket_client.py b/src/meshcat/tests/dummy_websocket_client.py new file mode 100644 index 0000000..ba5f12c --- /dev/null +++ b/src/meshcat/tests/dummy_websocket_client.py @@ -0,0 +1,63 @@ +# The MIT License (MIT) + +# Copyright (c) 2015 İlker Kesen + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + + +# Based on https://github.com/ilkerkesen/tornado-websocket-client-example + +from __future__ import absolute_import, division, print_function +import argparse + +from tornado.ioloop import IOLoop, PeriodicCallback +from tornado import gen +from tornado.websocket import websocket_connect + + +class Client(object): + def __init__(self, url, timeout): + self.url = url + self.timeout = timeout + self.ioloop = IOLoop.instance() + self.ws = None + self.connect() + self.ioloop.start() + + @gen.coroutine + def connect(self): + self.ws = yield websocket_connect(self.url) + self.run() + + @gen.coroutine + def run(self): + while True: + msg = yield self.ws.read_message() + if msg is None: + self.ws = None + break + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("port", type=int) + result = parser.parse_args() + url = "ws://localhost:{:d}".format(result.port) + client = Client(url, 5) diff --git a/src/meshcat/tests/test_drawing.py b/src/meshcat/tests/test_drawing.py index b7d0e09..f2101a7 100644 --- a/src/meshcat/tests/test_drawing.py +++ b/src/meshcat/tests/test_drawing.py @@ -1,6 +1,13 @@ import unittest +import subprocess +import sys +import tempfile import os +from io import StringIO, BytesIO + +import io + import numpy as np import meshcat @@ -10,19 +17,43 @@ class VisualizerTest(unittest.TestCase): def setUp(self): - self.vis = meshcat.Visualizer().open() + self.vis = meshcat.Visualizer() + + if "CI" in os.environ: + port = self.vis.url().split(":")[-1].split("/")[0] + self.dummy_proc = subprocess.Popen([sys.executable, "-m", "meshcat.tests.dummy_websocket_client", str(port)]) + else: + self.vis.open() + self.dummy_proc = None + + self.vis.wait() + + def tearDown(self): + if self.dummy_proc is not None: + self.dummy_proc.kill() class TestDrawing(VisualizerTest): def runTest(self): + self.vis.delete() v = self.vis["shapes"] v.set_transform(tf.translation_matrix([1., 0, 0])) - v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) - v["cube"].set_transform(tf.translation_matrix([0.05, 0.1, 0.15])) - # TODO: cylinder + v["box"].set_object(g.Box([1.0, 0.2, 0.3])) + v["box"].delete() + v["box"].set_object(g.Box([0.1, 0.2, 0.3])) + v["box"].set_transform(tf.translation_matrix([0.05, 0.1, 0.15])) + v["cylinder"].set_object(g.Cylinder(0.2, 0.1), g.MeshLambertMaterial(color=0x22dd22)) + v["cylinder"].set_transform(tf.translation_matrix([0, 0.5, 0.1]).dot(tf.rotation_matrix(-np.pi / 2, [1, 0, 0]))) v["sphere"].set_object(g.Mesh(g.Sphere(0.15), g.MeshLambertMaterial(color=0xff11dd))) v["sphere"].set_transform(tf.translation_matrix([0, 1, 0.15])) - # TODO: ellipsoid + v["ellipsoid"].set_object(g.Ellipsoid([0.3, 0.1, 0.1])) + v["ellipsoid"].set_transform(tf.translation_matrix([0, 1.5, 0.1])) + + v["transparent_ellipsoid"].set_object(g.Mesh( + g.Ellipsoid([0.3, 0.1, 0.1]), + g.MeshLambertMaterial(color=0xffffff, + opacity=0.5))) + v["transparent_ellipsoid"].set_transform(tf.translation_matrix([0, 2.0, 0.1])) v = self.vis["meshes/valkyrie/head"] v.set_object(g.Mesh( @@ -35,9 +66,237 @@ def runTest(self): )) v.set_transform(tf.translation_matrix([0, 0.5, 0.5])) + v = self.vis["meshes/convex"] + v["obj"].set_object(g.Mesh(g.ObjMeshGeometry.from_file(os.path.join(meshcat.viewer_assets_path(), "../tests/data/mesh_0_convex_piece_0.obj")))) + v["stl_ascii"].set_object(g.Mesh(g.StlMeshGeometry.from_file(os.path.join(meshcat.viewer_assets_path(), "../tests/data/mesh_0_convex_piece_0.stl_ascii")))) + v["stl_ascii"].set_transform(tf.translation_matrix([0, -0.5, 0])) + v["stl_binary"].set_object(g.Mesh(g.StlMeshGeometry.from_file(os.path.join(meshcat.viewer_assets_path(), "../tests/data/mesh_0_convex_piece_0.stl_binary")))) + v["stl_binary"].set_transform(tf.translation_matrix([0, -1, 0])) + v["dae"].set_object(g.Mesh(g.DaeMeshGeometry.from_file(os.path.join(meshcat.viewer_assets_path(), "../tests/data/mesh_0_convex_piece_0.dae")))) + v["dae"].set_transform(tf.translation_matrix([0, -1.5, 0])) + + v = self.vis["points"] - v.set_transform(tf.translation_matrix([-1, 0, 0])) - verts = np.random.rand(3, 100000) + v.set_transform(tf.translation_matrix([0, 2, 0])) + verts = np.random.rand(3, 1000000) colors = verts v["random"].set_object(g.PointCloud(verts, colors)) - v["random"].set_transform(tf.translation_matrix([-0.5, -0.5, 0])) \ No newline at end of file + v["random"].set_transform(tf.translation_matrix([-0.5, -0.5, 0])) + + v = self.vis["lines"] + v.set_transform(tf.translation_matrix(([-2, -3, 0]))) + + vertices = np.random.random((3, 10)).astype(np.float32) + v["line_segments"].set_object(g.LineSegments(g.PointsGeometry(vertices))) + + v["line"].set_object(g.Line(g.PointsGeometry(vertices))) + v["line"].set_transform(tf.translation_matrix([0, 1, 0])) + + v["line_loop"].set_object(g.LineLoop(g.PointsGeometry(vertices))) + v["line_loop"].set_transform(tf.translation_matrix([0, 2, 0])) + + v["line_loop_with_material"].set_object(g.LineLoop(g.PointsGeometry(vertices), g.LineBasicMaterial(color=0xff0000))) + v["line_loop_with_material"].set_transform(tf.translation_matrix([0, 3, 0])) + + colors = vertices # Color each line by treating its xyz coordinates as RGB colors + v["line_with_vertex_colors"].set_object(g.Line(g.PointsGeometry(vertices, colors), g.LineBasicMaterial(vertexColors=True))) + v["line_with_vertex_colors"].set_transform(tf.translation_matrix([0, 4, 0])) + + v["triad"].set_object(g.LineSegments( + g.PointsGeometry(position=np.array([ + [0, 0, 0], [1, 0, 0], + [0, 0, 0], [0, 1, 0], + [0, 0, 0], [0, 0, 1]]).astype(np.float32).T, + color=np.array([ + [1, 0, 0], [1, 0.6, 0], + [0, 1, 0], [0.6, 1, 0], + [0, 0, 1], [0, 0.6, 1]]).astype(np.float32).T + ), + g.LineBasicMaterial(vertexColors=True))) + v["triad"].set_transform(tf.translation_matrix(([0, 5, 0]))) + + v["triad_function"].set_object(g.triad(0.5)) + v["triad_function"].set_transform(tf.translation_matrix([0, 6, 0])) + + +class TestMeshStreams(VisualizerTest): + def runTest(self): + """ Applications using meshcat may already have meshes loaded in memory. It is + more efficient to load these meshes with streams rather than going to and then + from a file on disk. To test this we are importing meshes from disk and + converting them into streams so it kind of defeats the intended purpose! But at + least it tests the functionality. + """ + self.vis.delete() + v = self.vis["meshes/convex"] + + # Obj file + filename = os.path.join(meshcat.viewer_assets_path(), + "../tests/data/mesh_0_convex_piece_0.obj") + with open(filename, "r") as f: + fio = StringIO(f.read()) + v["stream_obj"].set_object(g.Mesh(g.ObjMeshGeometry.from_stream(fio))) + v["stream_stl_ascii"].set_transform(tf.translation_matrix([0, 0.0, 0])) + + # STL ASCII + filename = os.path.join(meshcat.viewer_assets_path(), + "../tests/data/mesh_0_convex_piece_0.stl_ascii") + with open(filename, "r") as f: + fio = StringIO(f.read()) + v["stream_stl_ascii"].set_object(g.Mesh(g.StlMeshGeometry.from_stream(fio))) + v["stream_stl_ascii"].set_transform(tf.translation_matrix([0, -0.5, 0])) + + # STL Binary + filename = os.path.join(meshcat.viewer_assets_path(), + "../tests/data/mesh_0_convex_piece_0.stl_binary") + with open(filename, "rb") as f: + fio = BytesIO(f.read()) + v["stream_stl_binary"].set_object(g.Mesh(g.StlMeshGeometry.from_stream(fio))) + v["stream_stl_binary"].set_transform(tf.translation_matrix([0, -1.0, 0])) + + # DAE + filename = os.path.join(meshcat.viewer_assets_path(), + "../tests/data/mesh_0_convex_piece_0.dae") + with open(filename, "r") as f: + fio = StringIO(f.read()) + v["stream_dae"].set_object(g.Mesh(g.DaeMeshGeometry.from_stream(fio))) + v["stream_dae"].set_transform(tf.translation_matrix([0, -1.5, 0])) + + +class TestStandaloneServer(unittest.TestCase): + def setUp(self): + self.zmq_url = "tcp://127.0.0.1:5560" + args = ["meshcat-server", "--zmq-url", self.zmq_url] + + if "CI" not in os.environ: + args.append("--open") + + self.server_proc = subprocess.Popen(args) + self.vis = meshcat.Visualizer(self.zmq_url) + # self.vis = meshcat.Visualizer() + # self.vis.open() + + if "CI" in os.environ: + port = self.vis.url().split(":")[-1].split("/")[0] + self.dummy_proc = subprocess.Popen([sys.executable, "-m", "meshcat.tests.dummy_websocket_client", str(port)]) + else: + # self.vis.open() + self.dummy_proc = None + + self.vis.wait() + + def runTest(self): + v = self.vis["shapes"] + v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) + v.set_transform(tf.translation_matrix([1., 0, 0])) + v.set_transform(tf.translation_matrix([1., 1., 0])) + + def tearDown(self): + if self.dummy_proc is not None: + self.dummy_proc.kill() + self.server_proc.kill() + + +class TestAnimation(VisualizerTest): + def runTest(self): + v = self.vis["shapes"] + v.set_transform(tf.translation_matrix([1., 0, 0])) + v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) + + animation = meshcat.animation.Animation() + with animation.at_frame(v, 0) as frame_vis: + frame_vis.set_transform(tf.translation_matrix([0, 0, 0])) + with animation.at_frame(v, 30) as frame_vis: + frame_vis.set_transform(tf.translation_matrix([2, 0, 0]).dot(tf.rotation_matrix(np.pi/2, [0, 0, 1]))) + v.set_animation(animation) + + +class TestCameraAnimation(VisualizerTest): + def runTest(self): + v = self.vis["shapes"] + v.set_transform(tf.translation_matrix([1., 0, 0])) + v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) + + animation = meshcat.animation.Animation() + with animation.at_frame(v, 0) as frame_vis: + frame_vis.set_transform(tf.translation_matrix([0, 0, 0])) + with animation.at_frame(v, 30) as frame_vis: + frame_vis.set_transform(tf.translation_matrix([2, 0, 0]).dot(tf.rotation_matrix(np.pi/2, [0, 0, 1]))) + with animation.at_frame(v, 0) as frame_vis: + frame_vis["/Cameras/default/rotated/"].set_property("zoom", "number", 1) + with animation.at_frame(v, 30) as frame_vis: + frame_vis["/Cameras/default/rotated/"].set_property("zoom", "number", 0.5) + v.set_animation(animation) + + +class TestStaticHTML(TestDrawing): + def runTest(self): + """Test that we can generate a static HTML file from the Drawing test case and view it.""" + super(TestStaticHTML, self).runTest() + res = self.vis.static_html() + # save to a file + temp = tempfile.mkstemp(suffix=".html") + with open(temp[1], "w") as f: + f.write(res) + + +class TestSetProperty(VisualizerTest): + def runTest(self): + self.vis["/Background"].set_property("top_color", [1, 0, 0]) + + +class TestTriangularMesh(VisualizerTest): + def runTest(self): + """ + Test that we can render meshes from raw vertices and faces as + numpy arrays + """ + v = self.vis["triangular_mesh"] + v.set_transform(tf.rotation_matrix(np.pi/2, [0., 0, 1])) + vertices = np.array([ + [0, 0, 0], + [1, 0, 0], + [1, 0, 1], + [0, 0, 1] + ]) + faces = np.array([ + [0, 1, 2], + [3, 0, 2] + ]) + v.set_object(g.TriangularMeshGeometry(vertices, faces), g.MeshLambertMaterial(color=0xeedd22, wireframe=True)) + + v = self.vis["triangular_mesh_w_vertex_coloring"] + v.set_transform(tf.translation_matrix([1, 0, 0]).dot(tf.rotation_matrix(np.pi/2, [0, 0, 1]))) + colors = vertices + v.set_object(g.TriangularMeshGeometry(vertices, faces, colors), g.MeshLambertMaterial(vertexColors=True, wireframe=True)) + + +class TestOrthographicCamera(VisualizerTest): + def runTest(self): + """ + Test that we can set_object with an OrthographicCamera. + """ + self.vis.set_object(g.Box([0.5, 0.5, 0.5])) + + camera = g.OrthographicCamera( + left=-1, right=1, bottom=-1, top=1, near=-1000, far=1000) + self.vis['/Cameras/default/rotated'].set_object(camera) + self.vis['/Cameras/default'].set_transform( + tf.translation_matrix([0, -1, 0])) + self.vis['/Cameras/default/rotated/'].set_property( + "position", [0, 0, 0]) + self.vis['/Grid'].set_property("visible", False) + +class TestPerspectiveCamera(VisualizerTest): + def runTest(self): + """ + Test that we can set_object with a PerspectiveCamera. + """ + self.vis.set_object(g.Box([0.5, 0.5, 0.5])) + + camera = g.PerspectiveCamera(fov=90) + self.vis['/Cameras/default/rotated'].set_object(camera) + self.vis['/Cameras/default'].set_transform( + tf.translation_matrix([1, -1, 0.5])) + self.vis['/Cameras/default/rotated/'].set_property( + "position", [0, 0, 0]) diff --git a/src/meshcat/tests/test_ports.py b/src/meshcat/tests/test_ports.py new file mode 100644 index 0000000..6f35282 --- /dev/null +++ b/src/meshcat/tests/test_ports.py @@ -0,0 +1,39 @@ +import unittest +import os +import subprocess +import sys + +import meshcat +import meshcat.geometry as g + + +class TestPortScan(unittest.TestCase): + """ + Test that the ZMQ server can correctly handle its default ports + already being in use. + """ + + def setUp(self): + + # the blocking_vis will take up the default fileserver and ZMQ ports + self.blocking_vis = meshcat.Visualizer() + + # this should still work, by chosing a new port + self.vis = meshcat.Visualizer() + + if "CI" in os.environ: + port = self.vis.url().split(":")[-1].split("/")[0] + self.dummy_proc = subprocess.Popen([sys.executable, "-m", "meshcat.tests.dummy_websocket_client", str(port)]) + else: + self.vis.open() + self.dummy_proc = None + + self.vis.wait() + + def runTest(self): + v = self.vis["shapes"] + v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) + + def tearDown(self): + if self.dummy_proc is not None: + self.dummy_proc.kill() diff --git a/src/meshcat/tests/test_start_server.py b/src/meshcat/tests/test_start_server.py new file mode 100644 index 0000000..5eb21f5 --- /dev/null +++ b/src/meshcat/tests/test_start_server.py @@ -0,0 +1,17 @@ +import unittest + +from meshcat.servers.zmqserver import start_zmq_server_as_subprocess + +class TestStartZmqServer(unittest.TestCase): + """ + Test the StartZmqServerAsSubprocess method. + """ + + def test_default_args(self): + proc, zmq_url, web_url = start_zmq_server_as_subprocess() + self.assertIn("127.0.0.1", web_url) + + def test_ngrok(self): + proc, zmq_url, web_url = start_zmq_server_as_subprocess( server_args=["--ngrok_http_tunnel"]) + self.assertIsNotNone(web_url) + self.assertNotIn("127.0.0.1", web_url) diff --git a/src/meshcat/transformations.py b/src/meshcat/transformations.py index a6bed19..886aa4f 100644 --- a/src/meshcat/transformations.py +++ b/src/meshcat/transformations.py @@ -1,5 +1,3 @@ - - # -*- coding: utf-8 -*- # transformations.py @@ -66,8 +64,8 @@ Matrices (M) can be inverted using numpy.linalg.inv(M), be concatenated using numpy.dot(M0, M1), or transform homogeneous coordinate arrays (v) using -numpy.dot(M, v) for shape (4, \*) column vectors, respectively -numpy.dot(v, M.T) for shape (\*, 4) row vectors ("array of points"). +numpy.dot(M, v) for shape (4, *) column vectors, respectively +numpy.dot(v, M.T) for shape (*, 4) row vectors ("array of points"). This module follows the "column vectors on the right" and "row major storage" (C contiguous) conventions. The translation components are in the right column @@ -195,8 +193,6 @@ """ -from __future__ import division, print_function - import math import numpy @@ -891,7 +887,7 @@ def orthogonalization_matrix(lengths, angles): def affine_matrix_from_points(v0, v1, shear=True, scale=True, usesvd=True): """Return affine transform matrix to register two point sets. - v0 and v1 are shape (ndims, \*) arrays of at least ndims non-homogeneous + v0 and v1 are shape (ndims, *) arrays of at least ndims non-homogeneous coordinates, where ndims is the dimensionality of the coordinate space. If shear is False, a similarity transformation matrix is returned. @@ -1000,7 +996,7 @@ def affine_matrix_from_points(v0, v1, shear=True, scale=True, usesvd=True): def superimposition_matrix(v0, v1, scale=False, usesvd=True): """Return matrix to transform given 3D point set into second point set. - v0 and v1 are shape (3, \*) or (4, \*) arrays of at least 3 points. + v0 and v1 are shape (3, *) or (4, *) arrays of at least 3 points. The parameters scale and usesvd are explained in the more general affine_matrix_from_points function. @@ -1438,8 +1434,8 @@ def quaternion_slerp(quat0, quat1, fraction, spin=0, shortestpath=True): True >>> q = quaternion_slerp(q0, q1, 0.5) >>> angle = math.acos(numpy.dot(q0, q)) - >>> numpy.allclose(2, math.acos(numpy.dot(q0, q1)) / angle) or \ - numpy.allclose(2, math.acos(-numpy.dot(q0, q1)) / angle) + >>> (numpy.allclose(2, math.acos(numpy.dot(q0, q1)) / angle) or + numpy.allclose(2, math.acos(-numpy.dot(q0, q1)) / angle)) True """ diff --git a/src/meshcat/viewer b/src/meshcat/viewer index 636f456..65781fc 160000 --- a/src/meshcat/viewer +++ b/src/meshcat/viewer @@ -1 +1 @@ -Subproject commit 636f456239997851efd14b1addd763bf8a789dac +Subproject commit 65781fcb064db536b99a66fe9fcf5bf0b6d1f790 diff --git a/src/meshcat/visualizer.py b/src/meshcat/visualizer.py index 45c47df..b7b9264 100644 --- a/src/meshcat/visualizer.py +++ b/src/meshcat/visualizer.py @@ -1,32 +1,25 @@ -import sys -import subprocess import webbrowser - import umsgpack import numpy as np import zmq +import io +from PIL import Image +from IPython.display import HTML -from .commands import ViewerMessage, SetObject, SetTransform, Delete +from .path import Path +from .commands import SetObject, SetTransform, Delete, SetProperty, SetAnimation, CaptureImage, SetCamTarget +from .geometry import MeshPhongMaterial +from .servers.zmqserver import start_zmq_server_as_subprocess -class CoreVisualizer: +class ViewerWindow: context = zmq.Context() - def __init__(self, zmq_url, start_server, open_url): + def __init__(self, zmq_url, start_server, server_args): if start_server: - # Need -u for unbuffered output: https://stackoverflow.com/a/25572491 - args = [sys.executable, "-u", "-m", "meshcat.servers.zmqserver"] - if zmq_url is not None: - args.append(zmq_url) - print("starting subprocess") - self.server_proc = subprocess.Popen(args, stdout=subprocess.PIPE) - print("started") - print("waiting for zmq url") - self.zmq_url = self.server_proc.stdout.readline().strip().decode("utf-8") - print("zmq_url", self.zmq_url) - self.web_url = self.server_proc.stdout.readline().strip().decode("utf-8") - print("web_url", self.web_url) - # self.server_proc, (self.zmq_url, self.web_url) = create_server(zmq_url=zmq_url) + self.server_proc, self.zmq_url, self.web_url = start_zmq_server_as_subprocess( + zmq_url=zmq_url, server_args=server_args) + else: self.server_proc = None self.zmq_url = zmq_url @@ -35,19 +28,20 @@ def __init__(self, zmq_url, start_server, open_url): if not start_server: self.web_url = self.request_web_url() + # Not sure why this is necessary, but requesting the web URL before + # the websocket connection is made seems to break the receiver + # callback in the server until we reconnect. + self.connect_zmq() - if open_url: - self.open() - else: - print("You can open the visualizer by visiting the following URL:") - print(self.web_url) + print("You can open the visualizer by visiting the following URL:") + print(self.web_url) def connect_zmq(self): self.zmq_socket = self.context.socket(zmq.REQ) self.zmq_socket.connect(self.zmq_url) def request_web_url(self): - self.zmq_socket.send(b"") + self.zmq_socket.send(b"url") response = self.zmq_socket.recv().decode("utf-8") return response @@ -55,78 +49,162 @@ def open(self): webbrowser.open(self.web_url, new=2) return self - def send(self, commands): - self.zmq_socket.send( - umsgpack.packb(ViewerMessage(commands).lower()) - ) + def wait(self): + self.zmq_socket.send(b"wait") + return self.zmq_socket.recv().decode("utf-8") + + def send(self, command): + cmd_data = command.lower() + self.zmq_socket.send_multipart([ + cmd_data["type"].encode("utf-8"), + cmd_data["path"].encode("utf-8"), + umsgpack.packb(cmd_data) + ]) self.zmq_socket.recv() - def __del__(self): - if self.server_proc is not None: - print("killing proc") - self.server_proc.kill() + def get_scene(self): + """Get the static HTML from the ZMQ server.""" + self.zmq_socket.send(b"get_scene") + # we receive the HTML as utf-8-encoded, so decode here + return self.zmq_socket.recv().decode('utf-8') + + def get_image(self, w, h): + cmd_data = CaptureImage(w, h).lower() + self.zmq_socket.send_multipart([ + cmd_data["type"].encode("utf-8"), + "".encode("utf-8"), + umsgpack.packb(cmd_data) + ]) + img_bytes = self.zmq_socket.recv() + img = Image.open(io.BytesIO(img_bytes)) + return img + + +def srcdoc_escape(x): + return x.replace("&", "&").replace('"', """) class Visualizer: - __slots__ = ["core", "path"] + __slots__ = ["window", "path"] - def __init__(self, zmq_url=None, core=None, open=False): - if core is None: - self.core = CoreVisualizer(zmq_url=zmq_url, start_server=(zmq_url is None), open_url=open) + def __init__(self, zmq_url=None, window=None, server_args=[]): + if window is None: + self.window = ViewerWindow(zmq_url=zmq_url, start_server=(zmq_url is None), server_args=server_args) else: - self.core = core - self.path = ["meshcat"] + self.window = window + self.path = Path(("meshcat",)) @staticmethod - def view_into(core, path): - vis = Visualizer(core=core) + def view_into(window, path): + vis = Visualizer(window=window) vis.path = path return vis def open(self): - self.core.open() + self.window.open() return self def url(self): - return self.core.web_url + return self.window.web_url + + def wait(self): + """ + Block until a browser is connected to the server + """ + return self.window.wait() - def jupyter_cell(self): - from IPython.display import HTML + def jupyter_cell(self, height=400): + """ + Render the visualizer in a jupyter notebook or jupyterlab cell. + + For this to work, it should be the very last command in the given jupyter + cell. + """ + return HTML(""" +
+ +
+ """.format(url=self.url(), height=height)) + + def render_static(self, height=400): + """ + Render a static snapshot of the visualizer in a jupyter notebook or + jupyterlab cell. The resulting snapshot of the visualizer will still be an + interactive 3D scene, but it won't be affected by any future `set_transform` + or `set_object` calls. + + Note: this method should work well even when your jupyter kernel is running + on a different machine or inside a container. + """ return HTML(""" -
- -
-""".format(url=self.url())) +
+ +
+ """.format(srcdoc=srcdoc_escape(self.static_html()), height=height)) def __getitem__(self, path): - return Visualizer.view_into(self.core, self.path + path.split("/")) + return Visualizer.view_into(self.window, self.path.append(path)) - def set_object(self, object): - return self.core.send([SetObject(object, self.path)]) + def set_object(self, geometry, material=None): + return self.window.send(SetObject(geometry, material, self.path)) def set_transform(self, matrix=np.eye(4)): - return self.core.send([SetTransform(matrix, self.path)]) + return self.window.send(SetTransform(matrix, self.path)) + + def set_property(self, key, value): + return self.window.send(SetProperty(key, value, self.path)) + + def set_animation(self, animation, play=True, repetitions=1): + return self.window.send(SetAnimation(animation, play=play, repetitions=repetitions)) + + def set_cam_target(self, value): + """Set camera target (in right-handed coordinates (x,y,z)).""" + v = list(value) + v[1], v[2] = v[2], -v[1] # convert to left-handed (x,z,-y) + return self.window.send(SetCamTarget(v)) + + def set_cam_pos(self, value): + """Set camera position (in right-handed coordinates (x,y,z)).""" + path = "/Cameras/default/rotated/" + v = list(value) + v[1], v[2] = v[2], -v[1] # convert to left-handed (x,z,-y) + return self[path].set_property("position", v) + + def get_image(self, w=None, h=None): + """Save an image""" + return self.window.get_image(w, h) def delete(self): - return self.core.send([Delete(self.path)]) + return self.window.send(Delete(self.path)) def close(self): - self.core.close() + self.window.close() + + def static_html(self): + """ + Generate and save a static HTML file that standalone encompasses the visualizer and contents. + + Ask the server for the scene (since the server knows it), and pack it all into an + HTML blob for future use. + """ + return self.window.get_scene() def __repr__(self): - return "".format(core=self.core, path=self.path) + return "".format(window=self.window, path=self.path) if __name__ == '__main__': import time import sys - + args = [] if len(sys.argv) > 1: zmq_url = sys.argv[1] + if len(sys.argv) > 2: + args = sys.argv[2:] else: zmq_url = None - core = CoreVisualizer(zmq_url, zmq_url is None, True) + window = ViewerWindow(zmq_url, zmq_url is None, True, args) while True: time.sleep(100) diff --git a/utils/zmqrelay.py b/utils/zmqrelay.py index 89a7f4c..fe71899 100644 --- a/utils/zmqrelay.py +++ b/utils/zmqrelay.py @@ -21,7 +21,7 @@ async def handle_new_connection(websocket, path): msg = await my_queue.get() await websocket.send(msg) except websockets.ConnectionClosed as e: - queues.remove(queue) + queues.remove(my_queue) start_server = websockets.serve(handle_new_connection, '127.0.0.1', 8765) asyncio.get_event_loop().run_until_complete(start_server)